Compare commits
208 Commits
v2-theme-o
...
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 |
@@ -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
|
||||
@@ -776,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
|
||||
@@ -809,6 +826,7 @@
|
||||
lib-be
|
||||
models
|
||||
permissions
|
||||
remote-sync
|
||||
search
|
||||
util}}
|
||||
|
||||
@@ -862,6 +880,7 @@
|
||||
models
|
||||
permissions
|
||||
premium-features
|
||||
remote-sync
|
||||
util}}
|
||||
|
||||
notification
|
||||
@@ -879,6 +898,7 @@
|
||||
config
|
||||
dashboards
|
||||
driver
|
||||
embedding
|
||||
events
|
||||
models
|
||||
parameters
|
||||
@@ -969,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
|
||||
@@ -1200,7 +1221,8 @@
|
||||
:api #{metabase.request.core
|
||||
metabase.request.init
|
||||
metabase.request.schema}
|
||||
:uses #{api
|
||||
:uses #{analytics
|
||||
api
|
||||
config
|
||||
embedding
|
||||
permissions ; FIXME
|
||||
@@ -1456,6 +1478,7 @@
|
||||
models
|
||||
permissions
|
||||
premium-features
|
||||
query-processor
|
||||
request
|
||||
task
|
||||
util}}
|
||||
@@ -1499,7 +1522,6 @@
|
||||
:uses #{analytics
|
||||
api
|
||||
collections
|
||||
config
|
||||
events
|
||||
models
|
||||
util}}
|
||||
@@ -1649,7 +1671,6 @@
|
||||
:uses #{analytics
|
||||
api
|
||||
app-db
|
||||
config
|
||||
database-routing
|
||||
driver
|
||||
events
|
||||
@@ -1777,6 +1798,7 @@
|
||||
metabase-enterprise.advanced-permissions.common
|
||||
metabase-enterprise.advanced-permissions.models.permissions.group-manager}
|
||||
:uses #{api
|
||||
audit-app
|
||||
lib
|
||||
permissions
|
||||
premium-features
|
||||
@@ -1787,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}
|
||||
@@ -2079,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"
|
||||
@@ -2247,6 +2281,7 @@
|
||||
native-query-snippets
|
||||
permissions
|
||||
premium-features
|
||||
remote-sync
|
||||
util}}
|
||||
|
||||
enterprise/sso
|
||||
@@ -2329,9 +2364,11 @@
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
permissions
|
||||
premium-features
|
||||
queries
|
||||
query-processor
|
||||
remote-sync
|
||||
request
|
||||
revisions
|
||||
search
|
||||
@@ -2350,8 +2387,11 @@
|
||||
app-db
|
||||
config
|
||||
driver
|
||||
events
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
permissions
|
||||
query-processor
|
||||
settings
|
||||
util
|
||||
|
||||
@@ -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
|
||||
|
||||
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 }}
|
||||
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
|
||||
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
|
||||
|
||||
|
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 |
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.
|
||||
|
||||
78
bb.edn
@@ -139,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]
|
||||
@@ -209,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))}
|
||||
|
||||
@@ -307,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
|
||||
|
||||
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)
|
||||
|
||||
1958
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).
|
||||
|
||||
@@ -15,8 +15,12 @@ To restrict data in guest embeds for specific people or groups, use [locked para
|
||||
|
||||
## 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
|
||||
|
||||
@@ -240,14 +244,14 @@ The end user won't see the "category" filter, but the dashboard will only show d
|
||||
|
||||
## 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
|
||||
|
||||

|
||||
@@ -258,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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,6 +44,7 @@ export const DataStudio = {
|
||||
},
|
||||
PythonLibrary: {
|
||||
header: () => cy.findByTestId("python-library-header"),
|
||||
editor: () => cy.findByTestId("python-editor"),
|
||||
},
|
||||
Snippets: {
|
||||
newPage: newSnippetPage,
|
||||
@@ -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,26 +142,4 @@ export const DataStudio = {
|
||||
collectionItem: (name: string) =>
|
||||
libraryPage().findAllByTestId("collection-name").contains(name),
|
||||
},
|
||||
Tasks: {
|
||||
visitBrokenEntities: () => cy.visit("/data-studio/tasks/broken"),
|
||||
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" }),
|
||||
transformInfo: () => cy.findByRole("region", { name: "Transform" }),
|
||||
missingColumnsInfo: () =>
|
||||
cy.findByRole("region", { name: "Missing columns" }),
|
||||
creationInfo: () =>
|
||||
cy.findByRole("region", { name: "Creator and last editor" }),
|
||||
brokenDependentsInfo: () =>
|
||||
cy.findByRole("region", { name: "Broken dependents" }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
60
e2e/support/helpers/e2e-dependency-helpers.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
DependencyNode,
|
||||
ListBreakingGraphNodesResponse,
|
||||
} from "metabase-types/api";
|
||||
|
||||
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"),
|
||||
};
|
||||
|
||||
export const DependencyDiagnostics = {
|
||||
visitBrokenDependencies: () =>
|
||||
cy.visit("/data-studio/dependency-diagnostics/broken"),
|
||||
visitUnreferencedEntities: () =>
|
||||
cy.visit("/data-studio/dependency-diagnostics/unreferenced"),
|
||||
list: () => cy.findByTestId("dependency-list"),
|
||||
searchInput: () => cy.findByTestId("dependency-list-search-input"),
|
||||
filterButton: () => cy.findByTestId("dependency-filter-button"),
|
||||
sidebar: () => cy.findByTestId("dependency-list-sidebar"),
|
||||
|
||||
Sidebar: {
|
||||
get: () => cy.findByTestId("dependency-list-sidebar"),
|
||||
header: () => cy.findByTestId("dependency-list-sidebar-header"),
|
||||
locationSection: () => cy.findByRole("region", { name: "Location" }),
|
||||
infoSection: () => cy.findByRole("region", { name: "Info" }),
|
||||
errorsSection: (name: string) => cy.findByRole("region", { name }),
|
||||
missingColumnsSection: () =>
|
||||
cy.findByRole("region", { name: "Missing columns" }),
|
||||
fieldsSection: () => cy.findByRole("region", { name: "Fields" }),
|
||||
brokenDependentsSection: () =>
|
||||
cy.findByRole("region", { name: "Broken dependents" }),
|
||||
},
|
||||
};
|
||||
|
||||
const WAIT_TIMEOUT = 10000;
|
||||
const WAIT_INTERVAL = 100;
|
||||
|
||||
export function waitForBreakingDependencies(
|
||||
filter: (nodes: DependencyNode[]) => boolean,
|
||||
timeout = WAIT_TIMEOUT,
|
||||
): Cypress.Chainable {
|
||||
return cy
|
||||
.request<ListBreakingGraphNodesResponse>(
|
||||
"GET",
|
||||
"/api/ee/dependencies/graph/breaking",
|
||||
)
|
||||
.then((response) => {
|
||||
if (filter(response.body.data)) {
|
||||
return cy.wrap(response);
|
||||
} else if (timeout > 0) {
|
||||
cy.wait(WAIT_INTERVAL);
|
||||
return waitForBreakingDependencies(filter, timeout - WAIT_INTERVAL);
|
||||
} else {
|
||||
throw new Error("Dependency analysis retry timeout");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export function getDimensions(isSelected) {
|
||||
}
|
||||
|
||||
export function getDimensionByName({ name, index = 0, isSelected }) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
return getDimensions(isSelected).filter(`:contains("${name}")`).eq(index);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
entityPickerModal,
|
||||
modal,
|
||||
popover,
|
||||
} from "e2e/support/helpers/e2e-ui-elements-helpers";
|
||||
import { modal, popover } from "e2e/support/helpers/e2e-ui-elements-helpers";
|
||||
|
||||
import { pickEntity } from "./e2e-collection-helpers";
|
||||
|
||||
export function saveInteractiveQuestionAsNewQuestion(options: {
|
||||
questionName: string;
|
||||
@@ -13,7 +11,7 @@ export function saveInteractiveQuestionAsNewQuestion(options: {
|
||||
|
||||
cy.intercept("POST", "/api/card").as("createCard");
|
||||
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
cy.findAllByTestId("cell-data").last().click();
|
||||
popover().findByText(`See these ${entityName}`).click();
|
||||
cy.findByRole("button", { name: "Save" }).click();
|
||||
@@ -29,15 +27,7 @@ export function saveInteractiveQuestionAsNewQuestion(options: {
|
||||
if (collectionPickerPath) {
|
||||
cy.findByTestId("dashboard-and-collection-picker-button").click();
|
||||
|
||||
entityPickerModal().within(() => {
|
||||
cy.findByText("Browse").click();
|
||||
|
||||
collectionPickerPath.forEach((collectionName) =>
|
||||
cy.findByText(collectionName).click(),
|
||||
);
|
||||
|
||||
cy.findByRole("button", { name: "Select this collection" }).click();
|
||||
});
|
||||
pickEntity({ path: collectionPickerPath, select: true });
|
||||
}
|
||||
|
||||
modal().within(() => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
entityPickerModal,
|
||||
modal,
|
||||
popover,
|
||||
} from "e2e/support/helpers/e2e-ui-elements-helpers";
|
||||
import { modal, popover } from "e2e/support/helpers/e2e-ui-elements-helpers";
|
||||
|
||||
import { pickEntity } from "./e2e-collection-helpers";
|
||||
|
||||
export function setDropdownFilterType() {
|
||||
cy.findByText("Dropdown list").click();
|
||||
@@ -20,9 +18,7 @@ export function setFilterQuestionSource({ question, field }) {
|
||||
cy.findByText("Pick a model or question…").click();
|
||||
});
|
||||
|
||||
entityPickerModal().within(() => {
|
||||
cy.findByText(question).click();
|
||||
});
|
||||
pickEntity({ path: [/Our analytics/, question], select: true });
|
||||
|
||||
modal().within(() => {
|
||||
cy.findByText("Pick a column…").click();
|
||||
|
||||
@@ -73,7 +73,7 @@ export function chatMessages() {
|
||||
}
|
||||
|
||||
export function lastChatMessage() {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
return chatMessages().last();
|
||||
}
|
||||
|
||||
|
||||
@@ -359,7 +359,7 @@ export function checkSavedToCollectionQuestionToast(addToDashboard) {
|
||||
|
||||
export function saveQuestionToCollection(
|
||||
name,
|
||||
pickEntityOptions = { tab: "Browse", path: ["Our analytics"] },
|
||||
pickEntityOptions = { path: ["Our analytics"] },
|
||||
reqInfo,
|
||||
) {
|
||||
saveQuestion(name, reqInfo, pickEntityOptions);
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { CyHttpMessages } from "cypress/types/net-stubbing";
|
||||
|
||||
import {
|
||||
entityPickerModal,
|
||||
entityPickerModalTab,
|
||||
miniPicker,
|
||||
miniPickerBrowseAll,
|
||||
popover,
|
||||
@@ -84,7 +83,7 @@ export function addSummaryField({
|
||||
stage?: number;
|
||||
index?: number;
|
||||
}) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
getNotebookStep("summarize", { stage, index })
|
||||
.findByTestId("aggregate-step")
|
||||
.findAllByTestId("notebook-cell-item")
|
||||
@@ -118,7 +117,7 @@ export function addSummaryGroupingField({
|
||||
index?: number;
|
||||
bucketSize?: string;
|
||||
}) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
getNotebookStep("summarize", { stage, index })
|
||||
.findByTestId("breakout-step")
|
||||
.findAllByTestId("notebook-cell-item")
|
||||
@@ -142,7 +141,7 @@ export function addSummaryGroupingField({
|
||||
});
|
||||
|
||||
if (bucketSize) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
popover().last().findByText(bucketSize).click();
|
||||
}
|
||||
}
|
||||
@@ -206,7 +205,10 @@ export function selectSavedQuestionsToJoin(
|
||||
) {
|
||||
cy.intercept("GET", "/api/table/*/query_metadata").as("joinedTableMetadata");
|
||||
miniPickerBrowseAll().click();
|
||||
entityPickerModal().findByText(firstQuestionName).click();
|
||||
entityPickerModal().within(() => {
|
||||
cy.findByText("Our analytics").click();
|
||||
cy.findByText(firstQuestionName).click();
|
||||
});
|
||||
|
||||
cy.wait("@joinedTableMetadata");
|
||||
|
||||
@@ -214,8 +216,10 @@ export function selectSavedQuestionsToJoin(
|
||||
cy.icon("join_left_outer").click();
|
||||
|
||||
miniPickerBrowseAll().click();
|
||||
entityPickerModalTab("Data").click();
|
||||
entityPickerModal().findByText(secondQuestionName).click();
|
||||
entityPickerModal().within(() => {
|
||||
cy.findByText("Our analytics").click();
|
||||
cy.findByText(secondQuestionName).click();
|
||||
});
|
||||
}
|
||||
|
||||
export function selectFilterOperator(operatorName: string) {
|
||||
@@ -339,7 +343,7 @@ function verifyNotebookExpressions(
|
||||
);
|
||||
|
||||
for (let index = 0; index < expressions.length; ++index) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
getExpressionItems(stageIndex)
|
||||
.eq(index)
|
||||
.should("have.text", expressions[index]);
|
||||
@@ -360,7 +364,7 @@ function verifyNotebookFilters(
|
||||
);
|
||||
|
||||
for (let index = 0; index < filters.length; ++index) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
getFilterItems(stageIndex).eq(index).should("have.text", filters[index]);
|
||||
}
|
||||
} else {
|
||||
@@ -381,7 +385,7 @@ function verifyNotebookAggregations(
|
||||
);
|
||||
|
||||
for (let index = 0; index < aggregations.length; ++index) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
getSummarizeItems(stageIndex, "aggregate")
|
||||
.eq(index)
|
||||
.should("have.text", aggregations[index]);
|
||||
@@ -410,7 +414,7 @@ function verifyNotebookBreakouts(
|
||||
);
|
||||
|
||||
for (let index = 0; index < breakouts.length; ++index) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
getSummarizeItems(stageIndex, "breakout")
|
||||
.eq(index)
|
||||
.should("have.text", breakouts[index]);
|
||||
@@ -444,9 +448,9 @@ function verifyNotebookSort(
|
||||
|
||||
for (let index = 0; index < sort.length; ++index) {
|
||||
const { column, order } = sort[index];
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
getSortItems(stageIndex).eq(index).should("have.text", column);
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
getSortItems(stageIndex)
|
||||
.eq(index)
|
||||
.icon(order === "asc" ? "arrow_up" : "arrow_down")
|
||||
|
||||
@@ -33,12 +33,14 @@ export function modifyPermission(
|
||||
}
|
||||
});
|
||||
}
|
||||
value && cy.findByText(value).click();
|
||||
if (value) {
|
||||
cy.findByText(value).click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function selectPermissionRow(item, permissionIndex) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
getPermissionRowPermissions(item).eq(permissionIndex).click();
|
||||
}
|
||||
|
||||
@@ -80,19 +82,20 @@ export function assertPermissionForItem(
|
||||
permissionColumnIndex,
|
||||
permissionValue,
|
||||
) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
getPermissionRowPermissions(item)
|
||||
.eq(permissionColumnIndex)
|
||||
.should("have.text", permissionValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} index
|
||||
* @param {string} row
|
||||
* @param {number} index
|
||||
* @param {string} permission
|
||||
* @param {boolean} isDisabled
|
||||
*/
|
||||
export function isPermissionDisabled(row, index, permission, isDisabled) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
return getPermissionRowPermissions(row)
|
||||
.eq(index)
|
||||
.should("have.attr", "aria-disabled", isDisabled.toString())
|
||||
|
||||
@@ -45,15 +45,49 @@ export const commitToRepo = (
|
||||
export function configureGit(
|
||||
syncType: "read-write" | "read-only",
|
||||
syncUrl = LOCAL_GIT_PATH + "/.git",
|
||||
collections?: Record<number, boolean>,
|
||||
) {
|
||||
cy.request("PUT", "/api/ee/remote-sync/settings", {
|
||||
"remote-sync-branch": "main",
|
||||
"remote-sync-type": syncType,
|
||||
"remote-sync-url": syncUrl,
|
||||
"remote-sync-enabled": true,
|
||||
...(collections && { collections }),
|
||||
});
|
||||
}
|
||||
|
||||
// Setup remote sync via the API and wait for/trigger the initial import
|
||||
export function configureGitAndPullChanges(
|
||||
syncType: "read-write" | "read-only",
|
||||
syncUrl = LOCAL_GIT_PATH + "/.git",
|
||||
) {
|
||||
configureGit(syncType, syncUrl);
|
||||
|
||||
if (syncType === "read-only") {
|
||||
// Read-only mode automatically triggers an import, just wait for it
|
||||
pollForTask({ taskName: "import" });
|
||||
} else {
|
||||
// Read-write mode needs manual import trigger
|
||||
cy.request("POST", "/api/ee/remote-sync/import", {});
|
||||
pollForTask({ taskName: "import" });
|
||||
}
|
||||
}
|
||||
|
||||
// Setup remote sync with a new synced collection in one step
|
||||
export function configureGitWithNewSyncedCollection(
|
||||
syncType: "read-write" | "read-only",
|
||||
collectionName = "Test Synced Collection",
|
||||
syncUrl = LOCAL_GIT_PATH + "/.git",
|
||||
) {
|
||||
return cy
|
||||
.request("POST", "/api/collection", { name: collectionName })
|
||||
.then((response) => {
|
||||
const collection = response.body;
|
||||
configureGit(syncType, syncUrl, { [collection.id]: true });
|
||||
return cy.wrap(collection);
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare the local git repo and initializing with an empty commit
|
||||
export function setupGitSync() {
|
||||
cy.exec("rm -rf " + LOCAL_GIT_PATH);
|
||||
@@ -136,7 +170,10 @@ export const updateRemoteQuestion = (
|
||||
});
|
||||
};
|
||||
|
||||
export const moveCollectionItemToSyncedCollection = (name: string) => {
|
||||
export const moveCollectionItemToSyncedCollection = (
|
||||
name: string,
|
||||
targetCollection = "Synced Collection",
|
||||
) => {
|
||||
navigationSidebar()
|
||||
.findByRole("treeitem", { name: /Our analytics/ })
|
||||
.click();
|
||||
@@ -145,22 +182,24 @@ export const moveCollectionItemToSyncedCollection = (name: string) => {
|
||||
popover().findByText("Move").click();
|
||||
|
||||
entityPickerModal().within(() => {
|
||||
cy.findAllByRole("tab", { name: /Browse|Collections/ }).click();
|
||||
entityPickerModalItem(1, "Synced Collection").click();
|
||||
entityPickerModalItem(1, targetCollection).click();
|
||||
cy.button("Move").click();
|
||||
});
|
||||
|
||||
getSyncStatusIndicators().should("have.length", 1);
|
||||
|
||||
navigationSidebar()
|
||||
.findByRole("treeitem", { name: /Synced Collection/ })
|
||||
.findByRole("treeitem", { name: new RegExp(targetCollection) })
|
||||
.click();
|
||||
collectionTable().findByText(name).should("exist");
|
||||
};
|
||||
|
||||
export const goToSyncedCollection = (opts?: Partial<Cypress.ClickOptions>) =>
|
||||
export const goToSyncedCollection = (
|
||||
collectionName = "Synced Collection",
|
||||
opts?: Partial<Cypress.ClickOptions>,
|
||||
) =>
|
||||
navigationSidebar()
|
||||
.findByRole("treeitem", { name: /Synced Collection/ })
|
||||
.findByRole("treeitem", { name: new RegExp(collectionName) })
|
||||
.click(opts);
|
||||
|
||||
// Git sync controls are now in the app bar, not the sidebar
|
||||
@@ -222,3 +261,47 @@ export const waitForTask = (
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Poll for task completion by actively querying the endpoint
|
||||
// Use this when the app isn't loaded yet (e.g., in setup helpers before cy.visit)
|
||||
export const pollForTask = (
|
||||
{ taskName }: { taskName: "import" | "export" },
|
||||
retries = 0,
|
||||
) => {
|
||||
if (retries > 30) {
|
||||
throw Error(`Too many retries waiting for ${taskName}`);
|
||||
}
|
||||
|
||||
return cy
|
||||
.request("GET", "/api/ee/remote-sync/current-task")
|
||||
.then((response) => {
|
||||
const { body } = response;
|
||||
|
||||
// No task exists yet, keep waiting
|
||||
if (!body) {
|
||||
cy.wait(500);
|
||||
return pollForTask({ taskName }, retries + 1);
|
||||
}
|
||||
|
||||
// Wrong task type, keep waiting
|
||||
if (body.sync_task_type !== taskName) {
|
||||
cy.wait(500);
|
||||
return pollForTask({ taskName }, retries + 1);
|
||||
}
|
||||
|
||||
// Task hasn't completed successfully yet
|
||||
if (body.status !== "successful") {
|
||||
// Check if it errored
|
||||
if (body.status === "errored") {
|
||||
throw Error(
|
||||
`Task ${taskName} failed: ${body.error_message || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
cy.wait(500);
|
||||
return pollForTask({ taskName }, retries + 1);
|
||||
}
|
||||
|
||||
// Success!
|
||||
return cy.wrap(body);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -74,30 +74,14 @@ export function entityPickerModalLevel(level) {
|
||||
* @param {string | RegExp} name
|
||||
*/
|
||||
export function entityPickerModalItem(level, name) {
|
||||
return entityPickerModalLevel(level).findByText(name).parents("a");
|
||||
}
|
||||
|
||||
export function entityPickerModalTab(name) {
|
||||
return cy.findAllByRole("tab").filter(`:contains(${name})`);
|
||||
}
|
||||
|
||||
// displays at least these tabs:
|
||||
export function shouldDisplayTabs(tabs) {
|
||||
tabs.forEach((tab) => {
|
||||
entityPickerModalTab(tab).should("exist");
|
||||
});
|
||||
}
|
||||
|
||||
export function tabsShouldBe(selected, tabs) {
|
||||
cy.log(tabs);
|
||||
cy.findAllByRole("tab").should("have.length", tabs.length);
|
||||
tabs.forEach((tab) => {
|
||||
if (tab === selected) {
|
||||
entityPickerModalTab(tab).and("have.attr", "aria-selected", "true");
|
||||
} else {
|
||||
entityPickerModalTab(tab).should("exist");
|
||||
}
|
||||
});
|
||||
return (
|
||||
entityPickerModalLevel(level)
|
||||
// in the recents and search results, the items look like: [collection name] [parent collection name]
|
||||
// which makes matching difficult as you may inadvertently match the parent collection name
|
||||
// so we ignore the parent collection name by ignoring data-testid="picker-item-location"
|
||||
.findByText(name, { ignore: '[data-testid="picker-item-location"]' })
|
||||
.parents("a")
|
||||
);
|
||||
}
|
||||
|
||||
export function collectionOnTheGoModal() {
|
||||
@@ -195,12 +179,12 @@ export function filterWidget({ isEditing = false, name = null } = {}) {
|
||||
}
|
||||
|
||||
export function clearFilterWidget(index = 0) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
return filterWidget().eq(index).icon("close").click();
|
||||
}
|
||||
|
||||
export function resetFilterWidgetToDefault(index = 0) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
return filterWidget().eq(index).icon("revert").click();
|
||||
}
|
||||
|
||||
@@ -321,6 +305,8 @@ export const moveDnDKitListElement = (
|
||||
{ startIndex, dropIndex, onBeforeDragEnd = () => {} } = {},
|
||||
) => {
|
||||
const selector = new RegExp(dataTestId);
|
||||
const getElement = () =>
|
||||
cy.findAllByTestId(selector).should("have.length.gt", 1).eq(startIndex);
|
||||
|
||||
const getCenter = ($el) => {
|
||||
const { x, y, width, height } = $el.getBoundingClientRect();
|
||||
@@ -334,9 +320,7 @@ export const moveDnDKitListElement = (
|
||||
const dragPoint = getCenter(dragEl);
|
||||
const dropPoint = getCenter(dropEl);
|
||||
|
||||
cy.wrap(dragEl).as("dragElement");
|
||||
|
||||
moveDnDKitElementByAlias("@dragElement", {
|
||||
moveDnDKitElementByGetter(getElement, {
|
||||
vertical: dropPoint.clientY - dragPoint.clientY,
|
||||
horizontal: dropPoint.clientX - dragPoint.clientX,
|
||||
onBeforeDragEnd,
|
||||
@@ -353,29 +337,45 @@ export const moveDnDKitListElement = (
|
||||
* @param {number} [options.vertical=0] - Vertical distance to move in pixels
|
||||
* @param {Function} [options.onBeforeDragEnd] - Optional callback executed before releasing the drag
|
||||
*/
|
||||
export const moveDnDKitElementByAlias = (
|
||||
alias,
|
||||
{ horizontal = 0, vertical = 0, onBeforeDragEnd = () => {} } = {},
|
||||
) => {
|
||||
export const moveDnDKitElementByAlias = (alias, options) => {
|
||||
// This function queries alias before triggering every event to avoid running into "element was removed from the DOM"
|
||||
// error caused by node remounting https://on.cypress.io/element-has-detached-from-dom
|
||||
cy.get(alias)
|
||||
const getElement = () => cy.get(alias);
|
||||
return moveDnDKitElementByGetter(getElement, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves a dnd-kit draggable element by a specified offset using a function that returns the element.
|
||||
*
|
||||
* @param {() => Cypress.Chainable} getElement - A function that returns the element to drag
|
||||
* @param {Object} options
|
||||
* @param {number} [options.horizontal=0] - Horizontal distance to move in pixels
|
||||
* @param {number} [options.vertical=0] - Vertical distance to move in pixels
|
||||
* @param {Function} [options.onBeforeDragEnd] - Optional callback executed before releasing the drag
|
||||
*/
|
||||
const moveDnDKitElementByGetter = (
|
||||
getElement,
|
||||
{ horizontal = 0, vertical = 0, onBeforeDragEnd = () => {} } = {},
|
||||
) => {
|
||||
getElement()
|
||||
.trigger("pointerdown", 0, 0, {
|
||||
force: true,
|
||||
isPrimary: true,
|
||||
button: 0,
|
||||
})
|
||||
.wait(200);
|
||||
|
||||
// This initial move needs to be greater than the activation constraint
|
||||
// of the pointer sensor
|
||||
cy.get(alias)
|
||||
getElement()
|
||||
.trigger("pointermove", 20, 20, {
|
||||
force: true,
|
||||
isPrimary: true,
|
||||
button: 0,
|
||||
})
|
||||
.wait(200);
|
||||
cy.get(alias)
|
||||
|
||||
getElement()
|
||||
.trigger("pointermove", horizontal, vertical, {
|
||||
force: true,
|
||||
isPrimary: true,
|
||||
@@ -439,7 +439,7 @@ export function tableInteractiveFooter() {
|
||||
}
|
||||
|
||||
export function resizeTableColumn(columnId, moveX, elementIndex = 0) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
cy.findAllByTestId(`resize-handle-${columnId}`)
|
||||
.eq(elementIndex)
|
||||
.trigger("mousedown", {
|
||||
@@ -483,7 +483,7 @@ export function assertTableRowsCount(value) {
|
||||
}
|
||||
|
||||
export function lastTableRow() {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
return tableInteractiveScrollContainer()
|
||||
.scrollTo("bottomLeft")
|
||||
.findAllByRole("row")
|
||||
@@ -547,7 +547,7 @@ export function assertTableData({ columns, firstRows = [] }) {
|
||||
.should("have.length", columns.length);
|
||||
|
||||
columns.forEach((column, index) => {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
tableInteractive()
|
||||
.findAllByTestId("header-cell")
|
||||
.eq(index)
|
||||
@@ -556,7 +556,7 @@ export function assertTableData({ columns, firstRows = [] }) {
|
||||
|
||||
firstRows.forEach((row, rowIndex) => {
|
||||
row.forEach((cell, cellIndex) => {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
tableInteractiveBody()
|
||||
.findAllByTestId("cell-data")
|
||||
.eq(columns.length * rowIndex + cellIndex)
|
||||
@@ -596,12 +596,12 @@ export function fieldValuesTextbox() {
|
||||
}
|
||||
|
||||
export function fieldValuesValue(index) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
return cy.findAllByTestId("token-field").eq(index);
|
||||
}
|
||||
|
||||
export function removeFieldValuesValue(index) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
return cy
|
||||
.findAllByTestId("token-field")
|
||||
.findAllByLabelText("Remove")
|
||||
@@ -610,7 +610,7 @@ export function removeFieldValuesValue(index) {
|
||||
}
|
||||
|
||||
export function multiAutocompleteValue(index, filter = ":eq(0)") {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
return cy
|
||||
.findAllByRole("combobox")
|
||||
.filter(filter)
|
||||
@@ -667,3 +667,35 @@ export function ensureParameterColumnValue({ columnName, columnValue }) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getProfileLink() {
|
||||
return cy.findByTestId("app-switcher-target");
|
||||
}
|
||||
|
||||
export const mainAppLinkText = "Main app";
|
||||
export const dataStudioAppLinkText = "Data studio";
|
||||
export const adminAppLinkText = "Admin";
|
||||
|
||||
export function goToMainApp() {
|
||||
getProfileLink().click();
|
||||
popover().findByText(mainAppLinkText).click();
|
||||
}
|
||||
|
||||
export function goToAdmin() {
|
||||
getProfileLink().click();
|
||||
popover().findByText(adminAppLinkText).click();
|
||||
}
|
||||
|
||||
export function goToDataStudio() {
|
||||
getProfileLink().click();
|
||||
popover().findByText(dataStudioAppLinkText).click();
|
||||
}
|
||||
|
||||
export function goToProfile() {
|
||||
getProfileLink().click();
|
||||
popover().findByTestId("mode-switcher-profile-link").click();
|
||||
}
|
||||
|
||||
export function getHelpSubmenu() {
|
||||
return cy.findByTestId("help-submenu");
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function uploadFile(
|
||||
|
||||
cy.wait(`@${uploadMode}CSV`);
|
||||
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
// eslint-disable-next-line metabase/no-unsafe-element-filtering
|
||||
cy.findAllByRole("status")
|
||||
.last()
|
||||
.findByText(`Data added to ${collectionName}`, {
|
||||
|
||||