Compare commits
254 Commits
ep-fix-pyt
...
dev-repl-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df8499a664 | ||
|
|
c33ed0162f | ||
|
|
33b745bf37 | ||
|
|
59c2c7e0dd | ||
|
|
b57d117c7e | ||
|
|
40c0273582 | ||
|
|
b5c0fdd4e3 | ||
|
|
1296b4671b | ||
|
|
b9ba7de970 | ||
|
|
0fa535476d | ||
|
|
b0d28fcfa4 | ||
|
|
6b1ceaf93b | ||
|
|
db7f048d82 | ||
|
|
4842e16f7c | ||
|
|
cd1be87231 | ||
|
|
89a8970e4a | ||
|
|
2e7aafe3a0 | ||
|
|
39310d4798 | ||
|
|
23be6baa0e | ||
|
|
7ed4b012e5 | ||
|
|
e38d87f616 | ||
|
|
f6e9a91db7 | ||
|
|
423aafea99 | ||
|
|
78ae095422 | ||
|
|
297d612165 | ||
|
|
8ae78915ed | ||
|
|
02b0bbaf76 | ||
|
|
2062c3fb13 | ||
|
|
37c991c292 | ||
|
|
3142c39435 | ||
|
|
898b7b4c38 | ||
|
|
c5ff6bbbbd | ||
|
|
50dac4248d | ||
|
|
df8fd2aa98 | ||
|
|
e597be0c10 | ||
|
|
64fb9ea259 | ||
|
|
52f689cb36 | ||
|
|
c51ca23c95 | ||
|
|
0bacd6c5ca | ||
|
|
f46fdd8d27 | ||
|
|
27fd3aac36 | ||
|
|
92a60c8529 | ||
|
|
ee030befe2 | ||
|
|
5f956d51bd | ||
|
|
590c8945fc | ||
|
|
c056b03d89 | ||
|
|
b4cf7383a4 | ||
|
|
9c003314be | ||
|
|
06a24797b4 | ||
|
|
ad8af02598 | ||
|
|
798fc4aff6 | ||
|
|
eb0a8d4900 | ||
|
|
d1e940e66b | ||
|
|
781282dd40 | ||
|
|
730265b198 | ||
|
|
0897ae0847 | ||
|
|
7b921d8f43 | ||
|
|
e22e0e5fb1 | ||
|
|
c9bcbbfd73 | ||
|
|
172e56f4b7 | ||
|
|
1e3406219c | ||
|
|
ad8376f6cc | ||
|
|
bf81141508 | ||
|
|
05c2aff020 | ||
|
|
bd3f1d4632 | ||
|
|
9743b3cbee | ||
|
|
50c809f3fa | ||
|
|
0559e0beee | ||
|
|
cf347ce318 | ||
|
|
eea96b2477 | ||
|
|
390bcd144e | ||
|
|
383f6ba968 | ||
|
|
450f5cc9ba | ||
|
|
353260e583 | ||
|
|
b300a6118f | ||
|
|
86410d0667 | ||
|
|
5d84cc6c3f | ||
|
|
ef4136d69e | ||
|
|
16e013a90d | ||
|
|
595e171f34 | ||
|
|
f01589a9d6 | ||
|
|
bf6c8b3459 | ||
|
|
65189a0b69 | ||
|
|
d950e70482 | ||
|
|
54e150e856 | ||
|
|
5959ca6c82 | ||
|
|
6b330140ff | ||
|
|
24b7d886aa | ||
|
|
b27e93965d | ||
|
|
f563a91095 | ||
|
|
b0cfcca1e5 | ||
|
|
0421a12c9c | ||
|
|
cb42387606 | ||
|
|
731f564a04 | ||
|
|
d41abaea80 | ||
|
|
13af38d9ca | ||
|
|
7d107764fe | ||
|
|
b871ae4909 | ||
|
|
114541e4fc | ||
|
|
0982712225 | ||
|
|
965ce2a47b | ||
|
|
7316888913 | ||
|
|
fe290f97e7 | ||
|
|
794819ead2 | ||
|
|
2ef8d50387 | ||
|
|
73766cf355 | ||
|
|
38f42a9386 | ||
|
|
0ec522227b | ||
|
|
cb42b96ca8 | ||
|
|
52f48e2400 | ||
|
|
36a5234efc | ||
|
|
065c23b648 | ||
|
|
b8ed3bd4d0 | ||
|
|
5486894ab8 | ||
|
|
8bafab3e0f | ||
|
|
0b8c5cd5a3 | ||
|
|
9c95c11b7b | ||
|
|
3a8eae8a39 | ||
|
|
b98eb79b38 | ||
|
|
7d6d5b35a3 | ||
|
|
bc82a001ee | ||
|
|
0b7737980d | ||
|
|
f0a7607b2b | ||
|
|
09d1a5d630 | ||
|
|
c3898889fd | ||
|
|
233dbfecdc | ||
|
|
503108d247 | ||
|
|
a45d9203f1 | ||
|
|
163180e8d9 | ||
|
|
b81aa809ad | ||
|
|
78efb4247b | ||
|
|
e51a02cd56 | ||
|
|
f8d4a239b3 | ||
|
|
ae05f0bab7 | ||
|
|
e2132ed193 | ||
|
|
50e9d524d6 | ||
|
|
fbd16c232b | ||
|
|
123f2dafd2 | ||
|
|
16504fd3c8 | ||
|
|
997b8d898e | ||
|
|
ceb518ab4b | ||
|
|
0ed6c4deed | ||
|
|
6c09fca355 | ||
|
|
049526c61c | ||
|
|
a799178420 | ||
|
|
f7b82d5c54 | ||
|
|
a51d1179d3 | ||
|
|
cbed2e45a8 | ||
|
|
418d7a0317 | ||
|
|
3bce633480 | ||
|
|
48327532c5 | ||
|
|
26f71cfeb5 | ||
|
|
3d4e44b776 | ||
|
|
86f3e524ba | ||
|
|
056d61238e | ||
|
|
926610da14 | ||
|
|
75251f933a | ||
|
|
d959d54aec | ||
|
|
0933b7378f | ||
|
|
e3b200b319 | ||
|
|
fb9c0e4499 | ||
|
|
40522b6cba | ||
|
|
b6edd15de2 | ||
|
|
16042f45c7 | ||
|
|
b28ab0ec1d | ||
|
|
add8906504 | ||
|
|
3345365546 | ||
|
|
862dc50c64 | ||
|
|
0941091421 | ||
|
|
0bd69823a4 | ||
|
|
6a01533ca7 | ||
|
|
7d18a1b5a6 | ||
|
|
0bf7e41984 | ||
|
|
95a25e4a3e | ||
|
|
a2c4eef9de | ||
|
|
2f81937643 | ||
|
|
48bf58b162 | ||
|
|
761a7cf01e | ||
|
|
8a76f71003 | ||
|
|
fd90d13ae3 | ||
|
|
a62aab3f1b | ||
|
|
bafd49ee17 | ||
|
|
1f0b461d6f | ||
|
|
1712274b85 | ||
|
|
ea203102e6 | ||
|
|
3c72591b17 | ||
|
|
9a19a5e4f0 | ||
|
|
f387d4aca4 | ||
|
|
1bb6f6a74a | ||
|
|
2ad4292465 | ||
|
|
37c3758748 | ||
|
|
ae9cad5c58 | ||
|
|
bd14e14974 | ||
|
|
85319217cd | ||
|
|
240acb4782 | ||
|
|
e6bc40c223 | ||
|
|
76fcb4d573 | ||
|
|
a92317dd27 | ||
|
|
087a317f70 | ||
|
|
c63fd746d8 | ||
|
|
f29a446aab | ||
|
|
d26bf4e454 | ||
|
|
7aa18ae448 | ||
|
|
97da3d7c7b | ||
|
|
10e8a68cf5 | ||
|
|
209a34b102 | ||
|
|
a6df84c136 | ||
|
|
adb9a5cbf4 | ||
|
|
d491eb6d51 | ||
|
|
0f20f4d2f0 | ||
|
|
9304e6cb6e | ||
|
|
21f043d697 | ||
|
|
d6bb46208f | ||
|
|
e2f7cc9d86 | ||
|
|
0880e7297d | ||
|
|
4f23bc973f | ||
|
|
1afabc3d01 | ||
|
|
cdf5111a0d | ||
|
|
70cf127afd | ||
|
|
ac421d0a6d | ||
|
|
9f5b23ecdd | ||
|
|
f8551c9800 | ||
|
|
4befb6089f | ||
|
|
c902d22458 | ||
|
|
7451ffcd93 | ||
|
|
410471a4d0 | ||
|
|
a2b3490355 | ||
|
|
3b8e1ecaa4 | ||
|
|
0176c399f9 | ||
|
|
2287567b12 | ||
|
|
d9b8b9a2c0 | ||
|
|
17f473a9f1 | ||
|
|
110cb5bca7 | ||
|
|
1149c3e30c | ||
|
|
735b98d6f8 | ||
|
|
359158e594 | ||
|
|
9a5e8227d0 | ||
|
|
287665a6fa | ||
|
|
82f16a8528 | ||
|
|
45b4b682cc | ||
|
|
7a96e1f142 | ||
|
|
a48f246c50 | ||
|
|
dd00b33545 | ||
|
|
51eab9a902 | ||
|
|
4b47763109 | ||
|
|
d4960babe5 | ||
|
|
3856fd989e | ||
|
|
2ac5ed4fb0 | ||
|
|
1b80d7dc16 | ||
|
|
34266fc636 | ||
|
|
e3ced91a43 | ||
|
|
a37b3fb0ec | ||
|
|
1284c201b5 | ||
|
|
feb0c5b34a |
12
.claude/commands/ci-report.md
Normal file
@@ -0,0 +1,12 @@
|
||||
Generate a CI failure report for PR $PR_NUM_OR_URL (or current branch if no argument given).
|
||||
|
||||
Run `./bin/mage ci-report $PR_NUM_OR_URL` to generate the report, then:
|
||||
|
||||
1. **Summarize the failures** - What tests failed and in which jobs?
|
||||
2. **Categorize** - Are these real failures, flaky tests, or infrastructure issues?
|
||||
3. **Recommend next steps**:
|
||||
- If flaky: suggest rerunning with `gh run rerun <run-id> --failed -R metabase/metabase`
|
||||
- If real: identify the root cause and suggest fixes
|
||||
- If infrastructure: note the issue type (timeout, OOM, network)
|
||||
|
||||
Focus on actionable insights. If all checks are passing, confirm that briefly.
|
||||
@@ -29,7 +29,7 @@ This guide covers Clojure and ClojureScript coding conventions for Metabase. See
|
||||
|
||||
**Comments:**
|
||||
|
||||
- `TODO` format: `;; TODO (Name M/D/YY) -- description`
|
||||
- `TODO` format: `;; TODO (Name YYYY-MM-DD) -- description`
|
||||
|
||||
## Code Organization
|
||||
|
||||
|
||||
504
.claude/skills/analytics-events/SKILL.md
Normal file
@@ -0,0 +1,504 @@
|
||||
---
|
||||
name: analytics-events
|
||||
description: Add product analytics events to track user interactions in the Metabase frontend
|
||||
allowed-tools: Read, Write, Edit, Grep, Glob
|
||||
---
|
||||
|
||||
# Frontend Analytics Events Skill
|
||||
|
||||
This skill helps you add product analytics (Snowplow) events to track user interactions in the Metabase frontend codebase.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
Analytics events in Metabase use Snowplow with typed event schemas. All events must be defined in TypeScript types before use.
|
||||
|
||||
**Key Files:**
|
||||
- `frontend/src/metabase-types/analytics/event.ts` - Event type definitions
|
||||
- `frontend/src/metabase-types/analytics/schema.ts` - Schema registry
|
||||
- `frontend/src/metabase/lib/analytics.ts` - Core tracking functions
|
||||
- Feature-specific `analytics.ts` files - Tracking function wrappers
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
When adding a new analytics event:
|
||||
|
||||
- [ ] Define event type in `frontend/src/metabase-types/analytics/event.ts`
|
||||
- [ ] Add event to appropriate union type (e.g., `DataStudioEvent`, `SimpleEvent`)
|
||||
- [ ] Create tracking function in feature's `analytics.ts` file
|
||||
- [ ] Import and call tracking function at the interaction point
|
||||
- [ ] Use `trackSimpleEvent()` for basic events (most common)
|
||||
|
||||
## Event Schema Types
|
||||
|
||||
### 1. Simple Events (Most Common)
|
||||
|
||||
Use `SimpleEventSchema` for straightforward tracking. It supports these standard fields:
|
||||
|
||||
```typescript
|
||||
type SimpleEventSchema = {
|
||||
event: string; // Required: Event name (snake_case)
|
||||
target_id?: number | null; // Optional: ID of affected entity
|
||||
triggered_from?: string | null; // Optional: UI location/context
|
||||
duration_ms?: number | null; // Optional: Duration in milliseconds
|
||||
result?: string | null; // Optional: Outcome (e.g., "success", "failure")
|
||||
event_detail?: string | null; // Optional: Additional detail/variant
|
||||
};
|
||||
```
|
||||
|
||||
**When to use:** 90% of events fit this schema. Use for clicks, opens, closes, creates, deletes, etc.
|
||||
|
||||
### 2. Custom Schemas (legacy, no events are being added)
|
||||
|
||||
Consider adding new event schema only in very special cases.
|
||||
|
||||
**Examples:** `DashboardEventSchema`, `CleanupEventSchema`, `QuestionEventSchema`
|
||||
|
||||
## Step-by-Step: Adding a Simple Event
|
||||
|
||||
### Example: Track when a user applies filters in a table picker
|
||||
|
||||
#### Step 1: Define Event Types
|
||||
|
||||
Add event type definitions to `frontend/src/metabase-types/analytics/event.ts`:
|
||||
|
||||
```typescript
|
||||
export type DataStudioTablePickerFiltersAppliedEvent = ValidateEvent<{
|
||||
event: "data_studio_table_picker_filters_applied";
|
||||
}>;
|
||||
|
||||
export type DataStudioTablePickerFiltersClearedEvent = ValidateEvent<{
|
||||
event: "data_studio_table_picker_filters_cleared";
|
||||
}>;
|
||||
```
|
||||
|
||||
#### Step 2: Add to Union Type
|
||||
|
||||
Find or create the appropriate union type and add your events:
|
||||
|
||||
```typescript
|
||||
export type DataStudioEvent =
|
||||
| DataStudioLibraryCreatedEvent
|
||||
| DataStudioTablePublishedEvent
|
||||
| DataStudioGlossaryCreatedEvent
|
||||
| DataStudioGlossaryEditedEvent
|
||||
| DataStudioGlossaryDeletedEvent
|
||||
| DataStudioTablePickerFiltersAppliedEvent // <- Add here
|
||||
| DataStudioTablePickerFiltersClearedEvent; // <- Add here
|
||||
```
|
||||
|
||||
#### Step 3: Create Tracking Functions
|
||||
|
||||
In your feature's `analytics.ts` file (e.g., `enterprise/frontend/src/metabase-enterprise/data-studio/analytics.ts`):
|
||||
|
||||
```typescript
|
||||
import { trackSimpleEvent } from "metabase/lib/analytics";
|
||||
|
||||
export const trackDataStudioTablePickerFiltersApplied = () => {
|
||||
trackSimpleEvent({
|
||||
event: "data_studio_table_picker_filters_applied",
|
||||
});
|
||||
};
|
||||
|
||||
export const trackDataStudioTablePickerFiltersCleared = () => {
|
||||
trackSimpleEvent({
|
||||
event: "data_studio_table_picker_filters_cleared",
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
#### Step 4: Use in Components
|
||||
|
||||
Import and call the tracking function at the interaction point:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
trackDataStudioTablePickerFiltersApplied,
|
||||
trackDataStudioTablePickerFiltersCleared,
|
||||
} from "metabase-enterprise/data-studio/analytics";
|
||||
|
||||
function FilterPopover({ filters, onSubmit }) {
|
||||
const handleReset = () => {
|
||||
trackDataStudioTablePickerFiltersCleared(); // <- Track here
|
||||
onSubmit(emptyFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
trackDataStudioTablePickerFiltersApplied(); // <- Track here
|
||||
onSubmit(form);
|
||||
}}
|
||||
>
|
||||
{/* form content */}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using SimpleEventSchema Fields
|
||||
|
||||
### Example: Event with target_id
|
||||
|
||||
```typescript
|
||||
// Type definition
|
||||
export type DataStudioLibraryCreatedEvent = ValidateEvent<{
|
||||
event: "data_studio_library_created";
|
||||
target_id: number | null;
|
||||
}>;
|
||||
|
||||
// Tracking function
|
||||
export const trackDataStudioLibraryCreated = (id: CollectionId) => {
|
||||
trackSimpleEvent({
|
||||
event: "data_studio_library_created",
|
||||
target_id: Number(id),
|
||||
});
|
||||
};
|
||||
|
||||
// Usage
|
||||
trackDataStudioLibraryCreated(newLibrary.id);
|
||||
```
|
||||
|
||||
### Example: Event with triggered_from
|
||||
|
||||
```typescript
|
||||
// Type definition
|
||||
export type NewButtonClickedEvent = ValidateEvent<{
|
||||
event: "new_button_clicked";
|
||||
triggered_from: "app-bar" | "empty-collection";
|
||||
}>;
|
||||
|
||||
// Tracking function
|
||||
export const trackNewButtonClicked = (location: "app-bar" | "empty-collection") => {
|
||||
trackSimpleEvent({
|
||||
event: "new_button_clicked",
|
||||
triggered_from: location,
|
||||
});
|
||||
};
|
||||
|
||||
// Usage
|
||||
<Button onClick={() => {
|
||||
trackNewButtonClicked("app-bar");
|
||||
handleCreate();
|
||||
}}>
|
||||
New
|
||||
</Button>
|
||||
```
|
||||
|
||||
### Example: Event with event_detail
|
||||
|
||||
```typescript
|
||||
// Type definition
|
||||
export type MetadataEditEvent = ValidateEvent<{
|
||||
event: "metadata_edited";
|
||||
event_detail: "type_casting" | "semantic_type_change" | "visibility_change";
|
||||
triggered_from: "admin" | "data_studio";
|
||||
}>;
|
||||
|
||||
// Tracking function
|
||||
export const trackMetadataChange = (
|
||||
detail: "type_casting" | "semantic_type_change" | "visibility_change",
|
||||
location: "admin" | "data_studio"
|
||||
) => {
|
||||
trackSimpleEvent({
|
||||
event: "metadata_edited",
|
||||
event_detail: detail,
|
||||
triggered_from: location,
|
||||
});
|
||||
};
|
||||
|
||||
// Usage
|
||||
trackMetadataChange("semantic_type_change", "data_studio");
|
||||
```
|
||||
|
||||
### Example: Event with result and duration
|
||||
|
||||
```typescript
|
||||
// Type definition
|
||||
export type MoveToTrashEvent = ValidateEvent<{
|
||||
event: "moved-to-trash";
|
||||
target_id: number | null;
|
||||
triggered_from: "collection" | "detail_page" | "cleanup_modal";
|
||||
duration_ms: number | null;
|
||||
result: "success" | "failure";
|
||||
event_detail: "question" | "model" | "metric" | "dashboard";
|
||||
}>;
|
||||
|
||||
// Tracking function
|
||||
export const trackMoveToTrash = (params: {
|
||||
targetId: number | null;
|
||||
triggeredFrom: "collection" | "detail_page" | "cleanup_modal";
|
||||
durationMs: number | null;
|
||||
result: "success" | "failure";
|
||||
itemType: "question" | "model" | "metric" | "dashboard";
|
||||
}) => {
|
||||
trackSimpleEvent({
|
||||
event: "moved-to-trash",
|
||||
target_id: params.targetId,
|
||||
triggered_from: params.triggeredFrom,
|
||||
duration_ms: params.durationMs,
|
||||
result: params.result,
|
||||
event_detail: params.itemType,
|
||||
});
|
||||
};
|
||||
|
||||
// Usage with timing
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
await moveToTrash(item);
|
||||
trackMoveToTrash({
|
||||
targetId: item.id,
|
||||
triggeredFrom: "collection",
|
||||
durationMs: Date.now() - startTime,
|
||||
result: "success",
|
||||
itemType: "question",
|
||||
});
|
||||
} catch (error) {
|
||||
trackMoveToTrash({
|
||||
targetId: item.id,
|
||||
triggeredFrom: "collection",
|
||||
durationMs: Date.now() - startTime,
|
||||
result: "failure",
|
||||
itemType: "question",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Event Names (snake_case)
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
"data_studio_library_created"
|
||||
"table_picker_filters_applied"
|
||||
"metabot_chat_opened"
|
||||
|
||||
// Bad
|
||||
"DataStudioLibraryCreated" // Wrong case
|
||||
"tablePickerFiltersApplied" // Wrong case
|
||||
"filters-applied" // Use underscore, not hyphen
|
||||
```
|
||||
|
||||
### Event Type Names (PascalCase with "Event" suffix)
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
DataStudioLibraryCreatedEvent
|
||||
TablePickerFiltersAppliedEvent
|
||||
MetabotChatOpenedEvent
|
||||
|
||||
// Bad
|
||||
dataStudioLibraryCreated // Wrong case
|
||||
DataStudioLibraryCreated // Missing "Event" suffix
|
||||
```
|
||||
|
||||
### Tracking Function Names (camelCase with "track" prefix)
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
trackDataStudioLibraryCreated
|
||||
trackTablePickerFiltersApplied
|
||||
trackMetabotChatOpened
|
||||
|
||||
// Bad
|
||||
DataStudioLibraryCreated // Missing "track" prefix
|
||||
track_library_created // Wrong case
|
||||
logLibraryCreated // Use "track" prefix
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Feature-Specific Union Types
|
||||
|
||||
Group related events together:
|
||||
|
||||
```typescript
|
||||
export type DataStudioEvent =
|
||||
| DataStudioLibraryCreatedEvent
|
||||
| DataStudioTablePublishedEvent
|
||||
| DataStudioGlossaryCreatedEvent;
|
||||
|
||||
export type MetabotEvent =
|
||||
| MetabotChatOpenedEvent
|
||||
| MetabotRequestSentEvent
|
||||
| MetabotFixQueryClickedEvent;
|
||||
|
||||
// Then add to SimpleEvent union
|
||||
export type SimpleEvent =
|
||||
| /* other events */
|
||||
| DataStudioEvent
|
||||
| MetabotEvent
|
||||
| /* more events */;
|
||||
```
|
||||
|
||||
### Pattern 2: Conditional Tracking
|
||||
|
||||
Track different events based on user action:
|
||||
|
||||
```typescript
|
||||
const handleSave = async () => {
|
||||
if (isNewItem) {
|
||||
await createItem(data);
|
||||
trackItemCreated(newItem.id);
|
||||
} else {
|
||||
await updateItem(id, data);
|
||||
trackItemUpdated(id);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Don't: Add custom fields to SimpleEvent
|
||||
|
||||
```typescript
|
||||
// WRONG - SimpleEvent doesn't support custom fields
|
||||
export const trackFiltersApplied = (filters: FilterState) => {
|
||||
trackSimpleEvent({
|
||||
event: "filters_applied",
|
||||
data_layer: filters.dataLayer, // ❌ Not in SimpleEventSchema
|
||||
data_source: filters.dataSource, // ❌ Not in SimpleEventSchema
|
||||
with_owner: filters.hasOwner, // ❌ Not in SimpleEventSchema
|
||||
});
|
||||
};
|
||||
|
||||
// RIGHT - Use only standard SimpleEventSchema fields
|
||||
export const trackFiltersApplied = () => {
|
||||
trackSimpleEvent({
|
||||
event: "filters_applied",
|
||||
});
|
||||
};
|
||||
|
||||
// Or use event_detail for a single variant
|
||||
export const trackFilterApplied = (filterType: string) => {
|
||||
trackSimpleEvent({
|
||||
event: "filter_applied",
|
||||
event_detail: filterType, // ✓ "data_layer", "data_source", etc.
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Don't: Forget to add event to union type
|
||||
|
||||
```typescript
|
||||
// Define the event
|
||||
export type NewFeatureClickedEvent = ValidateEvent<{
|
||||
event: "new_feature_clicked";
|
||||
}>;
|
||||
|
||||
// ❌ WRONG - Forgot to add to SimpleEvent union
|
||||
// Event won't be recognized by TypeScript
|
||||
|
||||
// ✓ RIGHT - Add to appropriate union
|
||||
export type SimpleEvent =
|
||||
| /* other events */
|
||||
| NewFeatureClickedEvent;
|
||||
```
|
||||
|
||||
### Don't: Mix up event name formats
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
event: "dataStudioLibraryCreated" // camelCase
|
||||
event: "data-studio-library-created" // kebab-case
|
||||
event: "Data_Studio_Library_Created" // Mixed case
|
||||
|
||||
// RIGHT
|
||||
event: "data_studio_library_created" // snake_case
|
||||
```
|
||||
|
||||
### Don't: Track PII or sensitive data
|
||||
|
||||
```typescript
|
||||
// WRONG - Don't track user emails, names, or sensitive data
|
||||
trackSimpleEvent({
|
||||
event: "user_logged_in",
|
||||
event_detail: user.email, // ❌ PII
|
||||
});
|
||||
|
||||
// RIGHT - Track non-sensitive identifiers only
|
||||
trackSimpleEvent({
|
||||
event: "user_logged_in",
|
||||
target_id: user.id, // ✓ Just the ID
|
||||
});
|
||||
```
|
||||
|
||||
### Don't: Forget to track both success and failure
|
||||
|
||||
```typescript
|
||||
// WRONG - Only tracking success
|
||||
try {
|
||||
await saveData();
|
||||
trackDataSaved();
|
||||
} catch (error) {
|
||||
// ❌ No tracking for failure case
|
||||
}
|
||||
|
||||
// RIGHT - Track both outcomes
|
||||
try {
|
||||
await saveData();
|
||||
trackDataSaved({ result: "success" });
|
||||
} catch (error) {
|
||||
trackDataSaved({ result: "failure" });
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Analytics Events
|
||||
|
||||
While developing, you can verify events are firing:
|
||||
|
||||
1. **Check browser console** - When `SNOWPLOW_ENABLED=true` in dev, events are logged
|
||||
2. **Use shouldLogAnalytics** - Set in `metabase/env` to see all analytics in console
|
||||
3. **Check Snowplow debugger** - Browser extension for Snowplow events
|
||||
|
||||
Example console output:
|
||||
|
||||
```
|
||||
[SNOWPLOW EVENT | event sent:true], data_studio_table_picker_filters_applied
|
||||
```
|
||||
|
||||
## File Organization
|
||||
|
||||
### Where to put tracking functions:
|
||||
|
||||
```
|
||||
Feature-specific analytics functions:
|
||||
frontend/src/metabase/{feature}/analytics.ts
|
||||
enterprise/frontend/src/metabase-enterprise/{feature}/analytics.ts
|
||||
|
||||
Event type definitions (all in one place):
|
||||
frontend/src/metabase-types/analytics/event.ts
|
||||
|
||||
Core tracking utilities:
|
||||
frontend/src/metabase/lib/analytics.ts
|
||||
```
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
See these files for reference:
|
||||
|
||||
- **Simple events**: `enterprise/frontend/src/metabase-enterprise/data-studio/analytics.ts`
|
||||
- **Events with variants**: `frontend/src/metabase/dashboard/analytics.ts`
|
||||
- **Complex events**: `frontend/src/metabase/query_builder/analytics.js`
|
||||
- **Event type examples**: `frontend/src/metabase-types/analytics/event.ts`
|
||||
|
||||
## Workflow Summary
|
||||
|
||||
1. **Identify the user interaction** to track
|
||||
2. **Decide on event name** (snake_case, descriptive)
|
||||
3. **Define event type** in `event.ts` using `ValidateEvent`
|
||||
4. **Add to union type** (create feature union if needed)
|
||||
5. **Create tracking function** in feature's `analytics.ts`
|
||||
6. **Import and call** at the interaction point
|
||||
7. **Test** that events fire correctly
|
||||
|
||||
## Tips
|
||||
|
||||
- **Be specific** - `filters_applied` is better than `action_performed`
|
||||
- **Use past tense** - `library_created` not `create_library`
|
||||
- **Group related events** - Create feature-specific event union types
|
||||
- **Track meaningful actions** - Not every click needs tracking
|
||||
- **Consider the data** - What would you want to analyze later?
|
||||
- **Stay consistent** - Follow existing naming patterns in the codebase
|
||||
- **Document context** - Use `triggered_from` to track where the action happened
|
||||
@@ -54,7 +54,7 @@ Use this to scan through changes efficiently:
|
||||
- [ ] Public vars in `src` or `enterprise/backend/src` have useful docstrings
|
||||
- [ ] Docstrings use Markdown conventions
|
||||
- [ ] References use `[[other-var]]` not backticks
|
||||
- [ ] `TODO` comments include author and date: `;; TODO (Name 1/1/25) -- description`
|
||||
- [ ] `TODO` comments include author and date: `;; TODO (Name 2025-01-01) -- description`
|
||||
|
||||
### Code Organization
|
||||
|
||||
@@ -118,7 +118,7 @@ Quick scan for common issues:
|
||||
| `update-db`, `save-model` | Missing `!` for side effects: `update-db!`, `save-model!` |
|
||||
| `snake_case_var` | Should use kebab-case |
|
||||
| Public var without docstring | Add docstring explaining purpose |
|
||||
| `;; TODO fix this` | Missing author/date: `;; TODO (Name 1/1/25) -- description` |
|
||||
| `;; TODO fix this` | Missing author/date: `;; TODO (Name 2025-01-01) -- description` |
|
||||
| `(defn foo ...)` in namespace used elsewhere | Should be `(defn ^:private foo ...)` |
|
||||
| Function > 20 lines | Consider breaking up into smaller functions |
|
||||
| `/api/dashboards/:id` | Use singular: `/api/dashboard/:id` |
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
:metabase/missing-test-expr-requires-in-cljs {:level :warning}
|
||||
:metabase/modules {:level :warning} ; more config in `.clj-kondo/config/modules/config.edn`
|
||||
:metabase/namespace-name {:level :warning}
|
||||
:metabase/no-jsqlparser-imports {:level :warning}
|
||||
:metabase/require-shape-checker {:level :warning}
|
||||
:metabase/test-helpers-use-non-thread-safe-functions {:level :warning} ; more config in `.clj-kondo/config/test-helpers/config.edn`
|
||||
:metabase/tests-must-live-in-known-modules {:level :warning}
|
||||
@@ -219,7 +220,8 @@
|
||||
;; misc config for built-in linters
|
||||
;;
|
||||
|
||||
:invalid-arity {:skip-args [metabase.lib.util.match/match]} ; TODO (cam): can we fix these?
|
||||
:invalid-arity {:skip-args [metabase.lib.util.match/match
|
||||
metabase.lib.util.match/match-lite]} ; TODO (cam): can we fix these?
|
||||
:refer-all {:level :warning, :exclude [clojure.test]}
|
||||
:unused-referred-var {:exclude {compojure.core [GET DELETE POST PUT]}}
|
||||
|
||||
@@ -238,7 +240,6 @@
|
||||
(metabase.lib.util.match/match)
|
||||
(metabase.lib.util.match/match-one)
|
||||
(metabase.lib.util.match/match-lite)
|
||||
(metabase.lib.util.match/match-lite-recursive)
|
||||
(metabase.lib.util.match/replace)
|
||||
(metabase.lib.util.match/replace-in)
|
||||
(metabase.driver-api.core/match)
|
||||
@@ -299,7 +300,8 @@
|
||||
metabase-enterprise.transforms.interface/execute! {:message "Use metabase-enterprise.transforms.execute/execute! instead of metabase-enterprise.transforms.interface/execute!"}
|
||||
next.jdbc/active-tx? {:message "Use metabase.app-db.core/in-transaction? to see if we are in a transaction"}
|
||||
toucan2.core/debug {:message "Don't commit code using t2/debug"}
|
||||
toucan2.tools.debug/debug {:message "Don't commit code using t2/debug"}}
|
||||
toucan2.tools.debug/debug {:message "Don't commit code using t2/debug"}
|
||||
macaw.core/parsed-query {:message "Use metabase.driver.util/parsed-query instead of macaw.core/parsed-query"}}
|
||||
|
||||
:discouraged-namespace
|
||||
{camel-snake-kebab.core {:message "CSK is not Turkish-safe, use the versions in metabase.util instead."}
|
||||
@@ -522,8 +524,10 @@
|
||||
metabase-enterprise.advanced-permissions.models.permissions.application-permissions-test/with-new-group-and-current-graph hooks.common/with-two-top-level-bindings
|
||||
metabase-enterprise.audit-app.pages-test/with-temp-objects hooks.common/with-one-binding
|
||||
metabase-enterprise.cache.config-test/with-temp-persist-models hooks.common/with-seven-bindings
|
||||
metabase-enterprise.dependencies.api-test/with-dependents-test! hooks.common/with-two-bindings
|
||||
metabase-enterprise.metabot-v3.tools.dependencies-test/with-dependent-transforms! hooks.common/with-two-bindings
|
||||
metabase-enterprise.semantic-search.db.connection/with-migrate-tx hooks.common/let-one-with-optional-value
|
||||
metabase-enterprise.serialization.api-test/with-serialization-test-data! hooks.common/with-three-bindings
|
||||
metabase-enterprise.serialization.test-util/with-temp-dpc hooks.toucan2.tools.with-temp/with-temp
|
||||
metabase-enterprise.transforms-python.drivers-test/with-test-table hooks.common/with-two-bindings
|
||||
metabase.api.common.internal-test/with-jetty-server hooks.common/with-one-binding
|
||||
|
||||
@@ -276,6 +276,7 @@
|
||||
events
|
||||
models
|
||||
premium-features
|
||||
request
|
||||
settings
|
||||
util}}
|
||||
|
||||
@@ -383,7 +384,6 @@
|
||||
:uses #{api
|
||||
app-db
|
||||
collections
|
||||
config
|
||||
eid-translation
|
||||
events
|
||||
lib-be
|
||||
@@ -614,7 +614,6 @@
|
||||
metabase.embedding.validation}
|
||||
:uses #{analytics
|
||||
api
|
||||
config
|
||||
premium-features
|
||||
server
|
||||
settings
|
||||
@@ -777,6 +776,23 @@
|
||||
settings
|
||||
util}}
|
||||
|
||||
llm
|
||||
{:team "Metabot"
|
||||
:api #{metabase.llm.api
|
||||
metabase.llm.init}
|
||||
:uses #{analytics
|
||||
api
|
||||
driver
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
premium-features
|
||||
request
|
||||
settings
|
||||
sync
|
||||
util
|
||||
warehouse-schema}}
|
||||
|
||||
logger
|
||||
{:team "DevEx"
|
||||
:api #{metabase.logger.api
|
||||
@@ -810,6 +826,7 @@
|
||||
lib-be
|
||||
models
|
||||
permissions
|
||||
remote-sync
|
||||
search
|
||||
util}}
|
||||
|
||||
@@ -863,6 +880,7 @@
|
||||
models
|
||||
permissions
|
||||
premium-features
|
||||
remote-sync
|
||||
util}}
|
||||
|
||||
notification
|
||||
@@ -880,6 +898,7 @@
|
||||
config
|
||||
dashboards
|
||||
driver
|
||||
embedding
|
||||
events
|
||||
models
|
||||
parameters
|
||||
@@ -970,7 +989,8 @@
|
||||
:api #{metabase.premium-features.api
|
||||
metabase.premium-features.core
|
||||
metabase.premium-features.init}
|
||||
:uses #{api
|
||||
:uses #{analytics
|
||||
api
|
||||
app-db
|
||||
classloader
|
||||
config
|
||||
@@ -1201,7 +1221,8 @@
|
||||
:api #{metabase.request.core
|
||||
metabase.request.init
|
||||
metabase.request.schema}
|
||||
:uses #{api
|
||||
:uses #{analytics
|
||||
api
|
||||
config
|
||||
embedding
|
||||
permissions ; FIXME
|
||||
@@ -1457,6 +1478,7 @@
|
||||
models
|
||||
permissions
|
||||
premium-features
|
||||
query-processor
|
||||
request
|
||||
task
|
||||
util}}
|
||||
@@ -1500,7 +1522,6 @@
|
||||
:uses #{analytics
|
||||
api
|
||||
collections
|
||||
config
|
||||
events
|
||||
models
|
||||
util}}
|
||||
@@ -1650,7 +1671,6 @@
|
||||
:uses #{analytics
|
||||
api
|
||||
app-db
|
||||
config
|
||||
database-routing
|
||||
driver
|
||||
events
|
||||
@@ -1778,6 +1798,7 @@
|
||||
metabase-enterprise.advanced-permissions.common
|
||||
metabase-enterprise.advanced-permissions.models.permissions.group-manager}
|
||||
:uses #{api
|
||||
audit-app
|
||||
lib
|
||||
permissions
|
||||
premium-features
|
||||
@@ -1788,6 +1809,17 @@
|
||||
warehouses
|
||||
enterprise/impersonation}}
|
||||
|
||||
enterprise/agent-api
|
||||
{:team "Metabot"
|
||||
:api #{metabase-enterprise.agent-api.api}
|
||||
:uses #{api
|
||||
auth-identity
|
||||
query-processor
|
||||
request
|
||||
server
|
||||
util
|
||||
enterprise/metabot-v3}}
|
||||
|
||||
enterprise/ai-entity-analysis
|
||||
{:team "Metabot"
|
||||
:api #{metabase-enterprise.ai-entity-analysis.api}
|
||||
@@ -2080,44 +2112,45 @@
|
||||
util}}
|
||||
|
||||
enterprise/metabot-v3
|
||||
{:team "Metabot"
|
||||
:api #{metabase-enterprise.metabot-v3.api
|
||||
metabase-enterprise.metabot-v3.client
|
||||
metabase-enterprise.metabot-v3.core
|
||||
metabase-enterprise.metabot-v3.init
|
||||
metabase-enterprise.metabot-v3.tools.api}
|
||||
:uses #{activity-feed
|
||||
api
|
||||
app-db
|
||||
audit-app
|
||||
collections
|
||||
config
|
||||
documents
|
||||
driver
|
||||
legacy-mbql
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
native-query-snippets
|
||||
permissions
|
||||
premium-features
|
||||
pulse
|
||||
query-processor
|
||||
request
|
||||
search
|
||||
server
|
||||
settings
|
||||
store-api
|
||||
sync
|
||||
system
|
||||
task
|
||||
users-rest
|
||||
util
|
||||
warehouse-schema
|
||||
warehouses-rest
|
||||
enterprise/dependencies
|
||||
enterprise/transforms
|
||||
enterprise/transforms-python}}
|
||||
{:team "Metabot"
|
||||
:api #{metabase-enterprise.metabot-v3.api
|
||||
metabase-enterprise.metabot-v3.client
|
||||
metabase-enterprise.metabot-v3.core
|
||||
metabase-enterprise.metabot-v3.init
|
||||
metabase-enterprise.metabot-v3.tools.api}
|
||||
:friends #{enterprise/agent-api}
|
||||
:uses #{activity-feed
|
||||
api
|
||||
app-db
|
||||
audit-app
|
||||
collections
|
||||
config
|
||||
documents
|
||||
driver
|
||||
legacy-mbql
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
native-query-snippets
|
||||
permissions
|
||||
premium-features
|
||||
pulse
|
||||
query-processor
|
||||
request
|
||||
search
|
||||
server
|
||||
settings
|
||||
store-api
|
||||
sync
|
||||
system
|
||||
task
|
||||
users-rest
|
||||
util
|
||||
warehouse-schema
|
||||
warehouses-rest
|
||||
enterprise/dependencies
|
||||
enterprise/transforms
|
||||
enterprise/transforms-python}}
|
||||
|
||||
enterprise/permission-debug
|
||||
{:team "UX West"
|
||||
@@ -2248,6 +2281,7 @@
|
||||
native-query-snippets
|
||||
permissions
|
||||
premium-features
|
||||
remote-sync
|
||||
util}}
|
||||
|
||||
enterprise/sso
|
||||
@@ -2330,9 +2364,11 @@
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
permissions
|
||||
premium-features
|
||||
queries
|
||||
query-processor
|
||||
remote-sync
|
||||
request
|
||||
revisions
|
||||
search
|
||||
@@ -2351,8 +2387,11 @@
|
||||
app-db
|
||||
config
|
||||
driver
|
||||
events
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
permissions
|
||||
query-processor
|
||||
settings
|
||||
util
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
(ns macros.metabase.server.statistics-handler-test)
|
||||
|
||||
(defmacro with-server
|
||||
[[_hold-chan _hit-chan] & body]
|
||||
`(let []
|
||||
~@body))
|
||||
@@ -99,12 +99,48 @@
|
||||
:message "Metabase namespaces should have the form metabase[-enterprise].<module>.* [:metabase/namespace-name]"
|
||||
:type :metabase/namespace-name)))))))
|
||||
|
||||
(defn- ns-form-node->import-node [ns-form-node]
|
||||
(some (fn [node]
|
||||
(when (and (hooks/list-node? node)
|
||||
(let [first-child (first (:children node))]
|
||||
(and (hooks/keyword-node? first-child)
|
||||
(= (hooks/sexpr first-child) :import))))
|
||||
node))
|
||||
(:children ns-form-node)))
|
||||
|
||||
(defn- jsqlparser-import?
|
||||
"Check if a symbol or string represents a jsqlparser import."
|
||||
[s]
|
||||
(str/starts-with? (str s) "net.sf.jsqlparser"))
|
||||
|
||||
(defn- lint-jsqlparser-imports [ns-form-node]
|
||||
(when-let [import-node (ns-form-node->import-node ns-form-node)]
|
||||
(doseq [node (rest (:children import-node))
|
||||
:when (not (contains? (hooks.common/ignored-linters node) :metabase/no-jsqlparser-imports))]
|
||||
(cond
|
||||
;; Handle (net.sf.jsqlparser.foo Bar Baz) form
|
||||
(hooks/list-node? node)
|
||||
(let [package-node (first (:children node))
|
||||
package-name (when package-node (str (hooks/sexpr package-node)))]
|
||||
(when (jsqlparser-import? package-name)
|
||||
(hooks/reg-finding! (assoc (meta node)
|
||||
:message "Don't import net.sf.jsqlparser classes directly. This code should be agnostic to the parsing library. [:metabase/no-jsqlparser-imports]"
|
||||
:type :metabase/no-jsqlparser-imports))))
|
||||
|
||||
;; Handle net.sf.jsqlparser.foo.Bar form (single class import)
|
||||
(hooks/token-node? node)
|
||||
(when (jsqlparser-import? (hooks/sexpr node))
|
||||
(hooks/reg-finding! (assoc (meta node)
|
||||
:message "Don't import net.sf.jsqlparser classes directly. This code should be agnostic to the parsing library. [:metabase/no-jsqlparser-imports]"
|
||||
:type :metabase/no-jsqlparser-imports)))))))
|
||||
|
||||
(defn lint-ns [x]
|
||||
(doto (:node x)
|
||||
lint-require-shapes
|
||||
lint-requires-on-new-lines
|
||||
(lint-modules (modules/config x))
|
||||
lint-namespace-name)
|
||||
lint-namespace-name
|
||||
lint-jsqlparser-imports)
|
||||
x)
|
||||
|
||||
(comment
|
||||
|
||||
@@ -154,7 +154,6 @@
|
||||
metabase.lib.util.match/match [[:inner 0]]
|
||||
metabase.lib.util.match/match-one [[:inner 0]]
|
||||
metabase.lib.util.match/match-lite [[:inner 0]]
|
||||
metabase.lib.util.match/match-lite-recursive [[:inner 0]]
|
||||
metabase.lib.util.match/replace [[:inner 0]]
|
||||
metabase.lib.util.match/replace-in [[:inner 0]]
|
||||
metabase.models.interface/define-batched-hydration-method [[:inner 0]]
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
frontend/src/cljs
|
||||
frontend/src/cljs_release
|
||||
e2e/support/cypress_sample_database.js
|
||||
e2e/support/cypress_sample_instance_data.js
|
||||
frontend/src/metabase-types/openapi/types.gen.ts
|
||||
e2e/embedding-sdk-host-apps
|
||||
26
.eslintrc.js
@@ -8,7 +8,13 @@ const path = require("path");
|
||||
// can use this flag to enable it. This is set to true in CI
|
||||
const shouldLintCssModules =
|
||||
process.env.LINT_CSS_MODULES === "true" || process.env.CI;
|
||||
const plugins = ["react", "no-only-tests", "ttag", "i18next"];
|
||||
const plugins = [
|
||||
"react",
|
||||
"no-only-tests",
|
||||
"ttag",
|
||||
"i18next",
|
||||
"metabase",
|
||||
];
|
||||
if (shouldLintCssModules) {
|
||||
plugins.push("postcss-modules");
|
||||
}
|
||||
@@ -176,11 +182,11 @@ module.exports = {
|
||||
{
|
||||
files: ["*.js", "*.jsx", "*.ts", "*.tsx"],
|
||||
rules: {
|
||||
"jtag-missing-key": "error",
|
||||
"no-unconditional-metabase-links-render": "error",
|
||||
"no-color-literals": "error",
|
||||
"no-literal-metabase-strings": "error",
|
||||
"no-oss-reinitialize-import": "error",
|
||||
"metabase/jtag-missing-key": "error",
|
||||
"metabase/no-unconditional-metabase-links-render": "error",
|
||||
"metabase/no-color-literals": "error",
|
||||
"metabase/no-literal-metabase-strings": "error",
|
||||
"metabase/no-oss-reinitialize-import": "error",
|
||||
"depend/ban-dependencies": [
|
||||
"error",
|
||||
{
|
||||
@@ -214,9 +220,9 @@ module.exports = {
|
||||
"rspack.embedding-sdk-bundle.config.js",
|
||||
],
|
||||
rules: {
|
||||
"no-color-literals": "off",
|
||||
"no-unconditional-metabase-links-render": "off",
|
||||
"no-literal-metabase-strings": "off",
|
||||
"metabase/no-color-literals": "off",
|
||||
"metabase/no-unconditional-metabase-links-render": "off",
|
||||
"metabase/no-literal-metabase-strings": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -337,7 +343,7 @@ module.exports = {
|
||||
"import/no-default-export": "off",
|
||||
"import/order": "off",
|
||||
"import/no-unresolved": "off",
|
||||
"no-color-literals": "off",
|
||||
"metabase/no-color-literals": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
2
.github/actions/prepare-cypress/action.yml
vendored
@@ -16,7 +16,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install Chrome v111
|
||||
uses: browser-actions/setup-chrome@v1
|
||||
uses: browser-actions/setup-chrome@v2
|
||||
with:
|
||||
# https://chromium.cypress.io/linux/stable/111.0.5563.146
|
||||
chrome-version: 1097615
|
||||
|
||||
1
.github/actions/prepare-frontend/action.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: Prepare front-end environment
|
||||
description: "Prepare the front-end environment for the CI build"
|
||||
inputs:
|
||||
node-version:
|
||||
description: "The version of Node.js to use, overriding .nvmrc"
|
||||
|
||||
8
.github/actions/test-driver/action.yml
vendored
@@ -12,6 +12,10 @@ inputs:
|
||||
logs-artifact-suffix:
|
||||
required: false
|
||||
description: Suffix to append to the test logs artifact name to avoid conflicts in matrix jobs
|
||||
edition:
|
||||
required: false
|
||||
default: 'ee'
|
||||
description: Edition to test (oss or ee)
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -28,7 +32,7 @@ runs:
|
||||
env:
|
||||
MB_EDITION: ee
|
||||
- name: Test database driver
|
||||
run: clojure -X:dev:ci:ee:ee-dev:drivers:drivers-dev:test ${{ inputs.test-args }}
|
||||
run: clojure -X:dev:ci:${{ inputs.edition }}:${{ inputs.edition }}-dev:drivers:drivers-dev:test ${{ inputs.test-args }}
|
||||
shell: bash
|
||||
id: run-test
|
||||
# Trunk Quarantine controls the final state of the run in the CI!
|
||||
@@ -52,7 +56,7 @@ runs:
|
||||
if: always() && steps.run-test.outcome != 'success'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ format('test-logs-{0}{1}', github.job, (inputs.logs-artifact-suffix == '' || inputs.logs-artifact-suffix == null) && '' || format('-{0}', inputs.logs-artifact-suffix)) }}
|
||||
name: ${{ format('test-logs-{0}{1}', github.job, inputs.logs-artifact-suffix && format('-{0}', inputs.logs-artifact-suffix) || '') }}
|
||||
path: logs/test-log.json
|
||||
retention-days: 1
|
||||
# Failing to upload the artifact should never affect the outcome of the whole test run.
|
||||
|
||||
40
.github/scripts/find-linear-issue.js
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
async function findLinearIssue({ githubIssueUrl, linearApiKey }) {
|
||||
const response = await fetch("https://api.linear.app/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: linearApiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: `
|
||||
query {
|
||||
attachmentsForURL(url: "${githubIssueUrl}") {
|
||||
nodes {
|
||||
issue { identifier }
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, message: `Linear API error: ${response.status}` };
|
||||
}
|
||||
|
||||
if (!data.data?.attachmentsForURL?.nodes?.length) {
|
||||
return {
|
||||
success: false,
|
||||
message: `No Linear issue linked to ${githubIssueUrl}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
issue: data.data.attachmentsForURL.nodes[0].issue.identifier,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { findLinearIssue };
|
||||
13
.github/team.json
vendored
@@ -86,14 +86,11 @@
|
||||
"eliotmetabase",
|
||||
"npfitz",
|
||||
"johnswanson",
|
||||
"iethree",
|
||||
"nvoxland",
|
||||
"edpaget",
|
||||
"deniskaber",
|
||||
"alxnddr",
|
||||
"bpander",
|
||||
"andsalves",
|
||||
"gantoreno",
|
||||
"kevinkelleher12",
|
||||
"undrfined"
|
||||
]
|
||||
@@ -112,6 +109,16 @@
|
||||
"uladzimirdev",
|
||||
"egorgrushin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Growth Eng",
|
||||
"label": ".Team/GrowthEng",
|
||||
"members": [
|
||||
"iethree",
|
||||
"bpander",
|
||||
"gantoreno",
|
||||
"losrebellos"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
30
.github/workflows/claude-code-bug-fix.yml
vendored
@@ -70,6 +70,31 @@ jobs:
|
||||
core.setFailed(error.message);
|
||||
}
|
||||
|
||||
- name: Find linked Linear issue
|
||||
id: find_linear
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
|
||||
ISSUE_NUMBER: ${{ steps.get_issue.outputs.issue_number }}
|
||||
with:
|
||||
script: |
|
||||
const { findLinearIssue } = require('${{ github.workspace }}/.github/scripts/find-linear-issue.js');
|
||||
|
||||
const githubIssueUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/issues/${process.env.ISSUE_NUMBER}`;
|
||||
|
||||
const result = await findLinearIssue({
|
||||
githubIssueUrl,
|
||||
linearApiKey: process.env.LINEAR_API_KEY
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log(`Found Linear issue: ${result.issue}`);
|
||||
core.setOutput('linear_issue', result.issue);
|
||||
} else {
|
||||
console.log(`Could not find Linear issue: ${result.message}`);
|
||||
}
|
||||
|
||||
- name: Set up git configuration
|
||||
run: |
|
||||
git config --global user.name "Claude Code[bot]"
|
||||
@@ -259,6 +284,7 @@ jobs:
|
||||
core.setOutput('reviewers', issueCreator);
|
||||
|
||||
- name: Create Pull Request
|
||||
id: create_pr
|
||||
if: steps.check_changes.outputs.has_changes == 'true'
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
@@ -279,6 +305,8 @@ jobs:
|
||||
body: |
|
||||
This PR automatically fixes the bug reported in #${{ env.ISSUE_NUMBER }}.
|
||||
|
||||
${{ steps.find_linear.outputs.linear_issue && format('Closes {0}', steps.find_linear.outputs.linear_issue) || '' }}
|
||||
|
||||
## Bug Report
|
||||
**Original Issue:** #${{ env.ISSUE_NUMBER }}
|
||||
**Reporter:** @${{ env.ISSUE_AUTHOR }}
|
||||
@@ -299,7 +327,6 @@ jobs:
|
||||
- If issues are found, either fix them or close this PR
|
||||
|
||||
---
|
||||
Closes #${{ env.ISSUE_NUMBER }}
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.ai/code)
|
||||
|
||||
@@ -309,6 +336,7 @@ jobs:
|
||||
automated-fix
|
||||
ci skip
|
||||
reviewers: ${{ steps.prepare_reviewers.outputs.reviewers }}
|
||||
assignees: ${{ github.actor }}
|
||||
|
||||
- name: Log existing branch update
|
||||
if: steps.check_changes.outputs.has_changes == 'true' && env.BRANCH_EXISTS == 'true'
|
||||
|
||||
33
.github/workflows/clean-cache.yml
vendored
@@ -1,33 +0,0 @@
|
||||
name: Cleanup caches by a branch
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cleanup
|
||||
run: |
|
||||
gh extension install actions/gh-actions-cache
|
||||
|
||||
REPO=${{ github.repository }}
|
||||
BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge"
|
||||
|
||||
echo "Fetching list of cache key"
|
||||
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 )
|
||||
|
||||
## Setting this to not fail the workflow while deleting cache keys.
|
||||
set +e
|
||||
echo "Deleting caches..."
|
||||
for cacheKey in $cacheKeysForPR
|
||||
do
|
||||
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
|
||||
done
|
||||
echo "Done"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
113
.github/workflows/codespell.yml
vendored
@@ -1,23 +1,110 @@
|
||||
name: Codespell
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "release-**"
|
||||
pull_request:
|
||||
schedule:
|
||||
# Run nightly at 4:00 AM UTC
|
||||
- cron: "0 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
codespell:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: codespell-project/actions-codespell@v2
|
||||
|
||||
- name: Install codespell
|
||||
run: pip install codespell
|
||||
|
||||
- name: Set up git configuration
|
||||
run: |
|
||||
git config --global user.name "Claude Code[bot]"
|
||||
git config --global user.email "noreply@anthropic.com"
|
||||
|
||||
- name: Get current date
|
||||
id: date
|
||||
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run codespell check
|
||||
id: codespell_check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -o pipefail
|
||||
./bin/mage codespell 2>&1 | tee codespell_output.txt
|
||||
|
||||
- name: Generate GitHub App Token for Claude
|
||||
if: steps.codespell_check.outcome == 'failure'
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: claude-app-token
|
||||
with:
|
||||
# keep this list in sync with the list in `mage/src/mage/codespell.clj`
|
||||
path: >-
|
||||
docs
|
||||
enterprise
|
||||
frontend
|
||||
modules/drivers
|
||||
src
|
||||
app-id: ${{ secrets.METABASE_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.METABASE_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Fix spelling errors with Claude Code
|
||||
if: steps.codespell_check.outcome == 'failure'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ steps.claude-app-token.outputs.token }}
|
||||
prompt: |
|
||||
You are tasked with fixing spelling errors detected by codespell in this repository.
|
||||
|
||||
## Codespell Output
|
||||
The following spelling errors were detected:
|
||||
```
|
||||
$(cat codespell_output.txt)
|
||||
```
|
||||
|
||||
## Instructions
|
||||
1. **Review the codespell output and identify reported spelling errors** - The usual format codespell uses is `typo ==> correction`
|
||||
2. **Fix every spelling error** - Use the Edit tool to fix each misspelling with the suggested correction
|
||||
- If codespell suggests multiple corrections, choose the most appropriate one based on context
|
||||
|
||||
Human reviewers will verify the changes before merging, so fix all errors reported by codespell.
|
||||
claude_args: |
|
||||
--allowedTools "Write,Read,Glob,Grep,Edit,MultiEdit,Bash(cat*),Bash(ls*)"
|
||||
--disallowedTools "Bash(git commit*),Bash(git push*),Bash(gh*)"
|
||||
|
||||
- name: Clean up temporary files
|
||||
if: steps.codespell_check.outcome == 'failure'
|
||||
run: rm -f codespell_output.txt
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.codespell_check.outcome == 'failure'
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: claude-fix/codespell-${{ steps.date.outputs.date }}
|
||||
base: master
|
||||
delete-branch: true
|
||||
commit-message: |
|
||||
Fix spelling errors detected by codespell (${{ steps.date.outputs.date }})
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.ai/code)
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
committer: "Claude Code[bot] <noreply@anthropic.com>"
|
||||
author: "Claude Code[bot] <noreply@anthropic.com>"
|
||||
title: "Fix spelling errors detected by codespell (${{ steps.date.outputs.date }})"
|
||||
body: |
|
||||
This PR automatically fixes spelling errors detected by the scheduled codespell check.
|
||||
|
||||
## Automated Fix
|
||||
🤖 This fix was generated automatically by Claude Code based on codespell output.
|
||||
|
||||
**Please review carefully:**
|
||||
- [ ] The spelling fixes are correct
|
||||
- [ ] No code functionality was changed
|
||||
- [ ] Technical terms were preserved correctly
|
||||
|
||||
---
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.ai/code)
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
labels: |
|
||||
automated-fix
|
||||
backport
|
||||
|
||||
34
.github/workflows/drivers.yml
vendored
@@ -146,6 +146,7 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
# DS 2025-09-26 temporarily disabled while we diagnose flakes
|
||||
# - name: Transforms Python Tests
|
||||
# test-args: >-
|
||||
@@ -509,6 +510,37 @@ jobs:
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
|
||||
be-tests-h2-oss:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.h2-should-run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CI: 'true'
|
||||
name: H2 (OSS)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Test H2 driver (OSS)
|
||||
id: test-driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-h2-oss'
|
||||
edition: 'oss'
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
aws-access-key-id: ${{ secrets.AWS_TEST_RESULTS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
|
||||
be-tests-mysql-mariadb:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.mysql-mariadb-should-run == 'true' }}
|
||||
@@ -1019,6 +1051,7 @@ jobs:
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-${{ matrix.version.junit-name }}'
|
||||
logs-artifact-suffix: ${{ matrix.version.name }}-${{ matrix.job.name }}
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
@@ -1224,6 +1257,7 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
|
||||
# DS 2025-09-26 temporarily disabled while we diagnose flakes
|
||||
# - name: Transforms Python Tests
|
||||
|
||||
@@ -120,6 +120,10 @@ jobs:
|
||||
- name: Prepare Cypress environment
|
||||
id: cypress-prep
|
||||
uses: ./.github/actions/prepare-cypress
|
||||
|
||||
- name: Compile CLJS
|
||||
run: yarn build-pure:cljs
|
||||
|
||||
- name: Run Metabase
|
||||
run: node e2e/runner/run_cypress_ci.js start
|
||||
|
||||
|
||||
3
.github/workflows/e2e-test.yml
vendored
@@ -104,6 +104,9 @@ jobs:
|
||||
if: github.run_attempt != '1'
|
||||
run: echo "specs=$(cat failed-specs)" >> $GITHUB_ENV
|
||||
|
||||
- name: Compile CLJS
|
||||
run: yarn build-pure:cljs
|
||||
|
||||
- name: Run Metabase
|
||||
run: node e2e/runner/run_cypress_ci.js start
|
||||
|
||||
|
||||
56
.github/workflows/fuzzing.yml
vendored
@@ -1,56 +0,0 @@
|
||||
name: Fuzzing
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "release-**"
|
||||
paths:
|
||||
- "frontend/src/metabase/querying/expressions/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "frontend/src/metabase/querying/expressions/**"
|
||||
|
||||
jobs:
|
||||
fe-fuzz-lexifier:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- run: yarn test-unit frontend/src/metabase/querying/expressions/pratt/fuzz.lexifier.unit.spec.ts
|
||||
env:
|
||||
MB_FUZZ: 1
|
||||
name: Run fuzz testing on the lexifier
|
||||
|
||||
fe-fuzz-compiler:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- run: yarn test-unit frontend/src/metabase/querying/expressions/fuzz.compiler.unit.spec.ts
|
||||
env:
|
||||
MB_FUZZ: 1
|
||||
name: Run fuzz testing on the custom expression compiler
|
||||
|
||||
fe-fuzz-string-quoting:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- run: yarn test-unit frontend/src/metabase/querying/expressions/fuzz.string.unit.spec.ts
|
||||
env:
|
||||
MB_FUZZ: 1
|
||||
name: Run fuzz testing on the custom expression string quoter
|
||||
44
.github/workflows/openapi-check.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: OpenAPI Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-openapi-specs:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 5
|
||||
if: contains(github.event.pull_request.labels.*.name, 'openapi-self-healing')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# we're intentionally using METABASE_AUTOMATION_USER_TOKEN to trigger the whole CI pipeline again
|
||||
# after the self-healing commit is created
|
||||
# if we used GITHUB_TOKEN, the self-healing commit would not trigger the CI pipeline again
|
||||
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
# fetch full git history to allow fetching the latest base branch commit and comparing changes
|
||||
fetch-depth: 0
|
||||
- name: Setup Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Generate version.properties
|
||||
run: ./bin/build.sh :steps [:version]
|
||||
- name: Get latest base branch commit
|
||||
run: |
|
||||
git fetch origin ${{ github.event.pull_request.base.ref }}
|
||||
echo "LATEST_BASE_SHA=$(git rev-parse origin/${{ github.event.pull_request.base.ref }})" >> $GITHUB_ENV
|
||||
- name: Check OpenAPI specs are up to date
|
||||
env:
|
||||
AUTO_COMMIT: "true"
|
||||
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
BASE_SHA: ${{ env.LATEST_BASE_SHA }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: ./bin/openapi-check.sh
|
||||
13
.github/workflows/release.yml
vendored
@@ -326,10 +326,11 @@ jobs:
|
||||
const { getExtraTagsForVersion } = require('${{ github.workspace }}/release/dist/index.cjs');
|
||||
|
||||
const version = '${{ inputs.version }}';
|
||||
const latestMajorVersion = '${{ vars.LATEST_MAJOR_VERSION }}';
|
||||
|
||||
const extra_tags = getExtraTagsForVersion({ version });
|
||||
const extra_tags = getExtraTagsForVersion({ version, latestMajorVersion });
|
||||
|
||||
console.log({ extra_tags });
|
||||
console.log({ extra_tags, latestMajorVersion });
|
||||
core.setOutput('extra_tags', JSON.stringify(extra_tags));
|
||||
|
||||
add-extra-tags:
|
||||
@@ -347,9 +348,11 @@ jobs:
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
tag_name: ${{ matrix.tag }}
|
||||
tag_ee: ${{ contains(matrix.tag, 'v1') }}
|
||||
tag_oss: ${{ contains(matrix.tag, 'v0') }}
|
||||
dot-x-tag: true
|
||||
# For version tags (v0.x, v1.x), apply to matching edition only
|
||||
# For non-version tags like "latest", apply to both editions
|
||||
tag_ee: ${{ contains(matrix.tag, 'v1') || !startsWith(matrix.tag, 'v') }}
|
||||
tag_oss: ${{ contains(matrix.tag, 'v0') || !startsWith(matrix.tag, 'v') }}
|
||||
dot-x-tag: ${{ startsWith(matrix.tag, 'v') }}
|
||||
|
||||
trigger-cloud-issues:
|
||||
if: ${{ !inputs.auto }}
|
||||
|
||||
16
.github/workflows/update-e2e-timings.yml
vendored
@@ -23,9 +23,16 @@ jobs:
|
||||
env:
|
||||
DAYS_BACK: ${{ inputs.days_back || 7 }}
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.METABASE_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.METABASE_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -41,7 +48,7 @@ jobs:
|
||||
const result = await collectAndAverageTimings({
|
||||
github,
|
||||
context,
|
||||
token: '${{ secrets.GITHUB_TOKEN }}',
|
||||
token: '${{ steps.app-token.outputs.token }}',
|
||||
daysBack: ${{ env.DAYS_BACK }},
|
||||
existingTimingsPath: 'e2e/support/timings.json'
|
||||
});
|
||||
@@ -59,7 +66,7 @@ jobs:
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
branch: ${{ env.BRANCH_NAME }}
|
||||
base: master
|
||||
commit-message: |
|
||||
@@ -89,4 +96,7 @@ jobs:
|
||||
Co-Authored-By: GitHub Actions <actions@github.com>
|
||||
labels: |
|
||||
.CI & Tests
|
||||
no-backport
|
||||
add-paths: e2e/support/timings.json
|
||||
team-reviewers: |
|
||||
devexp
|
||||
|
||||
1
.gitignore
vendored
@@ -106,7 +106,6 @@ dev/serialization_deltas/
|
||||
/trace-output/
|
||||
/temp-types/
|
||||
/frontend/src/metabase-types/openapi
|
||||
/resources/openapi/openapi.json
|
||||
|
||||
**/cypress_sample_database.json
|
||||
**/cypress_sample_instance_data.json
|
||||
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo, useEffect } from "react";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
|
||||
import { ThemeProvider } from "metabase/ui";
|
||||
|
||||
// @ts-expect-error: See metabase/lib/delay
|
||||
@@ -15,10 +16,13 @@ import "@mantine/dates/styles.css";
|
||||
|
||||
import { EmotionCacheProvider } from "metabase/styled-components/components/EmotionCacheProvider";
|
||||
import { getMetabaseCssVariables } from "metabase/styled-components/theme/css-variables";
|
||||
import { css, Global, useTheme } from "@emotion/react";
|
||||
|
||||
import { Global, css, useTheme } from "@emotion/react";
|
||||
|
||||
import { baseStyle, rootStyle } from "metabase/css/core/base.styled";
|
||||
import { defaultFontFiles } from "metabase/css/core/fonts.styled";
|
||||
import { saveDomImageStyles } from "metabase/visualizations/lib/image-exports";
|
||||
|
||||
import { initialize, mswLoader } from "msw-storybook-addon";
|
||||
|
||||
// Note: Changing the names of the stories may impact loki visual testing. Please ensure that
|
||||
@@ -107,7 +111,9 @@ function CssVariables() {
|
||||
}, []);
|
||||
|
||||
// This can get expensive so we should memoize it separately
|
||||
const cssVariables = useMemo(() => getMetabaseCssVariables(theme), [theme]);
|
||||
const cssVariables = useMemo(() => {
|
||||
return getMetabaseCssVariables({ theme });
|
||||
}, [theme]);
|
||||
|
||||
const styles = useMemo(() => {
|
||||
return css`
|
||||
|
||||
3
.vscode/settings.json
vendored
@@ -4,8 +4,5 @@
|
||||
"jake.autoDetect": "off",
|
||||
"gulp.autoDetect": "off",
|
||||
"npm.autoDetect": "off",
|
||||
"eslint.options": {
|
||||
"rulePaths": ["frontend/lint/eslint-rules/"]
|
||||
},
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ For detailed guidance on writing and reviewing code and documentation, see the s
|
||||
- **[docs-write](.claude/skills/docs-write/SKILL.md)** - Documentation writing with Metabase style guide
|
||||
- **[docs-review](.claude/skills/docs-review/SKILL.md)** - Documentation review checklist
|
||||
|
||||
## Frontend
|
||||
|
||||
- **[analytics-events](.claude/skills/analytics-events/SKILL.md)** - Add product analytics events to track user interactions
|
||||
|
||||
## Tool Preferences
|
||||
|
||||
If `clojure-mcp` tools are available, prefer them over shell-based alternatives for Clojure development.
|
||||
|
||||
101
bb.edn
@@ -83,6 +83,15 @@
|
||||
:requires [[mage.kondo :as kondo]]
|
||||
:task (task! (kondo/kondo-updated (:arguments parsed)))}
|
||||
|
||||
fix-unused-requires
|
||||
{:doc "Removes unused requires flagged by clj-kondo"
|
||||
:examples [["./bin/mage fix-unused-requires src/metabase/lib/validate.cljc" "Fix unused requires in a specific file"]
|
||||
["./bin/mage fix-unused-requires src" "Fix unused requires in all files in src"]]
|
||||
:requires [[mage.fix-unused-requires :as fix]]
|
||||
:arg-schema [:sequential [:string {:name "file-or-directory"
|
||||
:description "Files or directories to fix unused requires in"}]]
|
||||
:task (task! (fix/fix-files parsed))}
|
||||
|
||||
check
|
||||
{:doc "Checks whether we can compile all source files (and finds circular dependencies)"
|
||||
:examples [["./bin/mage check"]]
|
||||
@@ -130,6 +139,27 @@
|
||||
(let [[db version] (:arguments parsed)]
|
||||
(start-db/start-db (:db-info (current-task)) db version)))}
|
||||
|
||||
dev-repl
|
||||
{:doc "Launch Metabase dev environment (frontend + backend) with interactive configuration"
|
||||
:examples [["./bin/mage dev-repl" "Interactive TUI to configure and launch"]
|
||||
["./bin/mage dev-repl -e ee -t all-features -d postgres"
|
||||
"Launch with specific config (skip prompts)"]
|
||||
["./bin/mage dev-repl --no-frontend" "Backend only (skip frontend dev server)"]
|
||||
["./bin/mage dev-repl --fresh" "Recreate database container from scratch"]]
|
||||
:requires [[mage.dev-repl :as dev-repl]]
|
||||
:options [["-e" "--edition EDITION" "ee or oss"]
|
||||
["-t" "--token TOKEN" "Token type: all-features, starter-cloud, pro-cloud, pro-self-hosted, none"]
|
||||
["-d" "--db DB" "Database: postgres, mysql, mariadb, h2"]
|
||||
["-c" "--config FILE" "Config file path (default: dev/config.yml)"]
|
||||
["-v" "--db-version VERSION" "Database version: latest or oldest (default: latest)"]
|
||||
["-p" "--port PORT" "Backend port (default: 3000)" :parse-fn Integer/parseInt]
|
||||
["-f" "--fresh" "Recreate database container (destroy existing data)"]
|
||||
["-F" "--no-frontend" "Skip starting the frontend dev server"]]
|
||||
:db-info {:postgres {:ports {:oldest 5432 :latest 5433} :eol-url "https://endoflife.date/api/postgres.json"}
|
||||
:mysql {:ports {:oldest 3308 :latest 3309} :eol-url "https://endoflife.date/api/mysql.json"}
|
||||
:mariadb {:ports {:oldest 3306 :latest 3307} :eol-url "https://endoflife.date/api/mariadb.json"}}
|
||||
:task (task! (dev-repl/dev-repl parsed (select-keys (current-task) [:db-info])))}
|
||||
|
||||
nrepl
|
||||
{:doc "Starts the babashka nrepl: helpful for mage development"
|
||||
:requires [[babashka.nrepl.server :as nrepl.server]
|
||||
@@ -200,18 +230,20 @@
|
||||
(nrepl-eval "metabase.comments.core" code)))}
|
||||
|
||||
run-tests
|
||||
{:doc "Quickly run tests locally. Pass dirs/files as arguments to run them."
|
||||
{:doc "Quickly run tests locally. Pass dirs, files, namespaces, or single tests as arguments."
|
||||
:examples [["./bin/mage run-tests" "launch interactive test picker"]
|
||||
["./bin/mage run-tests test/metabase/db" "run tests in test/metabase/db"]
|
||||
["./bin/mage run-tests test/metabase/logger test/metabase/api_keys" "run tests in test/metabase/logger and src/metabase/api_keys"]
|
||||
["./bin/mage run-tests test/metabase/config_test.clj" "run all tests in the metabase.config-test namespace"]]
|
||||
["./bin/mage run-tests test/metabase/logger test/metabase/api_keys" "run tests in test/metabase/logger and test/metabase/api_keys"]
|
||||
["./bin/mage run-tests test/metabase/config_test.clj" "run all tests in the metabase.config-test namespace"]
|
||||
["./bin/mage run-tests metabase.config-test" "run all tests in the metabase.config-test namespace (by name)"]
|
||||
["./bin/mage run-tests metabase.config-test/config-test" "run a single test by namespace/test-name"]]
|
||||
;; TODO: add option to select files as well as dirs from fzf with styles
|
||||
:options [["-s" "--selecting STYLE" "Style of test selection" :default "all"]
|
||||
;; all, file, dir
|
||||
["-p" "--port PORT" "Port to use for the task, defaults to value in .nrepl-port"]]
|
||||
:arg-schema [:sequential
|
||||
{:details "If none are given, you will be prompted to select directories recursively from test/ and enterprise/backend/test/"}
|
||||
[:string {:name "file-or-dir" :description "Files or directories (recursive) to test"}]]
|
||||
[:string {:name "file-dir-ns-or-test" :description "Files, directories (recursive), namespaces, or namespace/test-name to test"}]]
|
||||
:requires [[mage.quick-test-runner :as qtr]]
|
||||
:task (task! (qtr/go parsed))}
|
||||
|
||||
@@ -274,6 +306,20 @@
|
||||
:requires [[mage.doctor :as doctor]]
|
||||
:task (task! (doctor/doctor!))}
|
||||
|
||||
ci-report
|
||||
{:doc "Generate a CI failure report for a PR"
|
||||
:examples [["./bin/mage ci-report" "Check current branch, find its PR, and generate report"]
|
||||
["./bin/mage ci-report 68292" "Generate report for PR #68292"]
|
||||
["./bin/mage ci-report https://github.com/metabase/metabase/pull/68292" "Generate report from PR URL"]]
|
||||
:usage-fn (fn [_]
|
||||
(str "Fetches CI check status and failed job logs from GitHub Actions.\n"
|
||||
"Parses Trunk test reports to extract test failure details.\n\n"
|
||||
"With no arguments, looks up the PR for your current git branch.\n\n"
|
||||
"Requires GitHub CLI (gh) to be installed and authenticated.\n"
|
||||
"If you see 'gh: command not found' or auth errors, run: gh auth login"))
|
||||
:requires [[mage.ci-report :as ci-report]]
|
||||
:task (task! (ci-report/generate-report! parsed))}
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Private Tasks:
|
||||
;; These tasks are more for internal tooling, e.g. for use from git hooks etc.
|
||||
@@ -284,32 +330,39 @@
|
||||
:examples [["./bin/mage -driver-decisions --git-ref=master --is-master-or-release=false --pr-labels= --skip=false" "Determine driver decisions for a PR"]
|
||||
["./bin/mage -driver-decisions --pr-labels=ci:all-cloud-drivers" "Run all cloud drivers via label"]
|
||||
["./bin/mage -driver-decisions --github-output-only" "Output only GITHUB_OUTPUT format (for CI)"]]
|
||||
:requires [[clojure.string :as str]
|
||||
[table.core :as table]
|
||||
[mage.modules]]
|
||||
:usage-fn
|
||||
(fn [_]
|
||||
(->>
|
||||
["# How we decide to run a driver's tests. For each driver, we check these conditions in order (first match wins):"
|
||||
" |----------|-------------------------------------|--------|-------------------------------------------|"
|
||||
" | Priority | Condition | Result | Reason |"
|
||||
" |----------|-------------------------------------|--------|-------------------------------------------|"
|
||||
" | 1 | Global skip (no backend changes) | SKIP | workflow skip (no backend changes) |"
|
||||
" | 2 | Driver is :h2 | RUN | H2 always runs |"
|
||||
" | 3 | Driver is quarantined | SKIP | driver is quarantined |"
|
||||
" | 3(a) | ...and has break-quarantine-X label | RUN | anti-quarantine label present |"
|
||||
" | 4 | On master or release branch | RUN | master/release branch |"
|
||||
" | 5 | Cloud driver + ci:all-cloud-drivers | RUN | ci:all-cloud-drivers label |"
|
||||
" | 6 | Cloud driver + its files changed | RUN | driver files changed |"
|
||||
" | 7 | Cloud driver, no relevant changes | SKIP | no relevant changes for cloud driver |"
|
||||
" | 8 | Driver deps affected by changes | RUN | driver module affected by shared code |"
|
||||
" | 9 | Self-hosted driver, not affected | SKIP | driver module not affected |"
|
||||
" |----------|-------------------------------------|--------|-------------------------------------------|"
|
||||
""] (str/join "\n")))
|
||||
(fn [_current-task]
|
||||
(let [driver-triggers (vec (sort mage.modules/default-modules-which-trigger-drivers))]
|
||||
(str
|
||||
"# How we decide to run a driver's tests. For each driver, we check these conditions in order (first match wins):\n"
|
||||
(with-out-str
|
||||
(table/table [["Priority" "Condition" "Result" "Reason"]
|
||||
["1" "Global skip (no backend changes)" "SKIP" "workflow skip (no backend changes)"]
|
||||
["2" "Driver is :h2 or :postgres" "RUN" "H2/Postgres always run"]
|
||||
["3" "Driver is quarantined" "SKIP" "driver is quarantined"]
|
||||
["3(a)" "...and has break-quarantine-X label" "RUN" "anti-quarantine label present"]
|
||||
["4" "On master or release branch" "RUN" "master/release branch"]
|
||||
["5" "Cloud driver + ci:all-cloud-drivers" "RUN" "ci:all-cloud-drivers label"]
|
||||
["6"
|
||||
"Cloud driver + its files changed"
|
||||
"RUN"
|
||||
(str "any of " (pr-str driver-triggers) " affected by cloud-driver module changes")]
|
||||
["7" "Cloud driver + query-processor updated" "RUN" "query-processor module updated"]
|
||||
["8" "Cloud driver, no relevant changes" "SKIP" "no relevant changes for cloud driver"]
|
||||
["9"
|
||||
(str (pr-str driver-triggers) " module deps affected")
|
||||
"RUN"
|
||||
(str "any of " (pr-str driver-triggers) " affected by module changes")]
|
||||
["10" "Self-hosted driver, not affected" "SKIP" "driver module not affected"]])))))
|
||||
:options [[nil "--git-ref REF" "The git ref to compute changed modules against"]
|
||||
[nil "--is-master-or-release MASTER_OR_RELEASE"]
|
||||
[nil "--pr-labels PR_LABELS" "Comma delimited labels on the pr"]
|
||||
[nil "--skip SKIP"]
|
||||
[nil "--github-output-only" "Only output GITHUB_OUTPUT format (no human-readable output)"]]
|
||||
:requires [[clojure.string :as str]
|
||||
[mage.modules]]
|
||||
|
||||
:task (task! (mage.modules/-main parsed))}
|
||||
|
||||
-install-merge-drivers
|
||||
|
||||
@@ -4,7 +4,7 @@ import { readFileSync } from "fs";
|
||||
|
||||
import { glob } from "glob";
|
||||
|
||||
import { colorConfig } from "../../frontend/src/metabase/lib/colors/colors";
|
||||
import { ALL_COLOR_NAMES } from "../../frontend/src/metabase/lib/colors";
|
||||
|
||||
/**
|
||||
* This script finds css variables that are used but *never* defined in our codebase.
|
||||
@@ -104,7 +104,7 @@ const main = () => {
|
||||
allDefinitions.add(definition);
|
||||
}
|
||||
|
||||
Object.keys(colorConfig).forEach((key) => {
|
||||
ALL_COLOR_NAMES.forEach((key) => {
|
||||
allDefinitions.add(`--mb-color-${key}`);
|
||||
});
|
||||
|
||||
|
||||
94
bin/openapi-check.sh
Executable file
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
OPENAPI_SPEC="resources/openapi/openapi.json"
|
||||
AUTO_COMMIT="${AUTO_COMMIT:-false}"
|
||||
|
||||
# Check that $OPENAPI_SPEC matches the current Malli schema definitions
|
||||
|
||||
if [[ -n "${GITHUB_SHA:-}" ]]; then
|
||||
echo "Running OpenAPI check on commit: $GITHUB_SHA"
|
||||
fi
|
||||
|
||||
# Generate the current OpenAPI spec from Malli schemas using write-openapi-spec-to-file!
|
||||
# This creates $OPENAPI_SPEC directly
|
||||
# Using the generate-openapi-spec command from metabase.cmd.core
|
||||
yarn generate-openapi
|
||||
ret=$?
|
||||
|
||||
if [[ $ret -ne 0 ]]; then
|
||||
exit $ret
|
||||
fi
|
||||
|
||||
if [[ ! -f "$OPENAPI_SPEC" ]]; then
|
||||
echo "Error: $OPENAPI_SPEC does not exist!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if there are any differences between generated and committed versions
|
||||
if ! git diff --exit-code "$OPENAPI_SPEC" >/dev/null 2>&1; then
|
||||
echo "OpenAPI schema changes detected:"
|
||||
git diff "$OPENAPI_SPEC"
|
||||
|
||||
if [[ "$AUTO_COMMIT" == "true" ]]; then
|
||||
echo ""
|
||||
echo "Auto-committing changes..."
|
||||
|
||||
# Require PR_BRANCH to be set for auto-commit
|
||||
if [[ -z "${PR_BRANCH:-}" ]]; then
|
||||
echo "Error: PR_BRANCH environment variable must be set for auto-commit"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stash the generated spec from the merge commit
|
||||
echo "Stashing generated OpenAPI spec from merge commit..."
|
||||
cp "$OPENAPI_SPEC" "${OPENAPI_SPEC}.tmp"
|
||||
|
||||
# Checkout the actual PR branch (in case we're on a merge commit)
|
||||
echo "Checking out branch: $PR_BRANCH"
|
||||
git checkout -f "$PR_BRANCH"
|
||||
|
||||
# Restore the generated spec from the merge commit
|
||||
mv -f "${OPENAPI_SPEC}.tmp" "$OPENAPI_SPEC"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add "$OPENAPI_SPEC"
|
||||
|
||||
# Build commit message with SHAs if available
|
||||
COMMIT_MSG="Update OpenAPI schema"
|
||||
if [[ -n "${BASE_SHA:-}" ]] && [[ -n "${HEAD_SHA:-}" ]]; then
|
||||
BASE_SHORT="${BASE_SHA:0:7}"
|
||||
HEAD_SHORT="${HEAD_SHA:0:7}"
|
||||
COMMIT_MSG="Update OpenAPI schema (${BASE_SHORT}...${HEAD_SHORT})"
|
||||
fi
|
||||
|
||||
git commit -m "$COMMIT_MSG"
|
||||
if ! git push origin "$PR_BRANCH"; then
|
||||
echo "Error: Failed to push OpenAPI schema update to $PR_BRANCH."
|
||||
exit 1
|
||||
fi
|
||||
echo "OpenAPI schema updated and pushed to $PR_BRANCH."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Error: OpenAPI specs are out of date!"
|
||||
echo "The schema in $OPENAPI_SPEC does not match the current Malli schema definitions."
|
||||
echo ""
|
||||
echo "This can happen when:"
|
||||
echo " 1. You added/modified API endpoints but didn't regenerate the OpenAPI spec"
|
||||
echo " 2. Your branch is behind master, which has new API endpoints"
|
||||
echo ""
|
||||
echo "To fix this:"
|
||||
echo " 1. Pull the latest changes from master: git pull origin master"
|
||||
echo " 2. Run 'yarn generate-openapi' to update $OPENAPI_SPEC"
|
||||
echo " 3. Commit the updated openapi.json file"
|
||||
echo ""
|
||||
echo "Differences:"
|
||||
git diff "$OPENAPI_SPEC" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "OpenAPI specs are up to date."
|
||||
|
||||
exit 0
|
||||
12
deps.edn
@@ -80,11 +80,11 @@
|
||||
hiccup/hiccup {:mvn/version "2.0.0"} ; HTML templating
|
||||
inflections/inflections {:mvn/version "0.15.0"} ; Clojure/Script library used for prularizing words
|
||||
instaparse/instaparse {:mvn/version "1.5.0"} ; Make your own parser
|
||||
io.github.camsaul/toucan2 {:mvn/version "1.0.569"}
|
||||
io.github.camsaul/toucan2 {:mvn/version "1.0.570"}
|
||||
io.github.eerohele/pp {#_#_:git/tag "2024-11-13.77" ; super fast pretty-printing library
|
||||
:git/sha "ffc4edc482c057cd9412c62f018a41e9140c618b"
|
||||
:git/url "https://github.com/eerohele/pp"}
|
||||
io.github.metabase/macaw {:mvn/version "0.2.30"} ; Parse native SQL queries
|
||||
io.github.metabase/macaw {:mvn/version "0.2.33"} ; Parse native SQL queries
|
||||
io.netty/netty-codec-http {:mvn/version "4.2.9.Final"} ; override of transitive dep from aws s3
|
||||
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"} ; prometheus jvm collector
|
||||
junegunn/grouper {:mvn/version "0.1.1"} ; Batch processing helper
|
||||
@@ -362,6 +362,8 @@
|
||||
"-Dmb.dangerous.unsafe.enable.testing.h2.connections.do.not.enable=true"
|
||||
;; Ignore connection URI in favor of the MB_DB_FILE
|
||||
"-Dmb.db.connection.uri="
|
||||
;; Unset the premium token so that tests can set it programmatically
|
||||
"-Dmb.premium.embedding.token="
|
||||
;; Ignore config.yml
|
||||
"-Dmb.config.file.path=__cypress__"
|
||||
;; We use Snowplow micro in e2e tests
|
||||
@@ -825,6 +827,12 @@
|
||||
:exec-fn dev.h2/shell
|
||||
:jvm-opts ["-Dfile.encoding=UTF-8"]}
|
||||
|
||||
;; connect to an H2 TCP listener:
|
||||
;; clojure -M:connect-to-h2-tcp --port 9092 --db-file /path/to/metabase
|
||||
:connect-to-h2-tcp
|
||||
{:extra-paths ["dev/src"]
|
||||
:main-opts ["-m" "dev.h2"]}
|
||||
|
||||
:generate-automagic-dashboards-pot
|
||||
{:main-opts ["-m" "metabase.xrays.automagic-dashboards.dashboard-templates"]}
|
||||
|
||||
|
||||
487
dev/src/dev/coverage.clj
Normal file
@@ -0,0 +1,487 @@
|
||||
(ns dev.coverage
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[clojure.string :as str]
|
||||
[clojure.test :as t]
|
||||
[metabase.util.malli.registry :as m.registry]
|
||||
[rewrite-clj.node :as n]
|
||||
[rewrite-clj.zip :as rz]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- test-results [fq-test-name]
|
||||
(let [test-var (find-var fq-test-name)
|
||||
results (atom false)]
|
||||
(binding [t/report (fn [m]
|
||||
(case (:type m)
|
||||
:fail (reset! results true)
|
||||
:error (reset! results true)
|
||||
#_else nil))]
|
||||
(t/run-test-var test-var))
|
||||
(if @results
|
||||
:killed
|
||||
:survived)))
|
||||
|
||||
(defn ns-coverage [code-namespaces test-namespaces]
|
||||
(let [coverage-pairs (atom #{})
|
||||
current-test (atom nil)
|
||||
original-fns (atom {})]
|
||||
|
||||
;; Store original functions and wrap them (both public and private)
|
||||
(doseq [ns-sym code-namespaces]
|
||||
(require ns-sym :reload)
|
||||
(let [ns-obj (find-ns ns-sym)]
|
||||
(doseq [[var-name var-obj] (ns-interns ns-obj)]
|
||||
(when (and (var? var-obj)
|
||||
(or (fn? @var-obj)
|
||||
(instance? clojure.lang.MultiFn @var-obj)))
|
||||
(let [original-fn @var-obj
|
||||
full-name (symbol (str ns-sym) (str var-name))]
|
||||
|
||||
;; Store original function
|
||||
(swap! original-fns assoc full-name original-fn)
|
||||
|
||||
;; Replace with tracking wrapper
|
||||
(alter-var-root var-obj
|
||||
(constantly
|
||||
(fn [& args]
|
||||
(when-let [test @current-test]
|
||||
(swap! coverage-pairs conj [full-name test]))
|
||||
(apply original-fn args)))))))))
|
||||
|
||||
;; Run tests, tracking which test is running
|
||||
(doseq [test-ns test-namespaces]
|
||||
(require test-ns :reload)
|
||||
(let [test-vars (->> (ns-interns test-ns)
|
||||
vals
|
||||
(filter (fn [v] (:test (meta v)))))]
|
||||
(doseq [^clojure.lang.Var test-var test-vars]
|
||||
(let [test-name (symbol (str test-ns) (name (.sym test-var)))]
|
||||
(reset! current-test test-name)
|
||||
(t/test-var test-var)))))
|
||||
|
||||
;; Restore original functions
|
||||
(doseq [ns-sym code-namespaces]
|
||||
(let [ns-obj (find-ns ns-sym)]
|
||||
(doseq [[var-name var-obj] (ns-interns ns-obj)]
|
||||
(when-let [original-fn (get @original-fns (symbol (str ns-sym) (str var-name)))]
|
||||
(alter-var-root var-obj (constantly original-fn))))))
|
||||
|
||||
;; Return set of [function, test] tuples
|
||||
(let [all-fns (set (keys @original-fns))
|
||||
coverage @coverage-pairs
|
||||
covered-fns (set (map first coverage))]
|
||||
{:coverage-pairs coverage
|
||||
:coverage (reduce #(merge-with into %1 %2) {} (for [[f t] coverage]
|
||||
{f #{t}}))
|
||||
:all-fns all-fns
|
||||
:uncovered-fns (set/difference all-fns covered-fns)
|
||||
:covered-fns covered-fns})))
|
||||
|
||||
(def mutation-rules
|
||||
"Map of symbols to their mutations"
|
||||
{'and 'or
|
||||
'or 'and
|
||||
'boolean 'not
|
||||
'not 'boolean
|
||||
'empty? 'seq
|
||||
'seq 'empty?
|
||||
'empty 'not-empty
|
||||
'not-empty 'empty
|
||||
'<= '<
|
||||
'< '<=
|
||||
'>= '>
|
||||
'> '>=
|
||||
'= 'not=
|
||||
'not= '=
|
||||
'for 'doseq
|
||||
'+ '-
|
||||
'- '+
|
||||
'* '/
|
||||
'/ '*
|
||||
'true 'false
|
||||
'false 'true
|
||||
'nil? 'some?
|
||||
'some? 'nil?
|
||||
'inc 'dec
|
||||
'dec 'inc
|
||||
'when 'when-not
|
||||
'when-not 'when
|
||||
'if 'if-not
|
||||
'if-not 'if
|
||||
'do 'comment
|
||||
'let 'comment})
|
||||
|
||||
(defn find-function-source
|
||||
"Find the source code for a fully-qualified function name"
|
||||
[fq-name]
|
||||
(let [ns-sym (symbol (namespace fq-name))
|
||||
fn-name (symbol (name fq-name))]
|
||||
(when-let [var-obj (ns-resolve ns-sym fn-name)]
|
||||
(when-let [file (:file (meta var-obj))]
|
||||
(when (and file (not= file "NO_SOURCE_PATH"))
|
||||
(when-let [line (:line (meta var-obj))]
|
||||
{:file file
|
||||
:line line
|
||||
:var var-obj
|
||||
:ns ns-sym
|
||||
:name fn-name}))))))
|
||||
|
||||
(defn read-function-from-file
|
||||
"Read the function definition from its source file"
|
||||
[{:keys [file line]}]
|
||||
(let [;; Try different possible file paths
|
||||
possible-paths [(str "src/" file)
|
||||
file
|
||||
(str "test/" file)]
|
||||
source-file (first (filter #(and % (.exists (java.io.File. ^String %))) possible-paths))]
|
||||
(when-not source-file
|
||||
(throw (ex-info (str "Could not find source file for: " file)
|
||||
{:file file
|
||||
:tried possible-paths})))
|
||||
(let [source (slurp source-file)
|
||||
zloc (rz/of-string source)]
|
||||
;; Find the defn at the specified line
|
||||
(loop [loc zloc]
|
||||
(when-not (rz/end? loc)
|
||||
(if (and (rz/list? loc)
|
||||
(let [first-child (rz/down loc)]
|
||||
(when (and first-child (rz/sexpr-able? first-child))
|
||||
(let [first-sym (rz/sexpr first-child)]
|
||||
;; Match various function definition forms
|
||||
(or (contains? #{'defn 'defn- 'defmethod} first-sym)
|
||||
;; Match qualified defn macros like mu/defn, s/defn, etc.
|
||||
(and (symbol? first-sym)
|
||||
(re-matches #".*defn-?" (name first-sym)))))))
|
||||
(= line (get (meta (rz/node loc)) :row)))
|
||||
(rz/string loc)
|
||||
(recur (rz/next loc))))))))
|
||||
|
||||
(defn- schema-keyword? [k]
|
||||
(or (contains? #{:-
|
||||
:string, :int, :double, :boolean, :keyword, :symbol, :nil, :uuid, :inst
|
||||
:vector, :set, :map, :tuple
|
||||
:map-of, :map-of-schema
|
||||
:sequential, :cat
|
||||
:enum :ref :optional :some :maybe :min :max :re}
|
||||
k)
|
||||
(m.registry/schema k)))
|
||||
|
||||
(defn generate-all-mutations-for-loc
|
||||
"Generate all possible mutations at a single location"
|
||||
[zloc]
|
||||
(when (rz/sexpr-able? zloc)
|
||||
(let [sexpr (rz/sexpr zloc)]
|
||||
(cond
|
||||
;; Symbol mutations (like and -> or, plus always try nil)
|
||||
(symbol? sexpr)
|
||||
(let [nil-mutation {:mutation (rz/root-string (rz/replace zloc (n/token-node nil)))
|
||||
:description (str "Replace " sexpr " with nil")
|
||||
:original sexpr
|
||||
:replacement nil
|
||||
:position (meta (rz/node zloc))}]
|
||||
(if-let [replacement (get mutation-rules sexpr)]
|
||||
[{:mutation (rz/root-string (rz/replace zloc (n/token-node replacement)))
|
||||
:description (str "Replace " sexpr " with " replacement)
|
||||
:original sexpr
|
||||
:replacement replacement
|
||||
:position (meta (rz/node zloc))}
|
||||
nil-mutation]
|
||||
[nil-mutation]))
|
||||
|
||||
;; Keyword mutations (like :id -> :id__)
|
||||
;; auto-resolved keywords don't work quite right when using rz/sexpr
|
||||
;; so read the string representation instead
|
||||
;; this will expand the namespace to a fully-qualified form
|
||||
;; this requires that *ns* is bound correctly
|
||||
(keyword? sexpr)
|
||||
(let [keyword-string (rz/string zloc)
|
||||
sexpr (read-string keyword-string)]
|
||||
(when (and (not (schema-keyword? sexpr))
|
||||
(not= sexpr :else))
|
||||
(let [mutated-keyword (keyword (str (name sexpr) "__"))]
|
||||
[{:mutation (rz/root-string (rz/replace zloc (n/token-node mutated-keyword)))
|
||||
:description (str "Replace " sexpr " with " mutated-keyword)
|
||||
:original sexpr
|
||||
:replacement mutated-keyword
|
||||
:position (meta (rz/node zloc))}])))
|
||||
|
||||
;; Number mutations (0 -> 1, other numbers -> 0)
|
||||
(number? sexpr)
|
||||
(let [replacement (if (zero? sexpr) 1 0)]
|
||||
[{:mutation (rz/root-string (rz/replace zloc (n/token-node replacement)))
|
||||
:description (str "Replace " sexpr " with " replacement)
|
||||
:original sexpr
|
||||
:replacement replacement
|
||||
:position (meta (rz/node zloc))}])
|
||||
|
||||
:else nil))))
|
||||
|
||||
(defn walk-and-mutate
|
||||
"Walk the AST and generate all possible mutations"
|
||||
[zloc]
|
||||
(loop [loc zloc
|
||||
mutations []]
|
||||
(if (rz/end? loc)
|
||||
mutations
|
||||
(let [new-mutations (or (generate-all-mutations-for-loc loc) [])]
|
||||
(recur (rz/next loc) (into mutations new-mutations))))))
|
||||
|
||||
(defn generate-mutations
|
||||
"Generate all mutations for a function given its fully-qualified name"
|
||||
[fq-name]
|
||||
(binding [*ns* (find-ns (symbol (namespace fq-name)))]
|
||||
(when-let [fn-info (find-function-source fq-name)]
|
||||
(when-let [fn-source (read-function-from-file fn-info)]
|
||||
(let [zloc (rz/of-string fn-source)
|
||||
mutations (walk-and-mutate zloc)]
|
||||
{:function fq-name
|
||||
:original-source fn-source
|
||||
:mutations mutations
|
||||
:count (count mutations)})))))
|
||||
|
||||
(defn- reduced-if [f p?]
|
||||
(fn [& args]
|
||||
(let [r (apply f args)]
|
||||
(if (p? r)
|
||||
(reduced r)
|
||||
r))))
|
||||
|
||||
(defn test-mutations
|
||||
"Test all mutations of a function with specific tests.
|
||||
Returns a map with:
|
||||
- :killed - mutations that caused test failures
|
||||
- :survived - mutations that passed all tests
|
||||
- :total - total number of mutations
|
||||
- :kill-rate - percentage of mutations killed"
|
||||
[fq-name test-names]
|
||||
(when-let [mutation-data (generate-mutations fq-name)]
|
||||
(let [ns-sym (symbol (namespace fq-name))
|
||||
var-obj (find-var fq-name)
|
||||
original-fn @var-obj
|
||||
original-meta (meta var-obj)
|
||||
results (atom {:killed [] :survived []})]
|
||||
|
||||
;; Test each mutation
|
||||
(doseq [[idx mutation] (map-indexed vector (:mutations mutation-data))]
|
||||
(try
|
||||
;; Evaluate the mutated function to replace the original
|
||||
(binding [*ns* (find-ns ns-sym)]
|
||||
(eval (read-string (:mutation mutation))))
|
||||
|
||||
;; Run tests until one fails (early termination)
|
||||
(let [result (loop [test-names test-names]
|
||||
(cond
|
||||
(empty? test-names)
|
||||
:survived
|
||||
|
||||
(= :killed (test-results (first test-names)))
|
||||
:killed
|
||||
|
||||
:else
|
||||
(recur (rest test-names))))]
|
||||
(swap! results update result conj mutation))
|
||||
|
||||
(catch Exception _e
|
||||
;; Mutation caused compilation/runtime error - count as killed
|
||||
(swap! results update :killed conj mutation))
|
||||
(finally
|
||||
;; Restore the original function AND var metadata
|
||||
(alter-var-root var-obj (constantly original-fn))
|
||||
(alter-meta! var-obj (constantly original-meta)))))
|
||||
|
||||
(assoc @results :original-source (:original-source mutation-data)))))
|
||||
|
||||
(defn- index-by [f coll]
|
||||
(into {} (for [v coll] [(f v) v])))
|
||||
|
||||
(defn test-namespace [target-ns test-ns-s]
|
||||
(let [coverage (ns-coverage [target-ns] test-ns-s)
|
||||
covered (into {}
|
||||
(for [[f tests] (:coverage coverage)
|
||||
:let [results (test-mutations f tests)]]
|
||||
[f (assoc results :function f :tests tests)]))
|
||||
uncovered (into {}
|
||||
(for [f (:uncovered-fns coverage)
|
||||
:let [fn-info (find-function-source f)
|
||||
source (when fn-info (read-function-from-file fn-info))]]
|
||||
[f {:function f :original-source source}]))]
|
||||
(merge uncovered covered)))
|
||||
|
||||
(defonce state (atom nil))
|
||||
|
||||
(defn init! [target-ns test-ns-s]
|
||||
(reset! state (test-namespace target-ns test-ns-s)))
|
||||
|
||||
(defn add-test [fq-name test-name]
|
||||
(swap! state update-in [fq-name :tests] (fnil conj #{}) test-name))
|
||||
|
||||
(defn retest-function! [fq-name]
|
||||
(let [entry (get @state fq-name)
|
||||
test-names (:tests entry)
|
||||
results (test-mutations fq-name test-names)
|
||||
new-entry (assoc results :function fq-name :tests test-names)]
|
||||
(swap! state assoc fq-name new-entry)
|
||||
results))
|
||||
|
||||
(defn generate-report [target-ns test-ns-s out-file]
|
||||
(let [results (test-namespace target-ns test-ns-s)
|
||||
fns (vals results)
|
||||
uncovered (sort-by :function (filter #(not (contains? % :tests)) fns))
|
||||
partially-covered (sort-by :function (filter #(seq (:survived %)) fns))
|
||||
fully-covered (sort-by :function (filter #(and (contains? % :tests)
|
||||
(empty? (:survived %))) fns))
|
||||
s #_:clj-kondo/ignore ;; it's wrapped in `with-out-str`
|
||||
(with-out-str
|
||||
(println "# Mutation Testing Namespace Report")
|
||||
(println)
|
||||
(println "Namespace:" target-ns)
|
||||
(println)
|
||||
(println "Test namespaces:" (str/join ", " (sort test-ns-s)))
|
||||
|
||||
(when (seq uncovered)
|
||||
(println)
|
||||
(println "## Uncovered Functions")
|
||||
(println)
|
||||
(println "These functions are never executed when the tests are run.")
|
||||
(doseq [{:keys [function original-source]} uncovered]
|
||||
(println)
|
||||
(println "###" function)
|
||||
(println)
|
||||
(println "```")
|
||||
(println original-source)
|
||||
(println "```")))
|
||||
|
||||
(when (seq partially-covered)
|
||||
(println)
|
||||
(println "## Partially Covered Functions")
|
||||
(println)
|
||||
(println "These functions are executed but have mutations that survive.")
|
||||
(doseq [{:keys [function survived original-source]} partially-covered]
|
||||
(println)
|
||||
(println "###" function)
|
||||
(println)
|
||||
(println "Original code")
|
||||
(println)
|
||||
(println "```")
|
||||
(println original-source)
|
||||
(println "```")
|
||||
(doseq [{:keys [description mutation]} survived]
|
||||
(println)
|
||||
(println "#### Mutation:" description)
|
||||
(println)
|
||||
(println "```")
|
||||
(println mutation)
|
||||
(println "```"))))
|
||||
|
||||
(when (seq fully-covered)
|
||||
(println)
|
||||
(println "## Fully Covered Functions")
|
||||
(println)
|
||||
(println "These functions are fully covered by the test namespaces.")
|
||||
(doseq [{:keys [original-source function]} fully-covered]
|
||||
(println)
|
||||
(println "###" function)
|
||||
(println)
|
||||
(println "Original code")
|
||||
(println)
|
||||
(println "```")
|
||||
(println original-source)
|
||||
(println "```"))))]
|
||||
(spit out-file s)))
|
||||
|
||||
(comment
|
||||
|
||||
;; Step 1: Manually specify namespaces instead of using discovery
|
||||
(def target-ns 'metabase.lib.card)
|
||||
(def test-ns 'metabase.lib.card-test)
|
||||
|
||||
(def results (test-namespace target-ns [test-ns]))
|
||||
|
||||
(generate-report 'metabase.lib.card ['metabase.lib.card-test] "mutation-testing-report.lib.card.md")
|
||||
|
||||
(clojure.pprint/pprint results)
|
||||
(:fully-covered results)
|
||||
(println (:uncovered-fns results))
|
||||
(first (:partially-covered results))
|
||||
(metabase.util.malli.fn/instrument-ns? *ns*)
|
||||
|
||||
(doseq [{:keys [function survived]} (vals (:partially-covered results))]
|
||||
(println)
|
||||
(println)
|
||||
(println "#" function)
|
||||
(doseq [{:keys [description mutation]} survived]
|
||||
(println)
|
||||
(println "## Description:" description)
|
||||
(println)
|
||||
(println "```")
|
||||
(println mutation)
|
||||
(println "```")))
|
||||
|
||||
;; Load them
|
||||
(require target-ns :reload)
|
||||
(require test-ns :reload)
|
||||
|
||||
(def c (ns-coverage [target-ns] [test-ns]))
|
||||
(clojure.pprint/pprint c)
|
||||
(contains? (:all-fns c) 'metabase.queries.models.card/has-temporal-dimension?)
|
||||
|
||||
(metabase.lib.card/source-card-is-model? {:id 1})
|
||||
|
||||
(generate-mutations 'metabase.lib.card/source-card-is-model?)
|
||||
|
||||
(set (map long (map :hits c)))
|
||||
|
||||
(doseq [l
|
||||
(take 10 c)]
|
||||
(prn l))
|
||||
|
||||
(def fs (split-covered-functions c target-ns))
|
||||
|
||||
(pr-str (namespace (rz/string (rz/of-string "::jj/k"))))
|
||||
(count (:hit fs))
|
||||
|
||||
;; Generate mutations for a function
|
||||
(def mutations (generate-mutations 'metabase.queries.models.card/some-function-name))
|
||||
|
||||
;; See how many mutations were generated
|
||||
(:count mutations)
|
||||
|
||||
;; Look at the mutations
|
||||
(doseq [m (:mutations mutations)]
|
||||
(println (:description m)))
|
||||
|
||||
(generate-mutations 'metabase.queries.models.card/source-card-id)
|
||||
|
||||
;; Get tests that call a specific function
|
||||
(def fn-name 'metabase.queries.models.card/source-card-id)
|
||||
(def tests-for-fn
|
||||
(->> (:coverage c)
|
||||
(filter (fn [[fn test]] (= fn fn-name)))
|
||||
(map second)
|
||||
vec))
|
||||
|
||||
;; Test all mutations with those tests
|
||||
(def mutation-results (test-mutations fn-name tests-for-fn))
|
||||
|
||||
;; See the results
|
||||
(println "Total mutations:" (:total mutation-results))
|
||||
(println "Killed:" (:killed-count mutation-results))
|
||||
(println "Survived:" (:survived-count mutation-results))
|
||||
(println "Kill rate:" (* 100 (:kill-rate mutation-results)) "%")
|
||||
|
||||
(test-results 'metabase.lib.card-test/source-card-is-model?-test)
|
||||
|
||||
(t/run-test 'metabase.lib.card-test/source-card-is-model?-test)
|
||||
|
||||
;; Look at mutations that survived (potential test gaps)
|
||||
(doseq [m (:survived mutation-results)]
|
||||
(println "SURVIVED:" (:description m))))
|
||||
|
||||
(comment
|
||||
|
||||
(def results (test-namespace 'metabase.lib.stage '[metabase.lib.stage-test]))
|
||||
|
||||
(test-mutations 'metabase.lib.card/source-card-is-model? #{'metabase.lib.card-test/source-card-is-model?-test})
|
||||
(binding [*ns* (find-ns 'metabase.lib.card)]
|
||||
(eval (read-string "(mu/defn source-card-is-model? :- :boolean\n \"Is the query's source-card a model?\"\n [query :- ::lib.schema/query]\n (= (source-card-type query) :model__))"))))
|
||||
@@ -1,5 +1,6 @@
|
||||
(ns dev.h2
|
||||
(:require
|
||||
[clojure.tools.cli :as cli]
|
||||
[environ.core :as env]
|
||||
[metabase.app-db.data-source :as mdb.data-source]
|
||||
[metabase.app-db.env :as mdb.env])
|
||||
@@ -24,9 +25,51 @@
|
||||
(def ^:private tcp-listener (atom nil))
|
||||
|
||||
(defn tcp-listen
|
||||
"Starts a TCP server for the H2 app-db database on port 9092."
|
||||
[]
|
||||
(when @tcp-listener
|
||||
(throw (ex-info "TCP listener is already running" {:port 9092})))
|
||||
(reset! tcp-listener (.start (Server/createTcpServer (into-array ["-tcp" "-tcpAllowOthers" "-tcpPort" "9092"]))))
|
||||
(println (str "H2 TCP server started (no username or password): jdbc:h2:tcp://localhost:9092/" (#'metabase.app-db.env/env->db-file metabase.app-db.env/env))))
|
||||
"Starts a TCP server for the H2 app-db database on the specified port."
|
||||
[port]
|
||||
(when-not (= "h2" (:mb-db-type env/env))
|
||||
(throw (ex-info "The database type is not H2" {:db-type (:mb-db-type env/env)})))
|
||||
(when-let [listener @tcp-listener]
|
||||
(throw (ex-info "TCP listener is already running" {:port (:port listener)})))
|
||||
(let [server (.start (Server/createTcpServer (into-array ["-tcp" "-tcpAllowOthers" "-tcpPort" (str port)])))]
|
||||
(reset! tcp-listener {:server server :port port})
|
||||
(println (str "H2 TCP server started (no username or password): jdbc:h2:tcp://localhost:" port "/"
|
||||
(#'metabase.app-db.env/env->db-file metabase.app-db.env/env)))))
|
||||
|
||||
(defn connect-tcp
|
||||
"Open an H2 shell connected to a TCP listener."
|
||||
[{:keys [port db-file]}]
|
||||
(let [url (str "jdbc:h2:tcp://localhost:" port "/" db-file)]
|
||||
(println "Connecting to database at URL" url)
|
||||
(org.h2.tools.Shell/main (into-array String ["-url" url]))))
|
||||
|
||||
(def ^:private connect-cli-spec
|
||||
[["-h" "--help" "Show this help text"]
|
||||
["-p" "--port PORT" "H2 TCP listener port"
|
||||
:parse-fn #(Integer/parseInt %)]
|
||||
["-f" "--db-file PATH" "H2 database file path"]])
|
||||
|
||||
(defn -main
|
||||
"CLI entry point for connecting to an H2 TCP listener."
|
||||
[& args]
|
||||
(let [{:keys [options errors summary]} (cli/parse-opts args connect-cli-spec)
|
||||
{:keys [help port db-file]} options]
|
||||
(when help
|
||||
#_:clj-kondo/ignore
|
||||
(do
|
||||
(println "Usage: clojure -M:connect-to-h2-tcp --port PORT --db-file PATH")
|
||||
(println "Options:")
|
||||
(println summary))
|
||||
(System/exit 0))
|
||||
(when (seq errors)
|
||||
#_:clj-kondo/ignore
|
||||
(doseq [error errors]
|
||||
(println error))
|
||||
(System/exit 1))
|
||||
(when-not (and port db-file)
|
||||
#_:clj-kondo/ignore
|
||||
(do
|
||||
(println "Both --port and --db-file are required.")
|
||||
(println "Usage: clojure -M:connect-to-h2-tcp --port PORT --db-file PATH"))
|
||||
(System/exit 1))
|
||||
(connect-tcp {:port port :db-file db-file})))
|
||||
|
||||
@@ -306,6 +306,7 @@
|
||||
{:current-user-id api/*current-user-id*
|
||||
:current-user-perms @api/*current-user-permissions-set*
|
||||
:is-superuser? api/*is-superuser?*
|
||||
:is-data-analyst api/*is-data-analyst?*
|
||||
:is-impersonated-user? (perms-util/impersonated-user?)
|
||||
:is-sandboxed-user? (perms-util/impersonated-user?)
|
||||
:archived false
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
|
||||
(def cli-spec [["-h" "--help" "Show this help text"]
|
||||
["-H" "--hot" "Enable hot reloading"] ;
|
||||
[nil "--h2-tcp-port PORT" "Start an H2 TCP listener on the specified port"
|
||||
:parse-fn #(Integer/parseInt %)]
|
||||
["-p" "--port PORT" "Port to run the nREPL server on"
|
||||
:default 50605
|
||||
:parse-fn #(Integer/parseInt %)]])
|
||||
@@ -75,9 +77,10 @@
|
||||
|
||||
Command Line Args:
|
||||
|
||||
`--hot` - Checks for modified files and reloads them during a request."
|
||||
`--hot` - Checks for modified files and reloads them during a request.
|
||||
`--h2-tcp-port` - Starts an H2 TCP listener on the specified port."
|
||||
[& args]
|
||||
(let [{:keys [help hot port]} (:options (cli/parse-opts args cli-spec))]
|
||||
(let [{:keys [help hot h2-tcp-port port]} (:options (cli/parse-opts args cli-spec))]
|
||||
(when help
|
||||
#_:clj-kondo/ignore
|
||||
(do
|
||||
@@ -89,6 +92,8 @@
|
||||
#_:clj-kondo/ignore
|
||||
(println "Enabling hot reloading of code. Backend code will reload on every request.")
|
||||
(alter-var-root #'*enable-hot-reload* (constantly true)))
|
||||
(when h2-tcp-port
|
||||
((requiring-resolve 'dev.h2/tcp-listen) h2-tcp-port))
|
||||
(future
|
||||
#_:clj-kondo/ignore
|
||||
(println "Starting Metabase cider repl on port" port)
|
||||
|
||||
2685
docs/api.json
@@ -16,7 +16,7 @@ Since we host your Metabase for you, you may need to add our IP addresses to the
|
||||
|
||||
Here are the IP addresses we use in each region.
|
||||
|
||||
## United States (North Virginia)
|
||||
## United States (North Virginia) - `us-east-1`
|
||||
|
||||
```sh
|
||||
18.207.81.126
|
||||
@@ -24,7 +24,7 @@ Here are the IP addresses we use in each region.
|
||||
50.17.234.169
|
||||
```
|
||||
|
||||
## Europe (Frankfurt)
|
||||
## Europe (Frankfurt) - `eu-central-1`
|
||||
|
||||
```sh
|
||||
18.192.2.142
|
||||
@@ -32,7 +32,7 @@ Here are the IP addresses we use in each region.
|
||||
3.65.184.173
|
||||
```
|
||||
|
||||
## Asia Pacific (Singapore)
|
||||
## Asia Pacific (Singapore) - `ap-southeast-1`
|
||||
|
||||
```sh
|
||||
54.179.42.215
|
||||
@@ -40,7 +40,7 @@ Here are the IP addresses we use in each region.
|
||||
18.139.48.211
|
||||
```
|
||||
|
||||
## Asia Pacific (Sydney)
|
||||
## Asia Pacific (Sydney) - `ap-southeast-2`
|
||||
|
||||
```sh
|
||||
13.238.131.1
|
||||
@@ -48,7 +48,7 @@ Here are the IP addresses we use in each region.
|
||||
54.252.39.14
|
||||
```
|
||||
|
||||
## South America (São Paulo)
|
||||
## South America (São Paulo) - `sa-east-1`
|
||||
|
||||
```sh
|
||||
18.228.120.123
|
||||
|
||||
@@ -224,6 +224,7 @@ config:
|
||||
remote-sync-auto-import-rate: 5
|
||||
remote-sync-check-changes-cache-ttl-seconds: 60
|
||||
remote-sync-task-time-limit-ms: 300000
|
||||
remote-sync-transforms: false
|
||||
report-timezone: null
|
||||
reset-token-ttl-hours: 48
|
||||
retry-initial-interval: 500
|
||||
@@ -271,6 +272,12 @@ config:
|
||||
site-url: null
|
||||
slack-app-token: null
|
||||
slack-bug-report-channel: metabase-bugs
|
||||
slack-connect-attribute-team-id: https://slack.com/team_id
|
||||
slack-connect-authentication-mode: sso
|
||||
slack-connect-client-id: null
|
||||
slack-connect-client-secret: null
|
||||
slack-connect-enabled: false
|
||||
slack-connect-user-provisioning-enabled: true
|
||||
smtp-override-enabled: false
|
||||
source-address-header: X-Forwarded-For
|
||||
sql-jdbc-fetch-size: 500
|
||||
|
||||
@@ -107,7 +107,7 @@ Allowed iframe hosts.
|
||||
- Default: `true`
|
||||
- [Configuration file name](./config-file.md): `anon-tracking-enabled`
|
||||
|
||||
Enable the collection of anonymous usage data in order to help Metabase improve.
|
||||
Enable the collection of anonymous usage data in order to help us improve.
|
||||
|
||||
### `MB_API_KEY`
|
||||
|
||||
@@ -1385,6 +1385,14 @@ Time-to-live in seconds for the remote changes check cache. Default is 60 second
|
||||
|
||||
The maximum amount of time a remote sync task will be given to complete.
|
||||
|
||||
### `MB_REMOTE_SYNC_TRANSFORMS`
|
||||
|
||||
- Type: boolean
|
||||
- Default: `false`
|
||||
- [Configuration file name](./config-file.md): `remote-sync-transforms`
|
||||
|
||||
Whether to sync transforms via remote-sync. When enabled, all transforms, transform tags, and transform jobs are synced as a single unit (all-or-nothing).
|
||||
|
||||
### `MB_REPORT_TIMEZONE`
|
||||
|
||||
- Type: string
|
||||
@@ -1831,6 +1839,66 @@ Bot user OAuth token for connecting the Metabase Slack app. This should be used
|
||||
|
||||
The name of the channel where bug reports should be posted.
|
||||
|
||||
### `MB_SLACK_CONNECT_ATTRIBUTE_TEAM_ID`
|
||||
|
||||
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
|
||||
|
||||
- Type: string
|
||||
- Default: `https://slack.com/team_id`
|
||||
- [Configuration file name](./config-file.md): `slack-connect-attribute-team-id`
|
||||
|
||||
Slack OIDC claim for the team/workspace ID.
|
||||
|
||||
### `MB_SLACK_CONNECT_AUTHENTICATION_MODE`
|
||||
|
||||
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
|
||||
|
||||
- Type: string
|
||||
- Default: `sso`
|
||||
- [Configuration file name](./config-file.md): `slack-connect-authentication-mode`
|
||||
|
||||
Controls whether Slack can be used for SSO login or just account linking. Valid values: "sso" (default) or "link-only".
|
||||
|
||||
### `MB_SLACK_CONNECT_CLIENT_ID`
|
||||
|
||||
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
|
||||
|
||||
- Type: string
|
||||
- Default: `null`
|
||||
- [Configuration file name](./config-file.md): `slack-connect-client-id`
|
||||
|
||||
Client ID for your Slack app. Get this from https://api.slack.com/apps.
|
||||
|
||||
### `MB_SLACK_CONNECT_CLIENT_SECRET`
|
||||
|
||||
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
|
||||
|
||||
- Type: string
|
||||
- Default: `null`
|
||||
- [Configuration file name](./config-file.md): `slack-connect-client-secret`
|
||||
|
||||
Client Secret for your Slack app.
|
||||
|
||||
### `MB_SLACK_CONNECT_ENABLED`
|
||||
|
||||
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
|
||||
|
||||
- Type: boolean
|
||||
- Default: `false`
|
||||
- [Configuration file name](./config-file.md): `slack-connect-enabled`
|
||||
|
||||
Is Slack Connect authentication configured and enabled?
|
||||
|
||||
### `MB_SLACK_CONNECT_USER_PROVISIONING_ENABLED`
|
||||
|
||||
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
|
||||
|
||||
- Type: boolean
|
||||
- Default: `true`
|
||||
- [Configuration file name](./config-file.md): `slack-connect-user-provisioning-enabled`
|
||||
|
||||
When a user logs in via Slack Connect, create a Metabase account for them automatically if they don't have one.
|
||||
|
||||
### `MB_SLACK_FILES_CHANNEL [DEPRECATED]`
|
||||
|
||||
> DEPRECATED: 0.54.0
|
||||
|
||||
@@ -33,6 +33,8 @@ This runs both the [frontend](#frontend) and [backend](#backend). Alternatively,
|
||||
|
||||
To use any other database beside the default ones please take a look at [Building Drivers](#building-drivers) further down in this document.
|
||||
|
||||
To skip the setup wizard and pre-configure users, database connections, and settings, add a [Metabase config file](https://www.metabase.com/docs/latest/configuring-metabase/config-file) to the directory where you run Metabase.
|
||||
|
||||
### Frontend
|
||||
|
||||
Metabase depends on third-party libraries to run, so you'll need to keep those up to date. The Clojure CLI will automatically fetch the dependencies when needed. With JavaScript dependencies, however, you'll need to kick off the installation process manually.
|
||||
|
||||
@@ -6,6 +6,8 @@ title: Driver interface changelog
|
||||
|
||||
## Metabase 0.59.0
|
||||
|
||||
- Added `metabase.driver/llm-sql-dialect-resource` multimethod. Returns the resource path for dialect-specific LLM prompt instructions, or nil if no dialect-specific instructions exist for this driver.
|
||||
|
||||
- Added `sql-jdbc.execute/db-type-name` multimethod. Override this if something more than the default is needed in your sql-jdbc-based driver. See the `:mysql` implementation as an example.
|
||||
|
||||
## Metabase 0.58.0
|
||||
|
||||
@@ -138,6 +138,8 @@ Please note that some users experienced Mongo connection timeouts even with this
|
||||
|
||||
### Running tests that depend on Docker images
|
||||
|
||||
Due to the requirement to use privileged ports for some tests in hosted environments, it is not possible to utilize **podman** or the **rootless Docker**. Stick to the classic Docker, or use OrbStack.
|
||||
|
||||
A large portion of our tests depend on the external services that are available through the Docker images. At the time of this writing, those are the three supported external QA databases, Webmail, Snowplow and LDAP servers. See `e2e/test/scenarios/docker-compose.yml` for up to date information. The default cypress command will spin up all necessary Docker containers for the tests to function properly. You can manually set up the e2e environment without them but be aware that you will run into test failures.
|
||||
|
||||
### Running tests with Snowplow involved
|
||||
|
||||
@@ -20,8 +20,9 @@ In cases where types are only used by some local components, they should be defi
|
||||
## Redux
|
||||
|
||||
We use Redux for global state. You will find domain-specific actions, reducers, and selectors generally grouped with the components that use them. e.g:
|
||||
- [query_builder](https://github.com/metabase/metabase/tree/master/frontend/src/metabase/query_builder)
|
||||
- [dashboard](https://github.com/metabase/metabase/tree/master/frontend/src/metabase/dashboard)
|
||||
|
||||
- [query_builder](https://github.com/metabase/metabase/tree/master/frontend/src/metabase/query_builder)
|
||||
- [dashboard](https://github.com/metabase/metabase/tree/master/frontend/src/metabase/dashboard)
|
||||
|
||||
Use Redux global state as little as possible, and wherever possible, prefer local component state, or narrowly-defined context.
|
||||
|
||||
@@ -40,6 +41,7 @@ Our UI library in `metabase/ui` is built on top of [`Mantine`](https://mantine.d
|
||||
## Styling
|
||||
|
||||
You'll note several styling patterns in the codebase. Currently you should prefer
|
||||
|
||||
1. [Mantine Style Props](https://mantine.dev/styles/style-props/) for most simple styling
|
||||
2. [CSS Modules](https://github.com/css-modules/css-modules) for more complex styling
|
||||
|
||||
@@ -49,7 +51,7 @@ Familiarize yourself with Mantine's Layout components. You can often save a lot
|
||||
|
||||
## Colors
|
||||
|
||||
You may not define new bespoke colors or use any color literals like `black` or `white`. Colors can be referenced directly in components using mantine color props like `c="text-primary"` or in css modules using variables `color: var(--mb-color-text-primary);` Using these colors ensures consistent visual design and user experience across both dark and light modes. The full palette is defined in `metabase/lib/colors.ts`.
|
||||
Colors should only be used in the form of mantine color props (primarily `c` and `bg`), or in css modules using variables eg: `color: var(--mb-color-text-primary);`. Using these colors ensures consistent visual design and user experience across both dark and light modes. Literal color values such as `black` or `#FFF` are not allowed, as well as using `color-mix` in your CSS modules to adjust the color or transparency of a variable. The full list of keys can be found in `frontend/src/metabase/lib/colors/types/color-keys.ts`, with their light and dark values found in `frontend/src/metabase/lib/colors/constants/themes`. If you find yourself requiring a color that does not already exist but is present in designs, reach out to the design team for guidance.
|
||||
|
||||
## Unit testing
|
||||
|
||||
@@ -170,7 +172,6 @@ const output = t`${name} is going to the ${place} with ${anotherName}`;
|
||||
// 😍
|
||||
const output = c("{0} and {2} are people's names, and {1} is a place")
|
||||
.t`${name} is going to the ${place} with ${anotherName}`;
|
||||
|
||||
```
|
||||
|
||||
## Style Guide
|
||||
@@ -213,6 +214,7 @@ render () {
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- Avoid complex logical expressions inside of if statements. Often extracting logic to a well-named boolean variable can make code much easier to read.
|
||||
|
||||
```javascript
|
||||
@@ -228,6 +230,7 @@ if (isMultilineText) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
- Use ALL_CAPS for constants
|
||||
|
||||
```javascript
|
||||
@@ -240,6 +243,7 @@ const OBJECT_CONFIG_CONSTANT = {
|
||||
abc: 123,
|
||||
};
|
||||
```
|
||||
|
||||
- Avoid magic strings and numbers
|
||||
|
||||
```javascript
|
||||
@@ -250,7 +254,9 @@ const options = _.times(10, () => ...);
|
||||
export const MAX_NUM_OPTIONS = 10;
|
||||
const options = _.times(MAX_NUM_OPTIONS, () => ...);
|
||||
```
|
||||
|
||||
- prefer declarative over imperative patterns where possible. You should write code with other engineers in mind as other engineers will spend more time reading than you spend writing (and re-writing). Code is more readable when it tells the computer "what to do" versus "how to do." Avoid imperative patterns like for loops:
|
||||
|
||||
```javascript
|
||||
// don't do this
|
||||
let foo = [];
|
||||
|
||||
@@ -280,3 +280,33 @@ app.get("/sso/metabase", async (req, res) => {
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Embedding Metabase in a different domain
|
||||
|
||||
{% include plans-blockquote.html feature="Authenticated modular embeds" convert_pro_link_to_embbedding=true is_plural=true %}
|
||||
|
||||
This section applies only to **authenticated embeds**. Guest embeds work cross-domain without additional configuration.
|
||||
|
||||
If you want to embed Metabase in another domain (say, if Metabase is hosted at `metabase.yourcompany.com`, but you want to embed Metabase at `yourcompany.github.io`), you'll need to [allow your domain in CORS](#allow-your-domain-in-cors).
|
||||
|
||||
### Allow your domain in CORS
|
||||
|
||||
Go to **Admin settings** > **Embedding** > **Modular embedding** and add your embedding domain under **Cross-Origin Resource Sharing (CORS)** (such as `https://*.example.com`).
|
||||
|
||||
### Configure session cookies when testing locally
|
||||
|
||||
When you use `useExistingUserSession: true` during development on a different domain, the browser must send the existing Metabase session cookie cross-origin into the iframe. To allow this, you'll need to set the session cookie's SameSite value to "none".
|
||||
|
||||
You can set session cookie's SameSite value in **Admin settings** > **Embedding** > **Security** > **SameSite cookie setting**.
|
||||
|
||||
SameSite values include:
|
||||
|
||||
- **Lax** (default): Allows Metabase session cookies to be shared on the same domain. Used for production instances on the same domain.
|
||||
- **None (requires HTTPS)**: Use "None" when your app and Metabase are hosted on different domains. Incompatible with Safari and iOS-based browsers.
|
||||
- **Strict** (not recommended): Does not allow Metabase session cookies to be shared with embedded instances. Use this if you do not want to enable session sharing with embedding.
|
||||
|
||||
You can also set the [`MB_SESSION_COOKIE_SAMESITE` environment variable](../configuring-metabase/environment-variables.md#mb_session_cookie_samesite).
|
||||
|
||||
If you're using Safari, you'll need to [allow cross-site tracking](https://support.apple.com/en-tj/guide/safari/sfri40732/mac). Depending on the browser, you may also run into issues when viewing embedded items in private/incognito tabs.
|
||||
|
||||
Learn more about [SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite).
|
||||
|
||||
@@ -11,12 +11,16 @@ Guest embeds are a way to embed basic Metabase components in your app without re
|
||||
|
||||
Even though you're not using SSO, guest embeds are still secure: Metabase will only load the embed if the request has a JWT signed with the secret shared between your app and your Metabase. The JWT also includes a reference to the resource to load (like the ID of the embedded item), and any values for parameters.
|
||||
|
||||
To restrict data in guest embeds for specific people or groups, use [locked parameters](./static-embedding-parameters.md#restricting-data-in-a-static-embed-with-locked-parameters).
|
||||
To restrict data in guest embeds for specific people or groups, use [locked parameters](#locked-parameters).
|
||||
|
||||
## Turning on guest embedding in Metabase
|
||||
|
||||
1. Go to **Settings > Admin settings > Embedding > Guest embeds**.
|
||||
2. Toggle **Enable guest embedding**.
|
||||
The path to embedding settings depends on your Metabase version:
|
||||
|
||||
- **OSS**: **Admin settings > Embedding**
|
||||
- **Starter/Pro/Enterprise**: **Admin settings > Embedding > Guest embeds**
|
||||
|
||||
Toggle **Enable guest embeds**.
|
||||
|
||||
## Creating a guest embed
|
||||
|
||||
@@ -94,37 +98,160 @@ Replace `YOUR_METABASE_SECRET_KEY` with your [embedding secret key](#regeneratin
|
||||
|
||||
You can set different attributes to enable/disable UI. Here are some example attributes:
|
||||
|
||||
| Attribute | Description |
|
||||
| -------------------- | -------------------------------------------------------------------- |
|
||||
| `token` | Required. The signed JWT token from your server. |
|
||||
| `with-title` | Show or hide the title. Values: `"true"` or `"false"`. |
|
||||
| `with-downloads` | Enable or disable downloads. Values: `"true"` or `"false"`. |
|
||||
| `initial-parameters` | JSON string of parameter values. Example: `'{"category":["Gizmo"]}'` |
|
||||
| Attribute | Description |
|
||||
| -------------------- | --------------------------------------------------------------------- |
|
||||
| `token` | Required. The signed JWT token from your server. |
|
||||
| `with-title` | Show or hide the title. Values: `"true"` or `"false"`. |
|
||||
| `with-downloads` | Enable or disable downloads. Values: `"true"` or `"false"`. |
|
||||
| `initial-parameters` | JSON string of parameter values. Example: `'{"category":["Gizmo"]}'`. |
|
||||
|
||||
Attributes will differ based on the type of thing you're embedding. Guest embeds have fewer options that embeds that use SSO. See more on [components and their attributes](./components.md).
|
||||
Attributes will differ based on the type of thing you're embedding. Guest embeds have fewer options than embeds that use SSO. See more on [components and their attributes](./components.md).
|
||||
|
||||
### Customizing appearance of guest embeds
|
||||
|
||||
Appearance settings available for guest embeds depend on your Metabase plan. If you're running Metabase OSS/Starter, you can select light or dark theme. If you're running Metabase Pro/Enterprise, you'll have access to granular customization options, see [Appearance](./appearance.md)
|
||||
Appearance settings available for guest embeds depend on your Metabase plan. If you're running Metabase OSS/Starter, you can select light or dark theme. If you're running Metabase Pro/Enterprise, you'll have access to granular customization options, see [Appearance](./appearance.md).
|
||||
|
||||
## Editing parameters on an embedded question or dashboard
|
||||
## Configuring parameters
|
||||
|
||||
If you change the [parameters](./static-embedding-parameters.md) of your embedded item:
|
||||
Parameters are disabled by default, which also makes them hidden from end-users. You can configure each parameter to be:
|
||||
|
||||
1. After making your changes, copy the code Metabase generates.
|
||||
2. Click **Publish** again.
|
||||
3. Update the code on your server so that it matches the code generated by Metabase.
|
||||
- **Disabled**: Hidden from end-users. Can't be set. This is the default.
|
||||
- **[Editable](#editable-parameters)**: End-users can see and modify the parameter values.
|
||||
- **[Locked](#locked-parameters)**: Hidden from end-users, set by your server (not the end-users) via the JWT.
|
||||
|
||||
To configure parameters:
|
||||
|
||||
1. Go to your embedded question or dashboard.
|
||||
2. Click the **sharing icon** and select **Embed**.
|
||||
3. Under **Parameters**, select the visibility option for each parameter, and optionally default value(s).
|
||||
4. Click **Publish**.
|
||||
|
||||
### Editable parameters
|
||||
|
||||
When you set Editable parameters, you can set default values for the filters, but users can change these when viewing the question or dashboard.
|
||||
|
||||
**Server code**
|
||||
|
||||
On the server, you pass an empty `params` object:
|
||||
|
||||
```javascript
|
||||
// you will need to install via 'npm install jsonwebtoken' or in your package.json
|
||||
|
||||
const jwt = require("jsonwebtoken");
|
||||
|
||||
const METABASE_SECRET_KEY = "YOUR_METABASE_SECRET_KEY";
|
||||
|
||||
const payload = {
|
||||
resource: { dashboard: 10 },
|
||||
params: {},
|
||||
exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 minute expiration
|
||||
};
|
||||
const token = jwt.sign(payload, METABASE_SECRET_KEY);
|
||||
```
|
||||
|
||||
**Client code**
|
||||
|
||||
You set default parameters on the client side with the `initial-parameters` key.
|
||||
|
||||
```html
|
||||
<script defer src="YOUR_METABASE_URL/app/embed.js"></script>
|
||||
<script>
|
||||
function defineMetabaseConfig(config) {
|
||||
window.metabaseConfig = config;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
defineMetabaseConfig({
|
||||
isGuest: true,
|
||||
instanceUrl: "YOUR_METABASE_URL",
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
THIS IS THE EXAMPLE!
|
||||
NEVER HARDCODE THIS JWT TOKEN DIRECTLY IN YOUR HTML!
|
||||
|
||||
Fetch the JWT token from your backend and programmatically pass it to the 'metabase-dashboard'.
|
||||
-->
|
||||
<metabase-dashboard
|
||||
token="YOUR SIGNED TOKEN"
|
||||
with-title="true"
|
||||
with-downloads="false"
|
||||
initial-parameters='{"category":["Doohickey","Gizmo"]}'
|
||||
>
|
||||
</metabase-dashboard>
|
||||
```
|
||||
|
||||
### Locked parameters
|
||||
|
||||
Locked parameters let you filter data without exposing the filter to the end-user. This is useful for restricting data based on who's viewing the embed (for example, showing each customer only their own data).
|
||||
|
||||
To use locked parameters, you need to:
|
||||
|
||||
1. Set the parameter to **Locked** in the embed settings.
|
||||
2. Include the parameter value in your JWT token on the server.
|
||||
3. Publish the item.
|
||||
|
||||
Here's an example of the server and client code that Metabase will generate if you lock a "category" parameter:
|
||||
|
||||
**Server code (Node.js)**
|
||||
|
||||
You set the locked parameter on the server, passing it in the token.
|
||||
|
||||
```javascript
|
||||
// Install via 'npm install jsonwebtoken'
|
||||
const jwt = require("jsonwebtoken");
|
||||
|
||||
const METABASE_SECRET_KEY = "YOUR_METABASE_SECRET_KEY";
|
||||
|
||||
const payload = {
|
||||
resource: { dashboard: 10 },
|
||||
params: {
|
||||
category: ["Gadget"], // Set the locked parameter value to Gadget
|
||||
},
|
||||
exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 minute expiration
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, METABASE_SECRET_KEY);
|
||||
```
|
||||
|
||||
**Client code (HTML)**
|
||||
|
||||
The parameter is set by the JWT:
|
||||
|
||||
```html
|
||||
<script defer src="YOUR_METABASE_URL/app/embed.js"></script>
|
||||
<script>
|
||||
window.metabaseConfig = {
|
||||
isGuest: true, // Must be set to guest
|
||||
instanceUrl: "YOUR_METABASE_URL",
|
||||
};
|
||||
</script>
|
||||
|
||||
<!--
|
||||
IMPORTANT: Never hardcode JWT tokens directly in your HTML!
|
||||
Fetch the token from your backend and pass it to the component programmatically.
|
||||
-->
|
||||
<metabase-dashboard
|
||||
token="YOUR_JWT_TOKEN"
|
||||
with-title="true"
|
||||
with-downloads="false"
|
||||
></metabase-dashboard>
|
||||
```
|
||||
|
||||
The end user won't see the "category" filter, but the dashboard will only show data for the "Gadget" category (or whatever you passed to the "category" array in the `params` object in the server payload you used to sign the JWT).
|
||||
|
||||
## Disabling embedding for a question or dashboard
|
||||
|
||||
You can find a list of all guest embeds of questions and dashboards from **Admin settings** > **Embedding** > **Guest embeds**.
|
||||
|
||||
1. Visit the embeddable question or dashboard.
|
||||
2. Click the **sharing icon** (square with an arrow pointing to the top right).
|
||||
3. Select **Embed**.
|
||||
4. Select **Guest embedding**
|
||||
5. Click **Unpublish**.
|
||||
|
||||
Admins can find a list of embedded items in **Admin settings > Embedding** (on Pro and Enterprise plans, check the **Guest embeds** tab).
|
||||
|
||||
## Removing the "Powered by Metabase" banner
|
||||
|
||||

|
||||
@@ -135,7 +262,7 @@ The banner appears on guest embeds created with Metabase's open-source version.
|
||||
|
||||
Your embedding secret key is used to sign JWTs for all of your embeds.
|
||||
|
||||
1. Go to **Admin** > **Embedding** > **Guest embeds**.
|
||||
1. Go to **Admin settings > Embedding**. On Pro and Enterprise plans, check the **Guest embeds** tab.
|
||||
2. Under **Regenerate secret key**, click **Regenerate key**.
|
||||
|
||||
This key is shared across all guest embeds. Whoever has access to this key could get access to all embedded artifacts, so keep this key secure. If you regenerate this key, you'll need to update your server code with the new key.
|
||||
@@ -179,6 +306,12 @@ The signed JWT is generated using your [Metabase secret key](#regenerating-the-e
|
||||
|
||||
If you want to embed charts with additional interactive features, like [drill-down](https://www.metabase.com/learn/metabase-basics/querying-and-dashboards/questions/drill-through) and [self-service querying](../questions/query-builder/editor.md), see [Modular embedding](./modular-embedding.md).
|
||||
|
||||
## Using guest embeds with the SDK
|
||||
|
||||
If you're using the [Modular Embedding SDK](./sdk/introduction.md), and you also want to embed a question or dashboard using guest authentication, you'll still need to visit the item in your Metabase and publish the item. You can ignore the code the wizard generates, but in order for Metabase to know it's okay to serve the item, you need to publish it.
|
||||
|
||||
One limitation, however, is that you can only have one type of authentication per page of your app. For example, on a single page in your app, you can't have both one question using guest authentication and another question using SSO.
|
||||
|
||||
## Guest embed limitations
|
||||
|
||||
Because guest embeds don't require you to create a Metabase account for each person via SSO, Metabase can't know who is viewing the embed, and therefore can't give them access to all their data and all the cool stuff Metabase can do.
|
||||
@@ -186,16 +319,16 @@ Because guest embeds don't require you to create a Metabase account for each per
|
||||
Guest embeds can't take advantage of:
|
||||
|
||||
- [Row and column security](../permissions/row-and-column-security.md)
|
||||
- [Database routing](../permissions/database-routing.md)
|
||||
- [Drill-through](https://www.metabase.com/learn/metabase-basics/querying-and-dashboards/questions/drill-through)
|
||||
- [Usage analytics](../usage-and-performance-tools/usage-analytics.md).
|
||||
- [Usage analytics](../usage-and-performance-tools/usage-analytics.md)
|
||||
- [Query builder](../questions/query-builder/editor.md)
|
||||
- [AI chat](./sdk/ai-chat.md).
|
||||
- [AI chat](./sdk/ai-chat.md)
|
||||
|
||||
For those features, check out [Modular embedding with SSO](./modular-embedding.md).
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Parameters for guest embeds](./static-embedding-parameters.md).
|
||||
- [Reference apps repo](https://github.com/metabase/embedding-reference-apps).
|
||||
- [Strategies for delivering customer-facing analytics](https://www.metabase.com/learn/metabase-basics/embedding/overview).
|
||||
- [Publishing data visualizations to the web](https://www.metabase.com/learn/metabase-basics/embedding/charts-and-dashboards).
|
||||
|
||||
@@ -46,9 +46,9 @@ With SSO, Metabase can know who's viewing what, which unlocks a lot of power. Yo
|
||||
|
||||
### Guest embedding
|
||||
|
||||
[Guest embeds](./guest-embedding.md) are a secure way to embed charts and dashboards.
|
||||
[Guest embeds](./guest-embedding.md) are a secure way to embed charts and dashboards. Guest embedding works on all Metabase plans, including OSS and Starter.
|
||||
|
||||
**When to use guest embeds**: simple embedding use cases where you don't want to offer ad-hoc querying or chart drill-through. To filter data relevant to the viewer, you can use guest embeds with [locked parameters](./static-embedding-parameters.md#locked-parameters-limit-the-values-available-to-other-editable-parameters).
|
||||
**When to use guest embeds**: simple embedding use cases where you don't want to offer ad-hoc querying or chart drill-through. To filter data relevant to the viewer, you can use guest embeds with [locked parameters](./guest-embedding.md#locked-parameters).
|
||||
|
||||
## Public links and embeds
|
||||
|
||||
|
||||
@@ -15,12 +15,19 @@ Modular embedding lets you embed and customize Metabase [components](./component
|
||||
|
||||
If you're using React, check out the [Modular embedding SDK](./sdk/introduction.md).
|
||||
|
||||
> **Want guest embeds?** For modular embedding without authentication (available on all plans), see [Guest Embeds](./guest-embedding.md).
|
||||
|
||||
## Enable modular embedding
|
||||
|
||||
1. In Metabase, go to **Admin settings > Embedding**.
|
||||
2. Toggle on **Enable modular embedding**.
|
||||
|
||||
**For guest embeds**: You're done! Skip to [Create a new embed](#create-a-new-embed) or see the [Guest embedding guide](./guest-embedding.md) for detailed setup instructions.
|
||||
|
||||
**For authenticated embeds** (Pro/Enterprise only), some more steps:
|
||||
|
||||
3. Under **Cross-Origin Resource Sharing (CORS)**, add the URLs of the websites where you want to embed Metabase (such as `https://*.example.com`). For testing embeds, you can use `localhost` which is always included in CORS policy.
|
||||
4. If you're embedding Metabase components in a domain that's different from your Metabase's domain (including when you're testing the app locally but use Metabase Cloud), go to **Admin settings > Embedding > Security** and set **SameSite cookie** to **None**.
|
||||
4. If embedding on a different domain, see [Embedding Metabase in a different domain](./authentication.md#embedding-metabase-in-a-different-domain).
|
||||
|
||||
## Create a new embed
|
||||
|
||||
@@ -46,7 +53,9 @@ You can also go to **Admin settings > Embedding > Modular embedding** and click
|
||||
|
||||
{% include plans-blockquote.html feature="Authenticated modular embedding" convert_pro_link_to_embbedding=true %}
|
||||
|
||||
How you authenticate the embed determines how cool the embed can get. This page covers the SSO setup, which lets you do everything. This setup requires you to have [set up SSO for your Metabase](./authentication.md). You can check out a [comparison between SSO and guest](./introduction.md#comparison-of-embedding-types), or jump straight to the [guest embed docs](./guest-embedding.md). But we recommend setting up modular embedding with SSO.
|
||||
With SSO, Metabase can know who is viewing the embed, and it can unlock all of its bells and whistles (see this [comparison between SSO and guest embeds](./introduction.md#comparison-of-embedding-types)).
|
||||
|
||||
This page covers the [SSO setup for your Metabase](./authentication.md). If you don't need to set up SSO, check out the [guest embed docs](./guest-embedding.md).
|
||||
|
||||
### 3. Customize your embed
|
||||
|
||||
@@ -177,48 +186,4 @@ To define the configuration that applies to every embed on the page, use the `de
|
||||
|
||||
## Authentication
|
||||
|
||||
### Use API keys to test embeds
|
||||
|
||||
> API keys can only be used for testing embeds locally. To make your embeds production-ready or deploy them to another domain, you'll need to implement SSO.
|
||||
|
||||
To use an API key to test your embeds:
|
||||
|
||||
1. Create an [API key](../people-and-groups/api-keys.md)
|
||||
2. Add `apiKey: "YOUR_API_KEY"` to `defineMetabaseConfig()`:
|
||||
|
||||
```html
|
||||
<script>
|
||||
defineMetabaseConfig({
|
||||
instanceUrl: "https://your-metabase-url",
|
||||
apiKey: "mb_hopeyouhaveaniceday",
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
API keys should only be used for testing with trusted people. Anyone with access to the front-end can grab the API key and use it to make requests against the Metabase API. For this reason, we only allow using API keys on localhost.
|
||||
|
||||
## Embedding Metabase in a different domain
|
||||
|
||||
{% include plans-blockquote.html feature="Authenticated modular embeds" convert_pro_link_to_embbedding=true is_plural=true %}
|
||||
|
||||
If you want to embed Metabase in another domain (say, if Metabase is hosted at `metabase.yourcompany.com`, but you want to embed Metabase at `yourcompany.github.io`), you can tell Metabase to set the session cookie's SameSite value to "none".
|
||||
|
||||
You can set session cookie's SameSite value in **Admin settings** > **Embedding** > **Security** > **SameSite cookie setting**.
|
||||
|
||||
SameSite values include:
|
||||
|
||||
- **Lax** (default): Allows Metabase session cookies to be shared on the same domain. Used for production instances on the same domain.
|
||||
- **None (requires HTTPS)**: Use "None" when your app and Metabase are hosted on different domains. Incompatible with Safari and iOS-based browsers.
|
||||
- **Strict** (not recommended): Does not allow Metabase session cookies to be shared with embedded instances. Use this if you do not want to enable session sharing with embedding.
|
||||
|
||||
You can also set the [`MB_SESSION_COOKIE_SAMESITE` environment variable](../configuring-metabase/environment-variables.md#mb_session_cookie_samesite).
|
||||
|
||||
If you're using Safari, you'll need to [allow cross-site tracking](https://support.apple.com/en-tj/guide/safari/sfri40732/mac). Depending on the browser, you may also run into issues when viewing embedded items in private/incognito tabs.
|
||||
|
||||
Learn more about [SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite).
|
||||
|
||||
## Setting up SSO
|
||||
|
||||
{% include plans-blockquote.html feature="Authenticated modular embeds" convert_pro_link_to_embbedding=true is_plural=true %}
|
||||
|
||||
For production use, you'll need to set up SSO authentication. See [Modular embedding - authentication](./authentication.md) for details on setting up JWT or SAML authentication.
|
||||
For authentication setup including API keys for local testing, SSO with JWT/SAML, and cross-domain configuration, see [Modular embedding - authentication](./authentication.md).
|
||||
|
||||
@@ -162,10 +162,20 @@ To prevent people from saving changes to an interactive question, or from saving
|
||||
|
||||

|
||||
|
||||
### Embed the visual query builder
|
||||
|
||||
You can embed the query builder for creating new questions by passing the `questionId="new"` prop to the `InteractiveQuestion` component. You can use the [`children` prop](#customizing-interactive-questions) to customize the layout for creating new questions.
|
||||
|
||||
```tsx
|
||||
{% include_file "{{ dirname }}/snippets/questions/new-question.tsx" %}
|
||||
```
|
||||
|
||||
### Embed the SQL editor
|
||||
|
||||
You can also embed the SQL editor to enable people to build questions with SQL by passing `questionId="new-native"` to the `InteractiveQuestion` component:
|
||||
|
||||
```tsx
|
||||
{% include_file "{{ dirname }}/snippets/questions/new-native-question.tsx" %}
|
||||
```
|
||||
|
||||
To customize the question editor's layout, use the `InteractiveQuestion` component [directly with a custom `children` prop](#customizing-interactive-questions).
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import {
|
||||
InteractiveQuestion,
|
||||
MetabaseProvider,
|
||||
defineMetabaseAuthConfig,
|
||||
} from "@metabase/embedding-sdk-react";
|
||||
|
||||
const authConfig = defineMetabaseAuthConfig({
|
||||
metabaseInstanceUrl: "https://your-metabase.example.com",
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<MetabaseProvider authConfig={authConfig}>
|
||||
<InteractiveQuestion questionId="new-native" />
|
||||
</MetabaseProvider>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,8 @@ What is embedding, and how does it work?
|
||||
|
||||
Embed individual dashboards, questions, or the query builder in your app with an interactive wizard and simple drop-in script, with minimal or no coding required. Control component UI and theming. Integrate your app's auth with Metabase SSO.
|
||||
|
||||
If you're on Metabase OSS or Starter, you can only embed components without SSO. See [Guest embeds](./guest-embedding.md).
|
||||
|
||||
### [Modular embedding SDK](./sdk/introduction.md)
|
||||
|
||||
With the Modular embedding SDK, you can embed individual Metabase components with React (like standalone charts, dashboards, the query builder, and more). You can manage access and interactivity per component, and you have advanced customization for seamless styling.
|
||||
@@ -24,11 +26,7 @@ Jump to a SDK quickstart with a sample React application.
|
||||
|
||||
### [Guest embedding](./guest-embedding.md)
|
||||
|
||||
Guest embedding is a secure way to embed charts and dashboards. Guest embeds are view only; people won't be able to drill-through charts and tables.
|
||||
|
||||
### [Parameters for guest embeds](./static-embedding-parameters.md)
|
||||
|
||||
You can pass parameters between Metabase and your website via the embedding URL to specify how Metabase items should look and behave inside the iframe on your website.
|
||||
Guest embedding is a secure way to embed charts and dashboards. Guest embeds are view-only; people won't be able to drill-through charts and tables.
|
||||
|
||||
### [Translating guest embeds](./translations.md)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"rules": {
|
||||
"no-unscoped-text-selectors": 2,
|
||||
"metabase/no-unscoped-text-selectors": 2,
|
||||
"import/no-commonjs": 0,
|
||||
"no-color-literals": 0,
|
||||
"metabase/no-color-literals": 0,
|
||||
"no-console": 0,
|
||||
"@typescript-eslint/no-namespace": "off",
|
||||
"cypress/no-assigning-return-values": "error",
|
||||
@@ -49,9 +49,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-direct-helper-import": 2,
|
||||
"no-unsafe-element-filtering": ["warn"],
|
||||
"no-unordered-test-helpers": 2
|
||||
"metabase/no-direct-helper-import": 2,
|
||||
"metabase/no-unsafe-element-filtering": ["warn"],
|
||||
"metabase/no-unordered-test-helpers": 2
|
||||
},
|
||||
"env": {
|
||||
"cypress/globals": true,
|
||||
|
||||
@@ -17,6 +17,9 @@ const options = {
|
||||
GENERATE_SNAPSHOTS: true,
|
||||
JAR_PATH: undefined,
|
||||
...booleanify(process.env),
|
||||
// If this token is present in your env when Cypress runs, it's a mistake.
|
||||
// Cypress needs to start from a clean slate in order to set the token programmatically.
|
||||
MB_PREMIUM_EMBEDDING_TOKEN: undefined,
|
||||
};
|
||||
|
||||
process.env = unBooleanify(options);
|
||||
@@ -135,9 +138,11 @@ const cleanup = async (exitCode: string | number = SUCCESS_EXIT_CODE) => {
|
||||
"🧹 Containers are running in background. If you wish to stop them, run:\n`docker compose -f ./e2e/test/scenarios/docker-compose.yml down`",
|
||||
);
|
||||
|
||||
typeof exitCode === "number"
|
||||
? process.exit(exitCode)
|
||||
: process.exit(SUCCESS_EXIT_CODE);
|
||||
if (typeof exitCode === "number") {
|
||||
process.exit(exitCode);
|
||||
} else {
|
||||
process.exit(SUCCESS_EXIT_CODE);
|
||||
}
|
||||
};
|
||||
|
||||
init()
|
||||
|
||||
@@ -125,22 +125,22 @@ describe("snapshots", () => {
|
||||
// groups
|
||||
cy.request("POST", "/api/permissions/group", { name: "collection" }).then(
|
||||
({ body }) => {
|
||||
expect(body.id).to.eq(COLLECTION_GROUP); // 4
|
||||
expect(body.id).to.eq(COLLECTION_GROUP); // 5
|
||||
},
|
||||
);
|
||||
cy.request("POST", "/api/permissions/group", { name: "data" }).then(
|
||||
({ body }) => {
|
||||
expect(body.id).to.eq(DATA_GROUP); // 5
|
||||
expect(body.id).to.eq(DATA_GROUP); // 6
|
||||
},
|
||||
);
|
||||
cy.request("POST", "/api/permissions/group", { name: "readonly" }).then(
|
||||
({ body }) => {
|
||||
expect(body.id).to.eq(READONLY_GROUP); // 6
|
||||
expect(body.id).to.eq(READONLY_GROUP); // 7
|
||||
},
|
||||
);
|
||||
cy.request("POST", "/api/permissions/group", { name: "nosql" }).then(
|
||||
({ body }) => {
|
||||
expect(body.id).to.eq(NOSQL_GROUP); // 7
|
||||
expect(body.id).to.eq(NOSQL_GROUP); // 8
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "cypress-iframe";
|
||||
|
||||
// this is the only place we allow direct helper import
|
||||
// eslint-disable-next-line no-direct-helper-import
|
||||
// eslint-disable-next-line metabase/no-direct-helper-import
|
||||
import { H } from "e2e/support";
|
||||
|
||||
import "./commands/ui/button";
|
||||
|
||||
@@ -23,7 +23,7 @@ export const loginCache: Partial<
|
||||
> = {};
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- dynamic require for optional file
|
||||
const sampleDataFile = require("e2e/support/cypress_sample_instance_data.json");
|
||||
const { loginCache: LOGIN_CACHE } = sampleDataFile;
|
||||
|
||||
|
||||
@@ -46,6 +46,67 @@ const assetsResolverPlugin = {
|
||||
},
|
||||
};
|
||||
|
||||
// Plugin to allow loading the ClojureScript bundle only when it exists
|
||||
// on the filesystem. This is to avoid breaking the build when the bundle
|
||||
// is not present.
|
||||
//
|
||||
// If the bundle is not present, tests relying on it will fail, but the Cypress
|
||||
// process will be allowed to start.
|
||||
//
|
||||
// The error will look like this:
|
||||
//
|
||||
// Dynamic require of "cljs/metabase.types.core" is not supported
|
||||
//
|
||||
// This allows us to avoid building the ClojureScript bundle when we don't need to (like in certain CI tasks).
|
||||
const virtualClojureScriptPlugin = {
|
||||
name: "virtualClojureScriptBundle",
|
||||
setup(build) {
|
||||
let cljsDir = null;
|
||||
|
||||
async function isDirectory(path) {
|
||||
try {
|
||||
const stat = await fs.promises.stat(path);
|
||||
return stat.isDirectory();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
build.onStart(async () => {
|
||||
const baseDir = path.join(__dirname, "../../target");
|
||||
const devDir = path.join(baseDir, "cljs_dev");
|
||||
const prodDir = path.join(baseDir, "cljs_prod");
|
||||
|
||||
const [devDirExists, prodDirExists] = await Promise.all([
|
||||
isDirectory(devDir),
|
||||
isDirectory(prodDir),
|
||||
]);
|
||||
|
||||
if (prodDirExists) {
|
||||
console.log("ClojureScript production bundle found");
|
||||
cljsDir = prodDir;
|
||||
} else if (devDirExists) {
|
||||
console.log("ClojureScript development bundle found");
|
||||
cljsDir = devDir;
|
||||
} else {
|
||||
console.log("No ClojureScript bundle found, not loading bundle");
|
||||
cljsDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
build.onResolve({ filter: /^cljs\// }, async (args) => {
|
||||
if (cljsDir === null) {
|
||||
return {
|
||||
path: args.path,
|
||||
external: true,
|
||||
};
|
||||
}
|
||||
const unnested = args.path.split(path.sep).slice(1).join(path.sep);
|
||||
return { path: path.join(cljsDir, unnested + ".js") };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const defaultConfig = {
|
||||
// This is the functionality of the old cypress-plugins.js file
|
||||
setupNodeEvents(cypressOn, config) {
|
||||
@@ -75,7 +136,11 @@ const defaultConfig = {
|
||||
loader: {
|
||||
".svg": "text",
|
||||
},
|
||||
plugins: [NodeModulesPolyfillPlugin(), assetsResolverPlugin],
|
||||
plugins: [
|
||||
NodeModulesPolyfillPlugin(),
|
||||
assetsResolverPlugin,
|
||||
virtualClojureScriptPlugin,
|
||||
],
|
||||
sourcemap: "inline",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -43,14 +43,15 @@ export const SAMPLE_DB_TABLES = {
|
||||
export const USER_GROUPS = {
|
||||
ALL_USERS_GROUP: 1,
|
||||
ADMIN_GROUP: 2,
|
||||
COLLECTION_GROUP: 4,
|
||||
DATA_GROUP: 5,
|
||||
READONLY_GROUP: 6,
|
||||
NOSQL_GROUP: 7,
|
||||
COLLECTION_GROUP: 5,
|
||||
DATA_GROUP: 6,
|
||||
READONLY_GROUP: 7,
|
||||
NOSQL_GROUP: 8,
|
||||
};
|
||||
|
||||
export const TENANT_USER_GROUPS = {
|
||||
export const MAGIC_USER_GROUPS = {
|
||||
EXTERNAL_USERS_GROUP: 3,
|
||||
DATA_ANALYSTS_GROUP: 4,
|
||||
};
|
||||
|
||||
const {
|
||||
|
||||
@@ -156,6 +156,10 @@ export const ALL_EXTERNAL_USERS_GROUP_ID = _.findWhere(
|
||||
},
|
||||
)?.id;
|
||||
|
||||
export const DATA_ANALYSTS_GROUP_ID = _.findWhere(SAMPLE_INSTANCE_DATA.groups, {
|
||||
magic_group_type: "data-analyst",
|
||||
})?.id;
|
||||
|
||||
export const COLLECTION_GROUP_ID = _.findWhere(SAMPLE_INSTANCE_DATA.groups, {
|
||||
name: "collection",
|
||||
}).id;
|
||||
|
||||
@@ -87,8 +87,6 @@ export async function resetWritableDb({ type = "postgres" }) {
|
||||
|
||||
export async function resetTable({ type = "postgres", table = "testTable1" }) {
|
||||
const dbClient = getDbClient(WRITABLE_DB_CONFIG[type]);
|
||||
|
||||
// eslint-disable-next-line import/namespace
|
||||
return testTables?.[table]?.(dbClient);
|
||||
}
|
||||
|
||||
|
||||
3
e2e/support/external/e2e-jwt-sign.js
vendored
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const jwt = require("jsonwebtoken");
|
||||
const process = require("process");
|
||||
|
||||
const jwt = require("jsonwebtoken");
|
||||
|
||||
const payload = process.argv[2];
|
||||
const METABASE_SECRET_KEY = process.argv[3];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// eslint-disable-next-line no-direct-helper-import
|
||||
// eslint-disable-next-line metabase/no-direct-helper-import
|
||||
import { H } from "e2e/support";
|
||||
|
||||
export const tryToCreateApiKeyViaModal = ({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
entityPickerModal,
|
||||
entityPickerModalLevel,
|
||||
entityPickerModalTab,
|
||||
getFullName,
|
||||
navigationSidebar,
|
||||
popover,
|
||||
@@ -44,7 +43,7 @@ export function getPersonalCollectionName(
|
||||
}
|
||||
|
||||
export function openCollectionItemMenu(item: string, index = 0) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
cy.findByTestId("collection-table")
|
||||
.findAllByText(item)
|
||||
.eq(index)
|
||||
@@ -79,11 +78,12 @@ export const openUnpinnedItemMenu = (name: string) => {
|
||||
};
|
||||
|
||||
export const moveOpenedCollectionTo = (newParent: string) => {
|
||||
cy.intercept("GET", "/api/collection/*/items**").as("getCollectionItems");
|
||||
openCollectionMenu();
|
||||
popover().within(() => cy.findByText("Move").click());
|
||||
|
||||
cy.wait(["@getCollectionItems", "@getCollectionItems"]);
|
||||
entityPickerModal().within(() => {
|
||||
cy.findByRole("tab", { name: /Collections/ }).click();
|
||||
cy.findByTestId("nested-item-picker").findByText(newParent).click();
|
||||
cy.button("Move").click();
|
||||
});
|
||||
@@ -94,16 +94,10 @@ export const moveOpenedCollectionTo = (newParent: string) => {
|
||||
export function pickEntity({
|
||||
path,
|
||||
select,
|
||||
tab,
|
||||
}: {
|
||||
path?: string[];
|
||||
path?: (string | RegExp)[];
|
||||
select?: boolean;
|
||||
tab?: string;
|
||||
}) {
|
||||
if (tab) {
|
||||
entityPickerModalTab(tab).click();
|
||||
}
|
||||
|
||||
if (path) {
|
||||
cy.findByTestId("nested-item-picker").within(() => {
|
||||
for (const [index, name] of path.entries()) {
|
||||
|
||||
@@ -36,12 +36,12 @@ export function getDashboardCards() {
|
||||
}
|
||||
|
||||
export function getDashboardCard(index = 0) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
return getDashboardCards().eq(index);
|
||||
}
|
||||
|
||||
export function ensureDashboardCardHasText(text: string, index = 0) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
cy.findAllByTestId("dashcard").eq(index).should("contain", text);
|
||||
}
|
||||
|
||||
@@ -370,6 +370,10 @@ export const dashboardGrid = () => {
|
||||
return cy.findByTestId("dashboard-grid");
|
||||
};
|
||||
|
||||
export function dashboardCancelButton() {
|
||||
return cy.findByTestId("edit-bar").findByRole("button", { name: "Cancel" });
|
||||
}
|
||||
|
||||
export function dashboardSaveButton() {
|
||||
return cy.findByTestId("edit-bar").findByRole("button", { name: "Save" });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { MeasureId, SegmentId, TableId } from "metabase-types/api";
|
||||
|
||||
import { codeMirrorHelpers } from "./e2e-codemirror-helpers";
|
||||
import { popover } from "./e2e-ui-elements-helpers";
|
||||
const { H } = cy;
|
||||
|
||||
const libraryPage = () => cy.findByTestId("library-page");
|
||||
const newSnippetPage = () => cy.findByTestId("new-snippet-page");
|
||||
@@ -22,6 +23,11 @@ export const DataStudio = {
|
||||
settingsTab: () => DataStudio.Transforms.header().findByText("Settings"),
|
||||
dependenciesTab: () =>
|
||||
DataStudio.Transforms.header().findByText("Dependencies"),
|
||||
visit: () => {
|
||||
cy.visit("/data-studio/transforms");
|
||||
DataStudio.Transforms.list().should("be.visible");
|
||||
},
|
||||
pythonResults: () => cy.findByTestId("python-results"),
|
||||
},
|
||||
Jobs: {
|
||||
header: () => cy.findByTestId("jobs-header"),
|
||||
@@ -38,11 +44,12 @@ export const DataStudio = {
|
||||
},
|
||||
PythonLibrary: {
|
||||
header: () => cy.findByTestId("python-library-header"),
|
||||
editor: () => cy.findByTestId("python-editor"),
|
||||
},
|
||||
Snippets: {
|
||||
newPage: newSnippetPage,
|
||||
editPage: editSnippetPage,
|
||||
nameInput: () => newSnippetPage().findByPlaceholderText("New SQL snippet"),
|
||||
nameInput: () => newSnippetPage().findByDisplayValue("New SQL snippet"),
|
||||
descriptionInput: () => cy.findByPlaceholderText("No description"),
|
||||
saveButton: () => cy.findByRole("button", { name: "Save" }),
|
||||
cancelButton: () => cy.findByRole("button", { name: "Cancel" }),
|
||||
@@ -111,6 +118,10 @@ export const DataStudio = {
|
||||
.findByTestId("table-description-section")
|
||||
.findByPlaceholderText("No description"),
|
||||
},
|
||||
openFilterPopover: () => {
|
||||
cy.findByRole("button", { name: "Filter" }).click();
|
||||
H.popover();
|
||||
},
|
||||
},
|
||||
Library: {
|
||||
visit: () => {
|
||||
@@ -131,21 +142,4 @@ export const DataStudio = {
|
||||
collectionItem: (name: string) =>
|
||||
libraryPage().findAllByTestId("collection-name").contains(name),
|
||||
},
|
||||
Tasks: {
|
||||
visitUnreferencedEntities: () =>
|
||||
cy.visit("/data-studio/tasks/unreferenced"),
|
||||
list: () => cy.findByTestId("dependency-list"),
|
||||
searchInput: () => cy.findByTestId("dependency-list-search-input"),
|
||||
filterButton: () => cy.findByTestId("dependency-list-filter-button"),
|
||||
sidebar: () => cy.findByTestId("dependency-list-sidebar"),
|
||||
|
||||
Sidebar: {
|
||||
get: () => cy.findByTestId("dependency-list-sidebar"),
|
||||
header: () => cy.findByTestId("dependency-list-sidebar-header"),
|
||||
locationInfo: () => cy.findByRole("region", { name: "Location" }),
|
||||
errorInfo: (label: string) => cy.findByRole("region", { name: label }),
|
||||
creationInfo: () =>
|
||||
cy.findByRole("region", { name: "Creator and last editor" }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
TableId,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { popover } from "./e2e-ui-elements-helpers";
|
||||
|
||||
export const DataModel = {
|
||||
visit,
|
||||
visitDataStudio,
|
||||
@@ -15,14 +17,19 @@ export const DataModel = {
|
||||
get: getTablePicker,
|
||||
getDatabase: getTablePickerDatabase,
|
||||
getDatabaseToggle: getTablePickerDatabaseToggle,
|
||||
getDatabaseCheckbox,
|
||||
getDatabases: getTablePickerDatabases,
|
||||
getSchemas: getTablePickerSchemas,
|
||||
getSchema: getTablePickerSchema,
|
||||
getSchemaToggle: getTablePickerSchemaToggle,
|
||||
getSchemaCheckbox,
|
||||
getTables: getTablePickerTables,
|
||||
getTable: getTablePickerTable,
|
||||
getSearchInput: getTablePickerSearchInput,
|
||||
getFilterForm: getTablePickerFilter,
|
||||
openFilterPopover,
|
||||
selectFilterOption,
|
||||
applyFilters,
|
||||
},
|
||||
TableSection: {
|
||||
get: getTableSection,
|
||||
@@ -664,3 +671,26 @@ function getMeasureEditorDependenciesTab() {
|
||||
function getMeasureRevisionHistory() {
|
||||
return cy.findByTestId("measure-revision-history-page");
|
||||
}
|
||||
|
||||
export function openFilterPopover() {
|
||||
cy.findByRole("button", { name: "Filter" }).click();
|
||||
popover();
|
||||
}
|
||||
|
||||
export function selectFilterOption(fieldLabel: string, optionLabel: string) {
|
||||
cy.findByRole("textbox", { name: fieldLabel }).click();
|
||||
popover().contains(optionLabel).click();
|
||||
}
|
||||
|
||||
export function applyFilters() {
|
||||
cy.findByRole("button", { name: "Apply" }).click();
|
||||
cy.wait("@listTables");
|
||||
}
|
||||
|
||||
export function getDatabaseCheckbox(databaseName: string) {
|
||||
return getTablePickerDatabase(databaseName).find('input[type="checkbox"]');
|
||||
}
|
||||
|
||||
export function getSchemaCheckbox(schemaName: string) {
|
||||
return getTablePickerSchema(schemaName).find('input[type="checkbox"]');
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export const DependencyGraph = {
|
||||
graph: () => cy.findByTestId("dependency-graph"),
|
||||
entryButton: () => cy.findByTestId("graph-entry-button"),
|
||||
entrySearchInput: () => cy.findByTestId("graph-entry-search-input"),
|
||||
selectionButton: () => cy.findByTestId("graph-selection-button"),
|
||||
dependencyPanel: () => cy.findByTestId("graph-dependency-panel"),
|
||||
};
|
||||