Compare commits
121 Commits
ep-fix-pyt
...
python_run
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a43b2d35f | ||
|
|
832f8ac44d | ||
|
|
88bdc6c06c | ||
|
|
bdfc24938c | ||
|
|
123f2dafd2 | ||
|
|
16504fd3c8 | ||
|
|
997b8d898e | ||
|
|
ceb518ab4b | ||
|
|
0ed6c4deed | ||
|
|
6c09fca355 | ||
|
|
049526c61c | ||
|
|
a799178420 | ||
|
|
f7b82d5c54 | ||
|
|
a51d1179d3 | ||
|
|
cbed2e45a8 | ||
|
|
418d7a0317 | ||
|
|
3bce633480 | ||
|
|
48327532c5 | ||
|
|
26f71cfeb5 | ||
|
|
3d4e44b776 | ||
|
|
86f3e524ba | ||
|
|
056d61238e | ||
|
|
926610da14 | ||
|
|
75251f933a | ||
|
|
d959d54aec | ||
|
|
0933b7378f | ||
|
|
e3b200b319 | ||
|
|
fb9c0e4499 | ||
|
|
40522b6cba | ||
|
|
b6edd15de2 | ||
|
|
16042f45c7 | ||
|
|
b28ab0ec1d | ||
|
|
add8906504 | ||
|
|
3345365546 | ||
|
|
862dc50c64 | ||
|
|
0941091421 | ||
|
|
0bd69823a4 | ||
|
|
6a01533ca7 | ||
|
|
7d18a1b5a6 | ||
|
|
0bf7e41984 | ||
|
|
95a25e4a3e | ||
|
|
a2c4eef9de | ||
|
|
2f81937643 | ||
|
|
48bf58b162 | ||
|
|
761a7cf01e | ||
|
|
8a76f71003 | ||
|
|
fd90d13ae3 | ||
|
|
a62aab3f1b | ||
|
|
bafd49ee17 | ||
|
|
1f0b461d6f | ||
|
|
1712274b85 | ||
|
|
ea203102e6 | ||
|
|
3c72591b17 | ||
|
|
9a19a5e4f0 | ||
|
|
f387d4aca4 | ||
|
|
1bb6f6a74a | ||
|
|
2ad4292465 | ||
|
|
37c3758748 | ||
|
|
ae9cad5c58 | ||
|
|
bd14e14974 | ||
|
|
85319217cd | ||
|
|
240acb4782 | ||
|
|
e6bc40c223 | ||
|
|
76fcb4d573 | ||
|
|
a92317dd27 | ||
|
|
087a317f70 | ||
|
|
c63fd746d8 | ||
|
|
f29a446aab | ||
|
|
d26bf4e454 | ||
|
|
7aa18ae448 | ||
|
|
97da3d7c7b | ||
|
|
10e8a68cf5 | ||
|
|
209a34b102 | ||
|
|
a6df84c136 | ||
|
|
adb9a5cbf4 | ||
|
|
d491eb6d51 | ||
|
|
0f20f4d2f0 | ||
|
|
9304e6cb6e | ||
|
|
21f043d697 | ||
|
|
d6bb46208f | ||
|
|
e2f7cc9d86 | ||
|
|
0880e7297d | ||
|
|
4f23bc973f | ||
|
|
1afabc3d01 | ||
|
|
cdf5111a0d | ||
|
|
70cf127afd | ||
|
|
ac421d0a6d | ||
|
|
9f5b23ecdd | ||
|
|
f8551c9800 | ||
|
|
4befb6089f | ||
|
|
c902d22458 | ||
|
|
7451ffcd93 | ||
|
|
410471a4d0 | ||
|
|
a2b3490355 | ||
|
|
3b8e1ecaa4 | ||
|
|
0176c399f9 | ||
|
|
2287567b12 | ||
|
|
d9b8b9a2c0 | ||
|
|
17f473a9f1 | ||
|
|
110cb5bca7 | ||
|
|
1149c3e30c | ||
|
|
735b98d6f8 | ||
|
|
359158e594 | ||
|
|
9a5e8227d0 | ||
|
|
287665a6fa | ||
|
|
82f16a8528 | ||
|
|
45b4b682cc | ||
|
|
7a96e1f142 | ||
|
|
a48f246c50 | ||
|
|
dd00b33545 | ||
|
|
51eab9a902 | ||
|
|
4b47763109 | ||
|
|
d4960babe5 | ||
|
|
3856fd989e | ||
|
|
2ac5ed4fb0 | ||
|
|
1b80d7dc16 | ||
|
|
34266fc636 | ||
|
|
e3ced91a43 | ||
|
|
a37b3fb0ec | ||
|
|
1284c201b5 | ||
|
|
feb0c5b34a |
12
.claude/commands/ci-report.md
Normal file
12
.claude/commands/ci-report.md
Normal file
@@ -0,0 +1,12 @@
|
||||
Generate a CI failure report for PR $PR_NUM_OR_URL (or current branch if no argument given).
|
||||
|
||||
Run `./bin/mage ci-report $PR_NUM_OR_URL` to generate the report, then:
|
||||
|
||||
1. **Summarize the failures** - What tests failed and in which jobs?
|
||||
2. **Categorize** - Are these real failures, flaky tests, or infrastructure issues?
|
||||
3. **Recommend next steps**:
|
||||
- If flaky: suggest rerunning with `gh run rerun <run-id> --failed -R metabase/metabase`
|
||||
- If real: identify the root cause and suggest fixes
|
||||
- If infrastructure: note the issue type (timeout, OOM, network)
|
||||
|
||||
Focus on actionable insights. If all checks are passing, confirm that briefly.
|
||||
@@ -29,7 +29,7 @@ This guide covers Clojure and ClojureScript coding conventions for Metabase. See
|
||||
|
||||
**Comments:**
|
||||
|
||||
- `TODO` format: `;; TODO (Name M/D/YY) -- description`
|
||||
- `TODO` format: `;; TODO (Name YYYY-MM-DD) -- description`
|
||||
|
||||
## Code Organization
|
||||
|
||||
|
||||
504
.claude/skills/analytics-events/SKILL.md
Normal file
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}
|
||||
@@ -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
|
||||
|
||||
@@ -383,7 +383,6 @@
|
||||
:uses #{api
|
||||
app-db
|
||||
collections
|
||||
config
|
||||
eid-translation
|
||||
events
|
||||
lib-be
|
||||
@@ -614,7 +613,6 @@
|
||||
metabase.embedding.validation}
|
||||
:uses #{analytics
|
||||
api
|
||||
config
|
||||
premium-features
|
||||
server
|
||||
settings
|
||||
@@ -810,6 +808,7 @@
|
||||
lib-be
|
||||
models
|
||||
permissions
|
||||
remote-sync
|
||||
search
|
||||
util}}
|
||||
|
||||
@@ -1457,6 +1456,7 @@
|
||||
models
|
||||
permissions
|
||||
premium-features
|
||||
query-processor
|
||||
request
|
||||
task
|
||||
util}}
|
||||
@@ -1500,7 +1500,6 @@
|
||||
:uses #{analytics
|
||||
api
|
||||
collections
|
||||
config
|
||||
events
|
||||
models
|
||||
util}}
|
||||
@@ -1650,7 +1649,6 @@
|
||||
:uses #{analytics
|
||||
api
|
||||
app-db
|
||||
config
|
||||
database-routing
|
||||
driver
|
||||
events
|
||||
@@ -2333,6 +2331,7 @@
|
||||
premium-features
|
||||
queries
|
||||
query-processor
|
||||
remote-sync
|
||||
request
|
||||
revisions
|
||||
search
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
(ns macros.metabase.server.statistics-handler-test)
|
||||
|
||||
(defmacro with-server
|
||||
[[_hold-chan _hit-chan] & body]
|
||||
`(let []
|
||||
~@body))
|
||||
@@ -99,12 +99,48 @@
|
||||
:message "Metabase namespaces should have the form metabase[-enterprise].<module>.* [:metabase/namespace-name]"
|
||||
:type :metabase/namespace-name)))))))
|
||||
|
||||
(defn- ns-form-node->import-node [ns-form-node]
|
||||
(some (fn [node]
|
||||
(when (and (hooks/list-node? node)
|
||||
(let [first-child (first (:children node))]
|
||||
(and (hooks/keyword-node? first-child)
|
||||
(= (hooks/sexpr first-child) :import))))
|
||||
node))
|
||||
(:children ns-form-node)))
|
||||
|
||||
(defn- jsqlparser-import?
|
||||
"Check if a symbol or string represents a jsqlparser import."
|
||||
[s]
|
||||
(str/starts-with? (str s) "net.sf.jsqlparser"))
|
||||
|
||||
(defn- lint-jsqlparser-imports [ns-form-node]
|
||||
(when-let [import-node (ns-form-node->import-node ns-form-node)]
|
||||
(doseq [node (rest (:children import-node))
|
||||
:when (not (contains? (hooks.common/ignored-linters node) :metabase/no-jsqlparser-imports))]
|
||||
(cond
|
||||
;; Handle (net.sf.jsqlparser.foo Bar Baz) form
|
||||
(hooks/list-node? node)
|
||||
(let [package-node (first (:children node))
|
||||
package-name (when package-node (str (hooks/sexpr package-node)))]
|
||||
(when (jsqlparser-import? package-name)
|
||||
(hooks/reg-finding! (assoc (meta node)
|
||||
:message "Don't import net.sf.jsqlparser classes directly. This code should be agnostic to the parsing library. [:metabase/no-jsqlparser-imports]"
|
||||
:type :metabase/no-jsqlparser-imports))))
|
||||
|
||||
;; Handle net.sf.jsqlparser.foo.Bar form (single class import)
|
||||
(hooks/token-node? node)
|
||||
(when (jsqlparser-import? (hooks/sexpr node))
|
||||
(hooks/reg-finding! (assoc (meta node)
|
||||
:message "Don't import net.sf.jsqlparser classes directly. This code should be agnostic to the parsing library. [:metabase/no-jsqlparser-imports]"
|
||||
:type :metabase/no-jsqlparser-imports)))))))
|
||||
|
||||
(defn lint-ns [x]
|
||||
(doto (:node x)
|
||||
lint-require-shapes
|
||||
lint-requires-on-new-lines
|
||||
(lint-modules (modules/config x))
|
||||
lint-namespace-name)
|
||||
lint-namespace-name
|
||||
lint-jsqlparser-imports)
|
||||
x)
|
||||
|
||||
(comment
|
||||
|
||||
1
.github/actions/prepare-frontend/action.yml
vendored
1
.github/actions/prepare-frontend/action.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: Prepare front-end environment
|
||||
description: "Prepare the front-end environment for the CI build"
|
||||
inputs:
|
||||
node-version:
|
||||
description: "The version of Node.js to use, overriding .nvmrc"
|
||||
|
||||
40
.github/scripts/find-linear-issue.js
vendored
Normal file
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 };
|
||||
3
.github/scripts/setup-python-runner.sh
vendored
3
.github/scripts/setup-python-runner.sh
vendored
@@ -19,11 +19,12 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
echo "Cloning python-runner repository..."
|
||||
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
|
||||
git clone https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
|
||||
|
||||
if [ "$SKIP_START" = false ]; then
|
||||
echo "Starting python-runner..."
|
||||
cd python-runner
|
||||
git checkout origin/memory_improvements_2
|
||||
make run-ci 1> ../python-runner.out.log 2> ../python-runner.err.log
|
||||
make logs 1>> ../python-runner.out.log 2>> ../python-runner.err.log &
|
||||
cd ..
|
||||
|
||||
30
.github/workflows/claude-code-bug-fix.yml
vendored
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
33
.github/workflows/clean-cache.yml
vendored
@@ -1,33 +0,0 @@
|
||||
name: Cleanup caches by a branch
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cleanup
|
||||
run: |
|
||||
gh extension install actions/gh-actions-cache
|
||||
|
||||
REPO=${{ github.repository }}
|
||||
BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge"
|
||||
|
||||
echo "Fetching list of cache key"
|
||||
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 )
|
||||
|
||||
## Setting this to not fail the workflow while deleting cache keys.
|
||||
set +e
|
||||
echo "Deleting caches..."
|
||||
for cacheKey in $cacheKeysForPR
|
||||
do
|
||||
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
|
||||
done
|
||||
echo "Done"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
113
.github/workflows/codespell.yml
vendored
113
.github/workflows/codespell.yml
vendored
@@ -1,23 +1,110 @@
|
||||
name: Codespell
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "release-**"
|
||||
pull_request:
|
||||
schedule:
|
||||
# Run nightly at 4:00 AM UTC
|
||||
- cron: "0 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
codespell:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: codespell-project/actions-codespell@v2
|
||||
|
||||
- name: Install codespell
|
||||
run: pip install codespell
|
||||
|
||||
- name: Set up git configuration
|
||||
run: |
|
||||
git config --global user.name "Claude Code[bot]"
|
||||
git config --global user.email "noreply@anthropic.com"
|
||||
|
||||
- name: Get current date
|
||||
id: date
|
||||
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run codespell check
|
||||
id: codespell_check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -o pipefail
|
||||
./bin/mage codespell 2>&1 | tee codespell_output.txt
|
||||
|
||||
- name: Generate GitHub App Token for Claude
|
||||
if: steps.codespell_check.outcome == 'failure'
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: claude-app-token
|
||||
with:
|
||||
# keep this list in sync with the list in `mage/src/mage/codespell.clj`
|
||||
path: >-
|
||||
docs
|
||||
enterprise
|
||||
frontend
|
||||
modules/drivers
|
||||
src
|
||||
app-id: ${{ secrets.METABASE_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.METABASE_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Fix spelling errors with Claude Code
|
||||
if: steps.codespell_check.outcome == 'failure'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ steps.claude-app-token.outputs.token }}
|
||||
prompt: |
|
||||
You are tasked with fixing spelling errors detected by codespell in this repository.
|
||||
|
||||
## Codespell Output
|
||||
The following spelling errors were detected:
|
||||
```
|
||||
$(cat codespell_output.txt)
|
||||
```
|
||||
|
||||
## Instructions
|
||||
1. **Review the codespell output and identify reported spelling errors** - The usual format codespell uses is `typo ==> correction`
|
||||
2. **Fix every spelling error** - Use the Edit tool to fix each misspelling with the suggested correction
|
||||
- If codespell suggests multiple corrections, choose the most appropriate one based on context
|
||||
|
||||
Human reviewers will verify the changes before merging, so fix all errors reported by codespell.
|
||||
claude_args: |
|
||||
--allowedTools "Write,Read,Glob,Grep,Edit,MultiEdit,Bash(cat*),Bash(ls*)"
|
||||
--disallowedTools "Bash(git commit*),Bash(git push*),Bash(gh*)"
|
||||
|
||||
- name: Clean up temporary files
|
||||
if: steps.codespell_check.outcome == 'failure'
|
||||
run: rm -f codespell_output.txt
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.codespell_check.outcome == 'failure'
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: claude-fix/codespell-${{ steps.date.outputs.date }}
|
||||
base: master
|
||||
delete-branch: true
|
||||
commit-message: |
|
||||
Fix spelling errors detected by codespell (${{ steps.date.outputs.date }})
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.ai/code)
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
committer: "Claude Code[bot] <noreply@anthropic.com>"
|
||||
author: "Claude Code[bot] <noreply@anthropic.com>"
|
||||
title: "Fix spelling errors detected by codespell (${{ steps.date.outputs.date }})"
|
||||
body: |
|
||||
This PR automatically fixes spelling errors detected by the scheduled codespell check.
|
||||
|
||||
## Automated Fix
|
||||
🤖 This fix was generated automatically by Claude Code based on codespell output.
|
||||
|
||||
**Please review carefully:**
|
||||
- [ ] The spelling fixes are correct
|
||||
- [ ] No code functionality was changed
|
||||
- [ ] Technical terms were preserved correctly
|
||||
|
||||
---
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.ai/code)
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
labels: |
|
||||
automated-fix
|
||||
backport
|
||||
|
||||
25
.github/workflows/drivers.yml
vendored
25
.github/workflows/drivers.yml
vendored
@@ -85,7 +85,6 @@ jobs:
|
||||
|
||||
be-tests-athena-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.athena-should-run == 'true' || needs.determine-driver-skips.outputs.athena-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -135,7 +134,6 @@ jobs:
|
||||
|
||||
be-tests-bigquery-cloud-sdk-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.bigquery-should-run == 'true' || needs.determine-driver-skips.outputs.bigquery-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -146,6 +144,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: >-
|
||||
@@ -206,7 +205,6 @@ jobs:
|
||||
|
||||
be-tests-clickhouse-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.clickhouse-should-run == 'true' || needs.determine-driver-skips.outputs.clickhouse-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -288,7 +286,6 @@ jobs:
|
||||
|
||||
be-tests-druid-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.druid-should-run == 'true' || needs.determine-driver-skips.outputs.druid-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -336,7 +333,6 @@ jobs:
|
||||
|
||||
be-tests-druid-jdbc-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.druid-jdbc-should-run == 'true' || needs.determine-driver-skips.outputs.druid-jdbc-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -384,7 +380,6 @@ jobs:
|
||||
|
||||
be-tests-databricks-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.databricks-should-run == 'true' || needs.determine-driver-skips.outputs.databricks-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -432,7 +427,6 @@ jobs:
|
||||
|
||||
be-tests-databricks-multi-catalog-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.databricks-should-run == 'true' || needs.determine-driver-skips.outputs.databricks-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -481,7 +475,6 @@ jobs:
|
||||
|
||||
be-tests-h2:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.h2-should-run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -511,7 +504,6 @@ jobs:
|
||||
|
||||
be-tests-mysql-mariadb:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.mysql-mariadb-should-run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -647,7 +639,6 @@ jobs:
|
||||
|
||||
be-tests-mongo:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.mongo-should-run == 'true' || needs.determine-driver-skips.outputs.mongo-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -727,7 +718,6 @@ jobs:
|
||||
|
||||
be-tests-mongo-ssl:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.mongo-ssl-should-run == 'true' || needs.determine-driver-skips.outputs.mongo-ssl-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -803,7 +793,7 @@ jobs:
|
||||
|
||||
be-tests-mongo-sharded-cluster-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.mongo-sharded-cluster-should-run == 'true' || needs.determine-driver-skips.outputs.mongo-sharded-cluster-quarantine-conflict == 'true' }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -848,7 +838,6 @@ jobs:
|
||||
|
||||
be-tests-oracle:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.oracle-should-run == 'true' || needs.determine-driver-skips.outputs.oracle-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -933,7 +922,6 @@ jobs:
|
||||
|
||||
be-tests-postgres:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.postgres-should-run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -1042,7 +1030,6 @@ jobs:
|
||||
|
||||
be-tests-presto-jdbc-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.presto-jdbc-should-run == 'true' || needs.determine-driver-skips.outputs.presto-jdbc-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -1123,7 +1110,6 @@ jobs:
|
||||
|
||||
be-tests-redshift-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.redshift-should-run == 'true' || needs.determine-driver-skips.outputs.redshift-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -1213,7 +1199,6 @@ jobs:
|
||||
|
||||
be-tests-snowflake-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.snowflake-should-run == 'true' || needs.determine-driver-skips.outputs.snowflake-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -1224,6 +1209,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
|
||||
@@ -1294,7 +1280,6 @@ jobs:
|
||||
|
||||
be-tests-sparksql-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.sparksql-should-run == 'true' || needs.determine-driver-skips.outputs.sparksql-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -1340,7 +1325,6 @@ jobs:
|
||||
|
||||
be-tests-sqlite-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.sqlite-should-run == 'true' || needs.determine-driver-skips.outputs.sqlite-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -1381,7 +1365,6 @@ jobs:
|
||||
|
||||
be-tests-sqlserver:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.sqlserver-should-run == 'true' || needs.determine-driver-skips.outputs.sqlserver-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
@@ -1502,7 +1485,6 @@ jobs:
|
||||
|
||||
login-to-ecr:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.vertica-should-run == 'true' || needs.determine-driver-skips.outputs.vertica-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
@@ -1532,7 +1514,6 @@ jobs:
|
||||
|
||||
be-tests-vertica-ee:
|
||||
needs: [determine-driver-skips, login-to-ecr]
|
||||
if: ${{ needs.determine-driver-skips.outputs.vertica-should-run == 'true' || needs.determine-driver-skips.outputs.vertica-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
|
||||
@@ -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
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
|
||||
|
||||
|
||||
44
.github/workflows/openapi-check.yml
vendored
Normal file
44
.github/workflows/openapi-check.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: OpenAPI Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-openapi-specs:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 5
|
||||
if: contains(github.event.pull_request.labels.*.name, 'openapi-self-healing')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# we're intentionally using METABASE_AUTOMATION_USER_TOKEN to trigger the whole CI pipeline again
|
||||
# after the self-healing commit is created
|
||||
# if we used GITHUB_TOKEN, the self-healing commit would not trigger the CI pipeline again
|
||||
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
# fetch full git history to allow fetching the latest base branch commit and comparing changes
|
||||
fetch-depth: 0
|
||||
- name: Setup Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Generate version.properties
|
||||
run: ./bin/build.sh :steps [:version]
|
||||
- name: Get latest base branch commit
|
||||
run: |
|
||||
git fetch origin ${{ github.event.pull_request.base.ref }}
|
||||
echo "LATEST_BASE_SHA=$(git rev-parse origin/${{ github.event.pull_request.base.ref }})" >> $GITHUB_ENV
|
||||
- name: Check OpenAPI specs are up to date
|
||||
env:
|
||||
AUTO_COMMIT: "true"
|
||||
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
BASE_SHA: ${{ env.LATEST_BASE_SHA }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: ./bin/openapi-check.sh
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -106,7 +106,6 @@ dev/serialization_deltas/
|
||||
/trace-output/
|
||||
/temp-types/
|
||||
/frontend/src/metabase-types/openapi
|
||||
/resources/openapi/openapi.json
|
||||
|
||||
**/cypress_sample_database.json
|
||||
**/cypress_sample_instance_data.json
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo, useEffect } from "react";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
|
||||
import { ThemeProvider } from "metabase/ui";
|
||||
|
||||
// @ts-expect-error: See metabase/lib/delay
|
||||
@@ -15,10 +16,13 @@ import "@mantine/dates/styles.css";
|
||||
|
||||
import { EmotionCacheProvider } from "metabase/styled-components/components/EmotionCacheProvider";
|
||||
import { getMetabaseCssVariables } from "metabase/styled-components/theme/css-variables";
|
||||
import { css, Global, useTheme } from "@emotion/react";
|
||||
|
||||
import { Global, css, useTheme } from "@emotion/react";
|
||||
|
||||
import { baseStyle, rootStyle } from "metabase/css/core/base.styled";
|
||||
import { defaultFontFiles } from "metabase/css/core/fonts.styled";
|
||||
import { saveDomImageStyles } from "metabase/visualizations/lib/image-exports";
|
||||
|
||||
import { initialize, mswLoader } from "msw-storybook-addon";
|
||||
|
||||
// Note: Changing the names of the stories may impact loki visual testing. Please ensure that
|
||||
@@ -107,7 +111,9 @@ function CssVariables() {
|
||||
}, []);
|
||||
|
||||
// This can get expensive so we should memoize it separately
|
||||
const cssVariables = useMemo(() => getMetabaseCssVariables(theme), [theme]);
|
||||
const cssVariables = useMemo(() => {
|
||||
return getMetabaseCssVariables({ theme });
|
||||
}, [theme]);
|
||||
|
||||
const styles = useMemo(() => {
|
||||
return css`
|
||||
|
||||
@@ -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.
|
||||
|
||||
69
bb.edn
69
bb.edn
@@ -83,6 +83,15 @@
|
||||
:requires [[mage.kondo :as kondo]]
|
||||
:task (task! (kondo/kondo-updated (:arguments parsed)))}
|
||||
|
||||
fix-unused-requires
|
||||
{:doc "Removes unused requires flagged by clj-kondo"
|
||||
:examples [["./bin/mage fix-unused-requires src/metabase/lib/validate.cljc" "Fix unused requires in a specific file"]
|
||||
["./bin/mage fix-unused-requires src" "Fix unused requires in all files in src"]]
|
||||
:requires [[mage.fix-unused-requires :as fix]]
|
||||
:arg-schema [:sequential [:string {:name "file-or-directory"
|
||||
:description "Files or directories to fix unused requires in"}]]
|
||||
:task (task! (fix/fix-files parsed))}
|
||||
|
||||
check
|
||||
{:doc "Checks whether we can compile all source files (and finds circular dependencies)"
|
||||
:examples [["./bin/mage check"]]
|
||||
@@ -274,6 +283,20 @@
|
||||
:requires [[mage.doctor :as doctor]]
|
||||
:task (task! (doctor/doctor!))}
|
||||
|
||||
ci-report
|
||||
{:doc "Generate a CI failure report for a PR"
|
||||
:examples [["./bin/mage ci-report" "Check current branch, find its PR, and generate report"]
|
||||
["./bin/mage ci-report 68292" "Generate report for PR #68292"]
|
||||
["./bin/mage ci-report https://github.com/metabase/metabase/pull/68292" "Generate report from PR URL"]]
|
||||
:usage-fn (fn [_]
|
||||
(str "Fetches CI check status and failed job logs from GitHub Actions.\n"
|
||||
"Parses Trunk test reports to extract test failure details.\n\n"
|
||||
"With no arguments, looks up the PR for your current git branch.\n\n"
|
||||
"Requires GitHub CLI (gh) to be installed and authenticated.\n"
|
||||
"If you see 'gh: command not found' or auth errors, run: gh auth login"))
|
||||
:requires [[mage.ci-report :as ci-report]]
|
||||
:task (task! (ci-report/generate-report! parsed))}
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Private Tasks:
|
||||
;; These tasks are more for internal tooling, e.g. for use from git hooks etc.
|
||||
@@ -284,32 +307,38 @@
|
||||
: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, no relevant changes" "SKIP" "no relevant changes for cloud driver"]
|
||||
["8"
|
||||
(str (pr-str driver-triggers) " module deps affected")
|
||||
"RUN"
|
||||
(str "any of " (pr-str driver-triggers) " affected by module changes")]
|
||||
["9" "Self-hosted driver, not affected" "SKIP" "driver module not affected"]])))))
|
||||
:options [[nil "--git-ref REF" "The git ref to compute changed modules against"]
|
||||
[nil "--is-master-or-release MASTER_OR_RELEASE"]
|
||||
[nil "--pr-labels PR_LABELS" "Comma delimited labels on the pr"]
|
||||
[nil "--skip SKIP"]
|
||||
[nil "--github-output-only" "Only output GITHUB_OUTPUT format (no human-readable output)"]]
|
||||
:requires [[clojure.string :as str]
|
||||
[mage.modules]]
|
||||
|
||||
:task (task! (mage.modules/-main parsed))}
|
||||
|
||||
-install-merge-drivers
|
||||
|
||||
@@ -4,7 +4,7 @@ import { readFileSync } from "fs";
|
||||
|
||||
import { glob } from "glob";
|
||||
|
||||
import { colorConfig } from "../../frontend/src/metabase/lib/colors/colors";
|
||||
import { ALL_COLOR_NAMES } from "../../frontend/src/metabase/lib/colors";
|
||||
|
||||
/**
|
||||
* This script finds css variables that are used but *never* defined in our codebase.
|
||||
@@ -104,7 +104,7 @@ const main = () => {
|
||||
allDefinitions.add(definition);
|
||||
}
|
||||
|
||||
Object.keys(colorConfig).forEach((key) => {
|
||||
ALL_COLOR_NAMES.forEach((key) => {
|
||||
allDefinitions.add(`--mb-color-${key}`);
|
||||
});
|
||||
|
||||
|
||||
94
bin/openapi-check.sh
Executable file
94
bin/openapi-check.sh
Executable file
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
OPENAPI_SPEC="resources/openapi/openapi.json"
|
||||
AUTO_COMMIT="${AUTO_COMMIT:-false}"
|
||||
|
||||
# Check that $OPENAPI_SPEC matches the current Malli schema definitions
|
||||
|
||||
if [[ -n "${GITHUB_SHA:-}" ]]; then
|
||||
echo "Running OpenAPI check on commit: $GITHUB_SHA"
|
||||
fi
|
||||
|
||||
# Generate the current OpenAPI spec from Malli schemas using write-openapi-spec-to-file!
|
||||
# This creates $OPENAPI_SPEC directly
|
||||
# Using the generate-openapi-spec command from metabase.cmd.core
|
||||
yarn generate-openapi
|
||||
ret=$?
|
||||
|
||||
if [[ $ret -ne 0 ]]; then
|
||||
exit $ret
|
||||
fi
|
||||
|
||||
if [[ ! -f "$OPENAPI_SPEC" ]]; then
|
||||
echo "Error: $OPENAPI_SPEC does not exist!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if there are any differences between generated and committed versions
|
||||
if ! git diff --exit-code "$OPENAPI_SPEC" >/dev/null 2>&1; then
|
||||
echo "OpenAPI schema changes detected:"
|
||||
git diff "$OPENAPI_SPEC"
|
||||
|
||||
if [[ "$AUTO_COMMIT" == "true" ]]; then
|
||||
echo ""
|
||||
echo "Auto-committing changes..."
|
||||
|
||||
# Require PR_BRANCH to be set for auto-commit
|
||||
if [[ -z "${PR_BRANCH:-}" ]]; then
|
||||
echo "Error: PR_BRANCH environment variable must be set for auto-commit"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stash the generated spec from the merge commit
|
||||
echo "Stashing generated OpenAPI spec from merge commit..."
|
||||
cp "$OPENAPI_SPEC" "${OPENAPI_SPEC}.tmp"
|
||||
|
||||
# Checkout the actual PR branch (in case we're on a merge commit)
|
||||
echo "Checking out branch: $PR_BRANCH"
|
||||
git checkout -f "$PR_BRANCH"
|
||||
|
||||
# Restore the generated spec from the merge commit
|
||||
mv -f "${OPENAPI_SPEC}.tmp" "$OPENAPI_SPEC"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add "$OPENAPI_SPEC"
|
||||
|
||||
# Build commit message with SHAs if available
|
||||
COMMIT_MSG="Update OpenAPI schema"
|
||||
if [[ -n "${BASE_SHA:-}" ]] && [[ -n "${HEAD_SHA:-}" ]]; then
|
||||
BASE_SHORT="${BASE_SHA:0:7}"
|
||||
HEAD_SHORT="${HEAD_SHA:0:7}"
|
||||
COMMIT_MSG="Update OpenAPI schema (${BASE_SHORT}...${HEAD_SHORT})"
|
||||
fi
|
||||
|
||||
git commit -m "$COMMIT_MSG"
|
||||
if ! git push origin "$PR_BRANCH"; then
|
||||
echo "Error: Failed to push OpenAPI schema update to $PR_BRANCH."
|
||||
exit 1
|
||||
fi
|
||||
echo "OpenAPI schema updated and pushed to $PR_BRANCH."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Error: OpenAPI specs are out of date!"
|
||||
echo "The schema in $OPENAPI_SPEC does not match the current Malli schema definitions."
|
||||
echo ""
|
||||
echo "This can happen when:"
|
||||
echo " 1. You added/modified API endpoints but didn't regenerate the OpenAPI spec"
|
||||
echo " 2. Your branch is behind master, which has new API endpoints"
|
||||
echo ""
|
||||
echo "To fix this:"
|
||||
echo " 1. Pull the latest changes from master: git pull origin master"
|
||||
echo " 2. Run 'yarn generate-openapi' to update $OPENAPI_SPEC"
|
||||
echo " 3. Commit the updated openapi.json file"
|
||||
echo ""
|
||||
echo "Differences:"
|
||||
git diff "$OPENAPI_SPEC" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "OpenAPI specs are up to date."
|
||||
|
||||
exit 0
|
||||
6
deps.edn
6
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
|
||||
|
||||
@@ -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
|
||||
|
||||
1012
docs/api.json
1012
docs/api.json
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,12 +11,16 @@ Guest embeds are a way to embed basic Metabase components in your app without re
|
||||
|
||||
Even though you're not using SSO, guest embeds are still secure: Metabase will only load the embed if the request has a JWT signed with the secret shared between your app and your Metabase. The JWT also includes a reference to the resource to load (like the ID of the embedded item), and any values for parameters.
|
||||
|
||||
To restrict data in guest embeds for specific people or groups, use [locked parameters](./static-embedding-parameters.md#restricting-data-in-a-static-embed-with-locked-parameters).
|
||||
To restrict data in guest embeds for specific people or groups, use [locked parameters](#locked-parameters).
|
||||
|
||||
## Turning on guest embedding in Metabase
|
||||
|
||||
1. Go to **Settings > Admin settings > Embedding > Guest embeds**.
|
||||
2. Toggle **Enable guest embedding**.
|
||||
The path to embedding settings depends on your Metabase version:
|
||||
|
||||
- **OSS**: **Admin settings > Embedding**
|
||||
- **Starter/Pro/Enterprise**: **Admin settings > Embedding > Guest embeds**
|
||||
|
||||
Toggle **Enable guest embeds**.
|
||||
|
||||
## Creating a guest embed
|
||||
|
||||
@@ -94,37 +98,160 @@ Replace `YOUR_METABASE_SECRET_KEY` with your [embedding secret key](#regeneratin
|
||||
|
||||
You can set different attributes to enable/disable UI. Here are some example attributes:
|
||||
|
||||
| Attribute | Description |
|
||||
| -------------------- | -------------------------------------------------------------------- |
|
||||
| `token` | Required. The signed JWT token from your server. |
|
||||
| `with-title` | Show or hide the title. Values: `"true"` or `"false"`. |
|
||||
| `with-downloads` | Enable or disable downloads. Values: `"true"` or `"false"`. |
|
||||
| `initial-parameters` | JSON string of parameter values. Example: `'{"category":["Gizmo"]}'` |
|
||||
| Attribute | Description |
|
||||
| -------------------- | --------------------------------------------------------------------- |
|
||||
| `token` | Required. The signed JWT token from your server. |
|
||||
| `with-title` | Show or hide the title. Values: `"true"` or `"false"`. |
|
||||
| `with-downloads` | Enable or disable downloads. Values: `"true"` or `"false"`. |
|
||||
| `initial-parameters` | JSON string of parameter values. Example: `'{"category":["Gizmo"]}'`. |
|
||||
|
||||
Attributes will differ based on the type of thing you're embedding. Guest embeds have fewer options that embeds that use SSO. See more on [components and their attributes](./components.md).
|
||||
Attributes will differ based on the type of thing you're embedding. Guest embeds have fewer options than embeds that use SSO. See more on [components and their attributes](./components.md).
|
||||
|
||||
### Customizing appearance of guest embeds
|
||||
|
||||
Appearance settings available for guest embeds depend on your Metabase plan. If you're running Metabase OSS/Starter, you can select light or dark theme. If you're running Metabase Pro/Enterprise, you'll have access to granular customization options, see [Appearance](./appearance.md)
|
||||
Appearance settings available for guest embeds depend on your Metabase plan. If you're running Metabase OSS/Starter, you can select light or dark theme. If you're running Metabase Pro/Enterprise, you'll have access to granular customization options, see [Appearance](./appearance.md).
|
||||
|
||||
## Editing parameters on an embedded question or dashboard
|
||||
## Configuring parameters
|
||||
|
||||
If you change the [parameters](./static-embedding-parameters.md) of your embedded item:
|
||||
Parameters are disabled by default, which also makes them hidden from end-users. You can configure each parameter to be:
|
||||
|
||||
1. After making your changes, copy the code Metabase generates.
|
||||
2. Click **Publish** again.
|
||||
3. Update the code on your server so that it matches the code generated by Metabase.
|
||||
- **Disabled**: Hidden from end-users. Can't be set. This is the default.
|
||||
- **[Editable](#editable-parameters)**: End-users can see and modify the parameter values.
|
||||
- **[Locked](#locked-parameters)**: Hidden from end-users, set by your server (not the end-users) via the JWT.
|
||||
|
||||
To configure parameters:
|
||||
|
||||
1. Go to your embedded question or dashboard.
|
||||
2. Click the **sharing icon** and select **Embed**.
|
||||
3. Under **Parameters**, select the visibility option for each parameter, and optionally default value(s).
|
||||
4. Click **Publish**.
|
||||
|
||||
### Editable parameters
|
||||
|
||||
When you set Editable parameters, you can set default values for the filters, but users can change these when viewing the question or dashboard.
|
||||
|
||||
**Server code**
|
||||
|
||||
On the server, you pass an empty `params` object:
|
||||
|
||||
```javascript
|
||||
// you will need to install via 'npm install jsonwebtoken' or in your package.json
|
||||
|
||||
const jwt = require("jsonwebtoken");
|
||||
|
||||
const METABASE_SECRET_KEY = "YOUR_METABASE_SECRET_KEY";
|
||||
|
||||
const payload = {
|
||||
resource: { dashboard: 10 },
|
||||
params: {},
|
||||
exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 minute expiration
|
||||
};
|
||||
const token = jwt.sign(payload, METABASE_SECRET_KEY);
|
||||
```
|
||||
|
||||
**Client code**
|
||||
|
||||
You set default parameters on the client side with the `initial-parameters` key.
|
||||
|
||||
```html
|
||||
<script defer src="YOUR_METABASE_URL/app/embed.js"></script>
|
||||
<script>
|
||||
function defineMetabaseConfig(config) {
|
||||
window.metabaseConfig = config;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
defineMetabaseConfig({
|
||||
isGuest: true,
|
||||
instanceUrl: "YOUR_METABASE_URL",
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
THIS IS THE EXAMPLE!
|
||||
NEVER HARDCODE THIS JWT TOKEN DIRECTLY IN YOUR HTML!
|
||||
|
||||
Fetch the JWT token from your backend and programmatically pass it to the 'metabase-dashboard'.
|
||||
-->
|
||||
<metabase-dashboard
|
||||
token="YOUR SIGNED TOKEN"
|
||||
with-title="true"
|
||||
with-downloads="false"
|
||||
initial-parameters='{"category":["Doohickey","Gizmo"]}'
|
||||
>
|
||||
</metabase-dashboard>
|
||||
```
|
||||
|
||||
### Locked parameters
|
||||
|
||||
Locked parameters let you filter data without exposing the filter to the end-user. This is useful for restricting data based on who's viewing the embed (for example, showing each customer only their own data).
|
||||
|
||||
To use locked parameters, you need to:
|
||||
|
||||
1. Set the parameter to **Locked** in the embed settings.
|
||||
2. Include the parameter value in your JWT token on the server.
|
||||
3. Publish the item.
|
||||
|
||||
Here's an example of the server and client code that Metabase will generate if you lock a "category" parameter:
|
||||
|
||||
**Server code (Node.js)**
|
||||
|
||||
You set the locked parameter on the server, passing it in the token.
|
||||
|
||||
```javascript
|
||||
// Install via 'npm install jsonwebtoken'
|
||||
const jwt = require("jsonwebtoken");
|
||||
|
||||
const METABASE_SECRET_KEY = "YOUR_METABASE_SECRET_KEY";
|
||||
|
||||
const payload = {
|
||||
resource: { dashboard: 10 },
|
||||
params: {
|
||||
category: ["Gadget"], // Set the locked parameter value to Gadget
|
||||
},
|
||||
exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 minute expiration
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, METABASE_SECRET_KEY);
|
||||
```
|
||||
|
||||
**Client code (HTML)**
|
||||
|
||||
The parameter is set by the JWT:
|
||||
|
||||
```html
|
||||
<script defer src="YOUR_METABASE_URL/app/embed.js"></script>
|
||||
<script>
|
||||
window.metabaseConfig = {
|
||||
isGuest: true, // Must be set to guest
|
||||
instanceUrl: "YOUR_METABASE_URL",
|
||||
};
|
||||
</script>
|
||||
|
||||
<!--
|
||||
IMPORTANT: Never hardcode JWT tokens directly in your HTML!
|
||||
Fetch the token from your backend and pass it to the component programmatically.
|
||||
-->
|
||||
<metabase-dashboard
|
||||
token="YOUR_JWT_TOKEN"
|
||||
with-title="true"
|
||||
with-downloads="false"
|
||||
></metabase-dashboard>
|
||||
```
|
||||
|
||||
The end user won't see the "category" filter, but the dashboard will only show data for the "Gadget" category (or whatever you passed to the "category" array in the `params` object in the server payload you used to sign the JWT).
|
||||
|
||||
## Disabling embedding for a question or dashboard
|
||||
|
||||
You can find a list of all guest embeds of questions and dashboards from **Admin settings** > **Embedding** > **Guest embeds**.
|
||||
|
||||
1. Visit the embeddable question or dashboard.
|
||||
2. Click the **sharing icon** (square with an arrow pointing to the top right).
|
||||
3. Select **Embed**.
|
||||
4. Select **Guest embedding**
|
||||
5. Click **Unpublish**.
|
||||
|
||||
Admins can find a list of embedded items in **Admin settings > Embedding** (on Pro and Enterprise plans, check the **Guest embeds** tab).
|
||||
|
||||
## Removing the "Powered by Metabase" banner
|
||||
|
||||

|
||||
@@ -135,7 +262,7 @@ The banner appears on guest embeds created with Metabase's open-source version.
|
||||
|
||||
Your embedding secret key is used to sign JWTs for all of your embeds.
|
||||
|
||||
1. Go to **Admin** > **Embedding** > **Guest embeds**.
|
||||
1. Go to **Admin settings > Embedding**. On Pro and Enterprise plans, check the **Guest embeds** tab.
|
||||
2. Under **Regenerate secret key**, click **Regenerate key**.
|
||||
|
||||
This key is shared across all guest embeds. Whoever has access to this key could get access to all embedded artifacts, so keep this key secure. If you regenerate this key, you'll need to update your server code with the new key.
|
||||
@@ -179,6 +306,12 @@ The signed JWT is generated using your [Metabase secret key](#regenerating-the-e
|
||||
|
||||
If you want to embed charts with additional interactive features, like [drill-down](https://www.metabase.com/learn/metabase-basics/querying-and-dashboards/questions/drill-through) and [self-service querying](../questions/query-builder/editor.md), see [Modular embedding](./modular-embedding.md).
|
||||
|
||||
## Using guest embeds with the SDK
|
||||
|
||||
If you're using the [Modular Embedding SDK](./sdk/introduction.md), and you also want to embed a question or dashboard using guest authentication, you'll still need to visit the item in your Metabase and publish the item. You can ignore the code the wizard generates, but in order for Metabase to know it's okay to serve the item, you need to publish it.
|
||||
|
||||
One limitation, however, is that you can only have one type of authentication per page of your app. For example, on a single page in your app, you can't have both one question using guest authentication and another question using SSO.
|
||||
|
||||
## Guest embed limitations
|
||||
|
||||
Because guest embeds don't require you to create a Metabase account for each person via SSO, Metabase can't know who is viewing the embed, and therefore can't give them access to all their data and all the cool stuff Metabase can do.
|
||||
@@ -186,16 +319,16 @@ Because guest embeds don't require you to create a Metabase account for each per
|
||||
Guest embeds can't take advantage of:
|
||||
|
||||
- [Row and column security](../permissions/row-and-column-security.md)
|
||||
- [Database routing](../permissions/database-routing.md)
|
||||
- [Drill-through](https://www.metabase.com/learn/metabase-basics/querying-and-dashboards/questions/drill-through)
|
||||
- [Usage analytics](../usage-and-performance-tools/usage-analytics.md).
|
||||
- [Usage analytics](../usage-and-performance-tools/usage-analytics.md)
|
||||
- [Query builder](../questions/query-builder/editor.md)
|
||||
- [AI chat](./sdk/ai-chat.md).
|
||||
- [AI chat](./sdk/ai-chat.md)
|
||||
|
||||
For those features, check out [Modular embedding with SSO](./modular-embedding.md).
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Parameters for guest embeds](./static-embedding-parameters.md).
|
||||
- [Reference apps repo](https://github.com/metabase/embedding-reference-apps).
|
||||
- [Strategies for delivering customer-facing analytics](https://www.metabase.com/learn/metabase-basics/embedding/overview).
|
||||
- [Publishing data visualizations to the web](https://www.metabase.com/learn/metabase-basics/embedding/charts-and-dashboards).
|
||||
|
||||
@@ -162,10 +162,20 @@ To prevent people from saving changes to an interactive question, or from saving
|
||||
|
||||

|
||||
|
||||
### Embed the visual query builder
|
||||
|
||||
You can embed the query builder for creating new questions by passing the `questionId="new"` prop to the `InteractiveQuestion` component. You can use the [`children` prop](#customizing-interactive-questions) to customize the layout for creating new questions.
|
||||
|
||||
```tsx
|
||||
{% include_file "{{ dirname }}/snippets/questions/new-question.tsx" %}
|
||||
```
|
||||
|
||||
### Embed the SQL editor
|
||||
|
||||
You can also embed the SQL editor to enable people to build questions with SQL by passing `questionId="new-native"` to the `InteractiveQuestion` component:
|
||||
|
||||
```tsx
|
||||
{% include_file "{{ dirname }}/snippets/questions/new-native-question.tsx" %}
|
||||
```
|
||||
|
||||
To customize the question editor's layout, use the `InteractiveQuestion` component [directly with a custom `children` prop](#customizing-interactive-questions).
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import {
|
||||
InteractiveQuestion,
|
||||
MetabaseProvider,
|
||||
defineMetabaseAuthConfig,
|
||||
} from "@metabase/embedding-sdk-react";
|
||||
|
||||
const authConfig = defineMetabaseAuthConfig({
|
||||
metabaseInstanceUrl: "https://your-metabase.example.com",
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<MetabaseProvider authConfig={authConfig}>
|
||||
<InteractiveQuestion questionId="new-native" />
|
||||
</MetabaseProvider>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -38,11 +38,12 @@ export const DataStudio = {
|
||||
},
|
||||
PythonLibrary: {
|
||||
header: () => cy.findByTestId("python-library-header"),
|
||||
editor: () => cy.findByTestId("python-editor"),
|
||||
},
|
||||
Snippets: {
|
||||
newPage: newSnippetPage,
|
||||
editPage: editSnippetPage,
|
||||
nameInput: () => newSnippetPage().findByPlaceholderText("New SQL snippet"),
|
||||
nameInput: () => newSnippetPage().findByDisplayValue("New SQL snippet"),
|
||||
descriptionInput: () => cy.findByPlaceholderText("No description"),
|
||||
saveButton: () => cy.findByRole("button", { name: "Save" }),
|
||||
cancelButton: () => cy.findByRole("button", { name: "Cancel" }),
|
||||
@@ -131,21 +132,4 @@ export const DataStudio = {
|
||||
collectionItem: (name: string) =>
|
||||
libraryPage().findAllByTestId("collection-name").contains(name),
|
||||
},
|
||||
Tasks: {
|
||||
visitUnreferencedEntities: () =>
|
||||
cy.visit("/data-studio/tasks/unreferenced"),
|
||||
list: () => cy.findByTestId("dependency-list"),
|
||||
searchInput: () => cy.findByTestId("dependency-list-search-input"),
|
||||
filterButton: () => cy.findByTestId("dependency-list-filter-button"),
|
||||
sidebar: () => cy.findByTestId("dependency-list-sidebar"),
|
||||
|
||||
Sidebar: {
|
||||
get: () => cy.findByTestId("dependency-list-sidebar"),
|
||||
header: () => cy.findByTestId("dependency-list-sidebar-header"),
|
||||
locationInfo: () => cy.findByRole("region", { name: "Location" }),
|
||||
errorInfo: (label: string) => cy.findByRole("region", { name: label }),
|
||||
creationInfo: () =>
|
||||
cy.findByRole("region", { name: "Creator and last editor" }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
31
e2e/support/helpers/e2e-dependency-helpers.ts
Normal file
31
e2e/support/helpers/e2e-dependency-helpers.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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" }),
|
||||
transformSection: () => cy.findByRole("region", { name: "Transform" }),
|
||||
missingColumnsSection: () =>
|
||||
cy.findByRole("region", { name: "Missing columns" }),
|
||||
creationSection: () =>
|
||||
cy.findByRole("region", { name: "Creator and last editor" }),
|
||||
brokenDependentsSection: () =>
|
||||
cy.findByRole("region", { name: "Broken dependents" }),
|
||||
},
|
||||
};
|
||||
@@ -47,6 +47,8 @@ export const exportFromDashcard = (format: string) => {
|
||||
cy.findByText(format).click();
|
||||
cy.findByTestId("download-results-button").click();
|
||||
});
|
||||
|
||||
cy.findByTestId("status-root-container").should("contain", "Downloading");
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,10 +4,20 @@ export const embedModalContent = () =>
|
||||
export const embedModalEnableEmbeddingCard = () =>
|
||||
cy.findByTestId("enable-embedding-card");
|
||||
|
||||
export const embedModalEnableEmbedding = () =>
|
||||
embedModalEnableEmbeddingCard().within(() => {
|
||||
cy.findByText(/(Agree and (continue|enable)|Enable)/).click();
|
||||
export const embedModalEnableEmbedding = () => {
|
||||
cy.get("body").then(($body) => {
|
||||
const isEmbeddingDisabled =
|
||||
$body.find('[data-testid="enable-embedding-card"]').length > 0;
|
||||
|
||||
if (isEmbeddingDisabled) {
|
||||
embedModalEnableEmbeddingCard().within(() => {
|
||||
cy.findByText(
|
||||
/(Agree and (continue|enable)|Enable and continue)/,
|
||||
).click();
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const legacyStaticEmbeddingButton = () =>
|
||||
cy.findByTestId("legacy-static-embedding-button");
|
||||
|
||||
@@ -10,7 +10,14 @@ export function selectNativeEditorDataSource(name: string) {
|
||||
popover().findByText(name).click();
|
||||
}
|
||||
|
||||
function clickOnRun() {
|
||||
cy.findByTestId("native-query-editor-container").within(() =>
|
||||
cy.findByTestId("run-button").click(),
|
||||
);
|
||||
}
|
||||
|
||||
export const NativeEditor = codeMirrorHelpers("native-query-editor", {
|
||||
dataSource: nativeEditorDataSource,
|
||||
selectDataSource: selectNativeEditorDataSource,
|
||||
clickOnRun,
|
||||
});
|
||||
|
||||
@@ -213,12 +213,12 @@ export const waitForTask = (
|
||||
if (retries > 3) {
|
||||
throw Error(`Too many retries waiting for ${taskName}`);
|
||||
}
|
||||
cy.wait("@currentTask").then(({ response }) => {
|
||||
return cy.wait("@currentTask").then(({ response }) => {
|
||||
const { body } = response || {};
|
||||
if (body?.sync_task_type !== taskName) {
|
||||
waitForTask({ taskName });
|
||||
return waitForTask({ taskName });
|
||||
} else if (body?.status !== "successful") {
|
||||
waitForTask({ taskName }, retries + 1);
|
||||
return waitForTask({ taskName }, retries + 1);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,6 +3,11 @@ interface User {
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
}
|
||||
|
||||
export function setUserAsAnalyst(userId: number) {
|
||||
cy.request("PUT", `/api/user/${userId}`, { is_data_analyst: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's full name, or an email address if name is not available.
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./api";
|
||||
export * from "./mbql";
|
||||
export * from "./e2e-action-helpers";
|
||||
export * from "./e2e-ad-hoc-question-helpers";
|
||||
export * from "./e2e-api-key-helpers";
|
||||
@@ -16,7 +17,7 @@ export * from "./e2e-dashboard-visualizer-helpers";
|
||||
export * from "./e2e-database-metadata-helpers";
|
||||
export * from "./e2e-datamodel-helpers";
|
||||
export * from "./e2e-data-studio-helpers";
|
||||
export * from "./e2e-dependency-graph-helpers";
|
||||
export * from "./e2e-dependency-helpers";
|
||||
export * from "./e2e-detail-view-helpers";
|
||||
export * from "./e2e-dimension-list-helpers";
|
||||
export * from "./e2e-document-helpers";
|
||||
|
||||
6
e2e/support/helpers/mbql/index.ts
Normal file
6
e2e/support/helpers/mbql/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
getMetadataProvider,
|
||||
createQuery,
|
||||
createNativeQuery,
|
||||
} from "./wrappers";
|
||||
export { createCardWithQuery, createCardWithNativeQuery } from "./question";
|
||||
86
e2e/support/helpers/mbql/metadata-provider.ts
Normal file
86
e2e/support/helpers/mbql/metadata-provider.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// WARNING:
|
||||
// This module should not be imported directly in cypress
|
||||
// harnesses, use an dynamic import instead.
|
||||
import { getMetadataWithoutSettings as getMetadataFromState } from "metabase/selectors/metadata";
|
||||
import * as Lib from "metabase-lib";
|
||||
import type {
|
||||
Card,
|
||||
CardId,
|
||||
Database,
|
||||
Table,
|
||||
TableId,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { createMockEntitiesState } from "../../../../frontend/test/__support__/store";
|
||||
import { SAMPLE_DB_ID, SAMPLE_DB_TABLES } from "../../cypress_data";
|
||||
|
||||
import type { GetMetadataOpts } from "./types";
|
||||
|
||||
const DEFAULT_TABLE_IDS: TableId[] = [
|
||||
SAMPLE_DB_TABLES.STATIC_ORDERS_ID,
|
||||
SAMPLE_DB_TABLES.STATIC_PEOPLE_ID,
|
||||
SAMPLE_DB_TABLES.STATIC_PRODUCTS_ID,
|
||||
SAMPLE_DB_TABLES.STATIC_REVIEWS_ID,
|
||||
];
|
||||
const DEFAULT_CARD_IDS: CardId[] = [];
|
||||
|
||||
/**
|
||||
* Returns a Lib.MetadataProvider instance containing metadata for the
|
||||
* provided database, tables and cards.
|
||||
*
|
||||
* WARNING:
|
||||
* DO NOT USE THIS DIRECTLY IN TESTS.
|
||||
* Use the `getMetadataProvider` from ./wrappers" instead.
|
||||
*
|
||||
* NOTE:
|
||||
* This function uses plain Promises and Promise.all to avoid type issues
|
||||
* with Cypress. The result needs to wrapped in cy.wrap() to be correctly usable
|
||||
* in Cypress tests.
|
||||
*/
|
||||
export async function getMetadataProvider({
|
||||
databaseId = SAMPLE_DB_ID,
|
||||
...rest
|
||||
}: GetMetadataOpts = {}) {
|
||||
const metadata = await getMetadata({ databaseId, ...rest });
|
||||
return Lib.metadataProvider(databaseId, metadata);
|
||||
}
|
||||
|
||||
async function getMetadata({
|
||||
databaseId = SAMPLE_DB_ID,
|
||||
tableIds = DEFAULT_TABLE_IDS,
|
||||
cardIds = DEFAULT_CARD_IDS,
|
||||
}: GetMetadataOpts = {}) {
|
||||
// Just one database for now, but wrapped to get the types to work
|
||||
const databaseIds = [databaseId];
|
||||
|
||||
const [databases, tables, cards] = await Promise.all([
|
||||
// databases
|
||||
Promise.all(
|
||||
databaseIds.map((databaseId) =>
|
||||
GET<Database>(`/api/database/${databaseId}`),
|
||||
),
|
||||
),
|
||||
|
||||
// cards
|
||||
Promise.all(
|
||||
tableIds.map((tableId) =>
|
||||
GET<Table>(`/api/table/${tableId}/query_metadata`),
|
||||
),
|
||||
),
|
||||
|
||||
// tables
|
||||
Promise.all(cardIds.map((cardId) => GET<Card>(`/api/card/${cardId}`))),
|
||||
]);
|
||||
|
||||
const entities = createMockEntitiesState({
|
||||
databases,
|
||||
tables,
|
||||
questions: cards,
|
||||
});
|
||||
return getMetadataFromState({ entities });
|
||||
}
|
||||
|
||||
async function GET<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
return response.json();
|
||||
}
|
||||
104
e2e/support/helpers/mbql/question.ts
Normal file
104
e2e/support/helpers/mbql/question.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type * as Lib from "metabase-lib";
|
||||
import type { CreateTestQueryOpts } from "metabase-lib/test-helpers";
|
||||
import type { DatabaseId } from "metabase-types/api";
|
||||
|
||||
import type { QuestionDetails } from "../api";
|
||||
import { type Options, question } from "../api/createQuestion";
|
||||
|
||||
import type { GetMetadataOpts } from "./types";
|
||||
import { createNativeQuery, createQuery } from "./wrappers";
|
||||
|
||||
/**
|
||||
* Create a card using the provided details and a MBQL query.
|
||||
*
|
||||
* @param details.metadata -
|
||||
* The metadata provider to use for the query,
|
||||
* or options used to create one on the fly.
|
||||
* If no metadata is provided, the query will be created with
|
||||
* the default metadata provider.
|
||||
*
|
||||
* @example
|
||||
* // If no metadata is provided, the card will be created with
|
||||
* // the default metadata provider.
|
||||
* H.createTestQuestion({
|
||||
* name: "My question",
|
||||
* query: {
|
||||
* databaseId: SAMPLE_DATABASE_ID,
|
||||
* stages: [
|
||||
* ...
|
||||
* ]
|
||||
* }
|
||||
* })
|
||||
* })
|
||||
*
|
||||
* @example
|
||||
* // Provide custom metadata entities relevant to your query.
|
||||
* H.createTestQuestion({
|
||||
* name: "My question",
|
||||
* databaseId: SAMPLE_DATABASE_ID,
|
||||
* query: "SELECT * FROM orders"
|
||||
* })
|
||||
* })
|
||||
*
|
||||
*/
|
||||
|
||||
type CardWithQueryDetails = Omit<QuestionDetails, "dataset_query"> & {
|
||||
metadata?: GetMetadataOpts | Lib.MetadataProvider;
|
||||
query: CreateTestQueryOpts;
|
||||
};
|
||||
|
||||
export function createCardWithQuery(
|
||||
{ metadata = {}, query, ...details }: CardWithQueryDetails,
|
||||
options: Options = {},
|
||||
) {
|
||||
return createQuery(metadata, query).then((dataset_query) =>
|
||||
question({ ...details, dataset_query }, options),
|
||||
);
|
||||
}
|
||||
|
||||
type CardWithNativeQueryDetails = Omit<QuestionDetails, "dataset_query"> & {
|
||||
metadata?: GetMetadataOpts | Lib.MetadataProvider;
|
||||
databaseId: DatabaseId;
|
||||
query: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a card using the provided details and a native query.
|
||||
*
|
||||
* @param details.metadata -
|
||||
* The metadata provider to use for the query,
|
||||
* or options used to create one on the fly.
|
||||
* If no metadata is provided, the query will be created with
|
||||
* the default metadata provider.
|
||||
*
|
||||
* @example
|
||||
* // If no metadata is provided, the card will be created with
|
||||
* // the default metadata provider.
|
||||
* H.createCardWithNativeQuery({
|
||||
* name: "My question",
|
||||
* databaseId: SAMPLE_DATABASE_ID,
|
||||
* query: "SELECT * FROM orders"
|
||||
* })
|
||||
* })
|
||||
*
|
||||
* @example
|
||||
* // Provide custom metadata entities relevant to your query.
|
||||
* H.createCardWithNativeQuery({
|
||||
* name: "My question",
|
||||
* databaseId: SAMPLE_DATABASE_ID,
|
||||
* metadata: {
|
||||
* databaseId: SAMPLE_DATABASE_ID,
|
||||
* cardIds: [SAMPLE_CARD_ID],
|
||||
* },
|
||||
* query: "SELECT * FROM orders"
|
||||
* })
|
||||
* })
|
||||
*/
|
||||
export function createCardWithNativeQuery(
|
||||
{ metadata = {}, databaseId, query, ...details }: CardWithNativeQueryDetails,
|
||||
options: Options = {},
|
||||
) {
|
||||
return createNativeQuery(metadata, databaseId, query).then((dataset_query) =>
|
||||
question({ ...details, dataset_query }, options),
|
||||
);
|
||||
}
|
||||
7
e2e/support/helpers/mbql/types.ts
Normal file
7
e2e/support/helpers/mbql/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { CardId, DatabaseId, TableId } from "metabase-types/api";
|
||||
|
||||
export type GetMetadataOpts = {
|
||||
databaseId?: DatabaseId;
|
||||
tableIds?: TableId[];
|
||||
cardIds?: CardId[];
|
||||
};
|
||||
103
e2e/support/helpers/mbql/wrappers.ts
Normal file
103
e2e/support/helpers/mbql/wrappers.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type * as Lib from "metabase-lib";
|
||||
import type { CreateTestQueryOpts } from "metabase-lib/test-helpers";
|
||||
import type { DatabaseId, OpaqueDatasetQuery } from "metabase-types/api";
|
||||
|
||||
import type { GetMetadataOpts } from "./types";
|
||||
|
||||
/**
|
||||
* Returns a Lib.MetadataProvider instance containing metadata for the
|
||||
* entities defined in opts.
|
||||
*
|
||||
* @param opts.databaseId - The database to fetch metadata for
|
||||
* @param opts.tableIds - The tables to fetch metadata for
|
||||
* @param opts.cardIds - Cards to fetch metadata for
|
||||
*/
|
||||
export function getMetadataProvider(
|
||||
opts?: GetMetadataOpts,
|
||||
): Cypress.Chainable<Lib.MetadataProvider> {
|
||||
return (
|
||||
cy
|
||||
// we need to log in before we can get metadata
|
||||
.getCookie("metabase.SESSION_ID")
|
||||
.then(() => _getMetadataProvider({ ...opts }))
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function which calls getMetadataProvider from ./metadata-provider
|
||||
// This module needs to imported lazily not to crash Cypress and to avoid importing
|
||||
// cljs in tests that don't need it.
|
||||
async function _getMetadataProvider(
|
||||
opts?: GetMetadataOpts,
|
||||
): Promise<Lib.MetadataProvider> {
|
||||
const { getMetadataProvider } = await import("./metadata-provider");
|
||||
return getMetadataProvider(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an MBQL query using a custom DSL that simplifies field selection and creation.
|
||||
*
|
||||
* @param metadataProvider - A Lib.MetadataProvider instance or a GetMetadataOpts object that will be user to instantiate a Lib.MetadataProvider
|
||||
* @param opts - The details of the query to create
|
||||
*/
|
||||
export function createQuery(
|
||||
metadataProvider: Lib.MetadataProvider | GetMetadataOpts,
|
||||
opts: CreateTestQueryOpts,
|
||||
): Cypress.Chainable<OpaqueDatasetQuery> {
|
||||
return ensureProvider(metadataProvider).then((provider) => {
|
||||
return createTestJsQuery(provider, opts);
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function which calls createTestJsQuery from metabase-lib/test-helpers.
|
||||
// This module needs to imported lazily not to crash Cypress and to avoid importing
|
||||
// cljs in tests that don't need it.
|
||||
async function createTestJsQuery(
|
||||
metadataProvider: Lib.MetadataProvider,
|
||||
opts: CreateTestQueryOpts,
|
||||
): Promise<OpaqueDatasetQuery> {
|
||||
const { createTestJsQuery } = await import("metabase-lib/test-helpers");
|
||||
return createTestJsQuery(metadataProvider, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a native MBQL query using a custom DSL that simplifies field selection and creation.
|
||||
*
|
||||
* @param metadataProvider - A Lib.MetadataProvider instance or a GetMetadataOpts object that will be user to instantiate a Lib.MetadataProvider
|
||||
* @param databaseId - The database the query is for
|
||||
* @param query - The native query string
|
||||
*/
|
||||
export function createNativeQuery(
|
||||
metadataProvider: Lib.MetadataProvider | GetMetadataOpts,
|
||||
databaseId: DatabaseId,
|
||||
query: string,
|
||||
): Cypress.Chainable<OpaqueDatasetQuery> {
|
||||
return ensureProvider(metadataProvider).then((provider) =>
|
||||
createTestNativeJsQuery(provider, databaseId, query),
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function which calls createTestNativeJsQuery from metabase-lib/test-helpers.
|
||||
// This module needs to imported lazily not to crash Cypress and to avoid importing
|
||||
// cljs in tests that don't need it.
|
||||
async function createTestNativeJsQuery(
|
||||
metadataProvider: Lib.MetadataProvider,
|
||||
databaseId: DatabaseId,
|
||||
query: string,
|
||||
) {
|
||||
const { createTestNativeJsQuery } = await import("metabase-lib/test-helpers");
|
||||
return createTestNativeJsQuery(metadataProvider, databaseId, query);
|
||||
}
|
||||
|
||||
function ensureProvider(
|
||||
providerOrOpts: GetMetadataOpts | Lib.MetadataProvider,
|
||||
): Cypress.Chainable<Lib.MetadataProvider> {
|
||||
return isMetadataProvider(providerOrOpts)
|
||||
? cy.wrap(providerOrOpts)
|
||||
: getMetadataProvider(providerOrOpts);
|
||||
}
|
||||
|
||||
function isMetadataProvider(
|
||||
opts: GetMetadataOpts | Lib.MetadataProvider,
|
||||
): opts is Lib.MetadataProvider {
|
||||
return "cache" in opts;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,4 +47,59 @@ describe("scenarios > embedding-sdk > interactive-question", () => {
|
||||
H.assertTableRowsCount(4);
|
||||
});
|
||||
});
|
||||
|
||||
it("should be able to edit a native question with the QueryEditor", () => {
|
||||
cy.intercept("GET", "/api/database").as("schema");
|
||||
|
||||
cy.get("@sqlQuestionId").then((sqlQuestionId) => {
|
||||
mountSdkContent(
|
||||
<InteractiveQuestion questionId={sqlQuestionId} withParameters />,
|
||||
);
|
||||
});
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByText("SQL Orders").should("be.visible");
|
||||
H.assertTableRowsCount(5);
|
||||
|
||||
cy.findByTestId("notebook-button").click();
|
||||
cy.findByTestId("native-query-editor").should("be.visible");
|
||||
cy.wait("@schema");
|
||||
|
||||
H.NativeEditor.value().should("equal", "select * from ORDERS limit 5");
|
||||
cy.findByRole("textbox").should("be.visible").click();
|
||||
|
||||
H.NativeEditor.type("{movetoend}{backspace}10");
|
||||
|
||||
H.NativeEditor.clickOnRun();
|
||||
|
||||
cy.button("Visualize").should("be.visible").click();
|
||||
|
||||
H.assertTableRowsCount(10);
|
||||
});
|
||||
});
|
||||
|
||||
it("should be able to create a native question with the QueryEditor", () => {
|
||||
cy.intercept("GET", "/api/database?can-query=true").as("schema");
|
||||
|
||||
mountSdkContent(
|
||||
<InteractiveQuestion questionId="new-native" withParameters />,
|
||||
);
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByTestId("native-query-editor").should("be.visible");
|
||||
cy.findByLabelText("placeholder SELECT * FROM TABLE_NAME").should(
|
||||
"be.visible",
|
||||
);
|
||||
|
||||
cy.wait("@schema");
|
||||
|
||||
H.NativeEditor.type("SELECT * from ORDERS LIMIT 10");
|
||||
|
||||
H.NativeEditor.clickOnRun();
|
||||
|
||||
cy.button("Visualize").should("be.visible").click();
|
||||
|
||||
H.assertTableRowsCount(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,7 +110,7 @@ describe("scenarios > admin > people", () => {
|
||||
|
||||
cy.findByRole("link", { name: /group/i }).click();
|
||||
|
||||
cy.findByRole("table").findByRole("link", { name: /data/i }).click();
|
||||
cy.findByRole("table").findByRole("link", { name: /data$/i }).click();
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("1 member");
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SAMPLE_DB_ID, WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
||||
|
||||
describe("issue 26470", { tags: "@external" }, () => {
|
||||
beforeEach(() => {
|
||||
H.restore("postgres_12");
|
||||
H.restore("postgres-writable");
|
||||
cy.signInAsAdmin();
|
||||
cy.request("POST", "/api/persist/enable");
|
||||
});
|
||||
|
||||
@@ -374,21 +374,24 @@ describe("Remote Sync", () => {
|
||||
cy.button(/Push changes/).click();
|
||||
});
|
||||
|
||||
H.waitForTask({ taskName: "export" });
|
||||
H.waitForTask({ taskName: "export" }).then(() => {
|
||||
H.getGitSyncControls().should("contain.text", NEW_BRANCH);
|
||||
H.collectionTable().within(() => {
|
||||
cy.findByText("Orders").should("exist");
|
||||
cy.findByText(REMOTE_QUESTION_NAME).should("exist");
|
||||
});
|
||||
|
||||
H.getGitSyncControls().should("contain.text", NEW_BRANCH);
|
||||
H.collectionTable().within(() => {
|
||||
cy.findByText("Orders").should("exist");
|
||||
cy.findByText(REMOTE_QUESTION_NAME).should("exist");
|
||||
});
|
||||
H.modal().findByText("Pushing to Git").should("not.exist");
|
||||
|
||||
H.getSwitchBranchOption().click();
|
||||
H.popover().findByRole("option", { name: "main" }).click();
|
||||
H.getSwitchBranchOption().click();
|
||||
H.popover().findByRole("option", { name: "main" }).click();
|
||||
|
||||
H.waitForTask({ taskName: "import" });
|
||||
H.collectionTable().within(() => {
|
||||
cy.findByText("Orders").should("not.exist");
|
||||
cy.findByText(REMOTE_QUESTION_NAME).should("exist");
|
||||
H.waitForTask({ taskName: "import" }).then(() => {
|
||||
H.collectionTable().within(() => {
|
||||
cy.findByText("Orders").should("not.exist");
|
||||
cy.findByText(REMOTE_QUESTION_NAME).should("exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -139,17 +139,14 @@ describe("issue 14636", () => {
|
||||
"have.value",
|
||||
"field values scanning",
|
||||
);
|
||||
cy.findByPlaceholderText("Filter by status").should(
|
||||
"have.value",
|
||||
"Success",
|
||||
);
|
||||
getFilterByStatus().should("have.value", "Success");
|
||||
cy.findAllByTestId("task").should("have.length", 1);
|
||||
cy.findByTestId("task")
|
||||
.should("contain.text", "field values scanning")
|
||||
.and("contain.text", "Sample Database")
|
||||
.and("contain.text", "Success");
|
||||
|
||||
cy.findByPlaceholderText("Filter by status").click();
|
||||
getFilterByStatus().click();
|
||||
H.popover().findByText("Failed").click();
|
||||
cy.location("search").should(
|
||||
"eq",
|
||||
@@ -161,12 +158,9 @@ describe("issue 14636", () => {
|
||||
"No results",
|
||||
);
|
||||
|
||||
cy.findByPlaceholderText("Filter by status")
|
||||
.parent()
|
||||
.findByLabelText("Clear")
|
||||
.click();
|
||||
getFilterByStatus().parent().findByLabelText("Clear").click();
|
||||
cy.location("search").should("eq", "?task=field+values+scanning");
|
||||
cy.findByPlaceholderText("Filter by status").should("have.value", "");
|
||||
getFilterByStatus().should("have.value", "");
|
||||
cy.findAllByTestId("task").should("have.length", 1);
|
||||
cy.findByTestId("task")
|
||||
.should("contain.text", "field values scanning")
|
||||
@@ -184,14 +178,14 @@ describe("issue 14636", () => {
|
||||
|
||||
cy.log("should reset pagination when changing filters");
|
||||
cy.visit("/admin/tools/tasks/list?page=1");
|
||||
cy.findByPlaceholderText("Filter by status").click();
|
||||
getFilterByStatus().click();
|
||||
H.popover().findByText("Success").click();
|
||||
cy.location("search").should("eq", "?status=success");
|
||||
|
||||
cy.log("should remove invalid query params");
|
||||
cy.visit("/admin/tools/tasks/list?status=foobar");
|
||||
cy.location("search").should("eq", "");
|
||||
cy.findByPlaceholderText("Filter by status").should("have.value", "");
|
||||
getFilterByStatus().should("have.value", "");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -244,7 +238,7 @@ describe("scenarios > admin > tools > tasks", () => {
|
||||
cy.window().then((window) => {
|
||||
cy.stub(window.navigator.clipboard, "writeText").resolves();
|
||||
});
|
||||
cy.icon("copy").click();
|
||||
cy.findByTestId("code-container").icon("copy").click();
|
||||
cy.window()
|
||||
.its("navigator.clipboard.writeText")
|
||||
.should("be.calledWith", formattedTaskJson);
|
||||
@@ -832,54 +826,40 @@ describe("scenarios > admin > tools > task runs filtering", () => {
|
||||
cy.wait("@getTaskRuns");
|
||||
|
||||
cy.log("Filter by run type");
|
||||
cy.findByPlaceholderText("Filter by run type").click();
|
||||
getFilterByRun().click();
|
||||
H.popover().findByText("Sync").click();
|
||||
cy.location("search").should("contain", "run-type=sync");
|
||||
cy.wait("@getTaskRuns");
|
||||
cy.log("Filter by started at");
|
||||
getFilterByStartedAt().click();
|
||||
H.popover().findByText("Previous 30 days").click();
|
||||
cy.location("search").should("contain", "started-at=past30days");
|
||||
cy.wait("@getTaskRuns");
|
||||
|
||||
cy.wait("@getEntities");
|
||||
cy.log("Filter by entity");
|
||||
cy.findByPlaceholderText("Filter by entity").click();
|
||||
getFilterByEntity().click();
|
||||
H.popover().findByText("Sample Database").click();
|
||||
cy.location("search").should("contain", "entity-type=database");
|
||||
cy.location("search").should("contain", "entity-id=1");
|
||||
cy.wait("@getTaskRuns");
|
||||
|
||||
cy.log("Filter by status");
|
||||
cy.findByPlaceholderText("Filter by status").click();
|
||||
getFilterByStatus().click();
|
||||
H.popover().findByText("Success").click();
|
||||
cy.location("search").should("contain", "status=success");
|
||||
cy.wait("@getTaskRuns");
|
||||
|
||||
cy.log("Clear all filters");
|
||||
cy.findByPlaceholderText("Filter by run type")
|
||||
.parent()
|
||||
.findByLabelText("Clear")
|
||||
.click();
|
||||
cy.findByPlaceholderText("Filter by status")
|
||||
.parent()
|
||||
.findByLabelText("Clear")
|
||||
.click();
|
||||
getFilterByRun().parent().findByLabelText("Clear").click();
|
||||
getFilterByStartedAt().parent().findByLabelText("Clear").click();
|
||||
getFilterByStatus().parent().findByLabelText("Clear").click();
|
||||
cy.location("search").should("eq", "");
|
||||
});
|
||||
|
||||
it("entity picker should be disabled/enabled based on run type and entities availability", () => {
|
||||
it("entity picker should be disabled/enabled based on run type, started at and entities availability", () => {
|
||||
cy.visit("/admin/tools/tasks/runs");
|
||||
cy.wait("@getTaskRuns");
|
||||
|
||||
cy.log("Should be disabled when no run type is selected");
|
||||
cy.findByPlaceholderText("Filter by entity").should("be.disabled");
|
||||
|
||||
cy.log("Should show tooltip 'Select a run type first' when hovering");
|
||||
cy.findByPlaceholderText("Filter by entity").trigger("mouseenter", {
|
||||
force: true,
|
||||
});
|
||||
cy.findByRole("tooltip").should("have.text", "Select a run type first");
|
||||
cy.findByPlaceholderText("Filter by entity").trigger("mouseleave", {
|
||||
force: true,
|
||||
});
|
||||
|
||||
cy.log("Should show loader while loading entities");
|
||||
cy.intercept("GET", "/api/task/runs/entities?*", {
|
||||
body: [
|
||||
{
|
||||
@@ -891,10 +871,24 @@ describe("scenarios > admin > tools > task runs filtering", () => {
|
||||
delay: 500,
|
||||
}).as("getEntitiesDelayed");
|
||||
|
||||
cy.findByPlaceholderText("Filter by run type").click();
|
||||
cy.log("Should be disabled when no run type is selected");
|
||||
getFilterByEntity().should("be.disabled");
|
||||
assertFilterByEntityTooltipText("Select a run type first");
|
||||
|
||||
getFilterByRun().click();
|
||||
H.popover().findByText("Sync").click();
|
||||
|
||||
cy.findByPlaceholderText("Filter by entity")
|
||||
cy.log("Should be still disabled until started at is selected");
|
||||
getFilterByEntity().should("be.disabled");
|
||||
|
||||
cy.log("Should show tooltip 'Select a start time' when hovering");
|
||||
assertFilterByEntityTooltipText("Select a start time first");
|
||||
|
||||
getFilterByStartedAt().click();
|
||||
H.popover().findByText("Previous 30 days").click();
|
||||
|
||||
cy.log("Should show loader while loading entities");
|
||||
getFilterByEntity()
|
||||
.should("be.disabled")
|
||||
.closest(".mb-mantine-Select-root")
|
||||
.find(".mb-mantine-Loader-root")
|
||||
@@ -903,30 +897,56 @@ describe("scenarios > admin > tools > task runs filtering", () => {
|
||||
cy.wait("@getEntitiesDelayed");
|
||||
|
||||
cy.log("Should be enabled after entities are loaded");
|
||||
cy.findByPlaceholderText("Filter by entity").should("not.be.disabled");
|
||||
getFilterByEntity().should("not.be.disabled");
|
||||
|
||||
cy.log("Should clear and disable entity filter when run type is cleared");
|
||||
cy.findByPlaceholderText("Filter by run type")
|
||||
.parent()
|
||||
.findByLabelText("Clear")
|
||||
.click();
|
||||
getFilterByRun().parent().findByLabelText("Clear").click();
|
||||
|
||||
cy.findByPlaceholderText("Filter by entity").should("be.disabled");
|
||||
cy.findByPlaceholderText("Filter by entity").should("have.value", "");
|
||||
getFilterByEntity().should("be.disabled");
|
||||
getFilterByEntity().should("have.value", "");
|
||||
|
||||
cy.log("Should clear and disable entity filter when started at is cleared");
|
||||
getFilterByRun().click();
|
||||
H.popover().findByText("Sync").click();
|
||||
getFilterByStartedAt().parent().findByLabelText("Clear").click();
|
||||
|
||||
getFilterByEntity().should("be.disabled");
|
||||
getFilterByEntity().should("have.value", "");
|
||||
|
||||
cy.log("Should show tooltip 'No entities available' when no entities");
|
||||
cy.intercept("GET", "/api/task/runs/entities?*", {
|
||||
body: [],
|
||||
}).as("getEmptyEntities");
|
||||
|
||||
cy.findByPlaceholderText("Filter by run type").click();
|
||||
getFilterByRun().click();
|
||||
H.popover().findByText("Alert").click();
|
||||
getFilterByStartedAt().click();
|
||||
H.popover().findByText("Previous 30 days").click();
|
||||
cy.wait("@getEmptyEntities");
|
||||
|
||||
cy.findByPlaceholderText("Filter by entity").should("be.disabled");
|
||||
cy.findByPlaceholderText("Filter by entity").trigger("mouseenter", {
|
||||
force: true,
|
||||
});
|
||||
cy.findByRole("tooltip").should("have.text", "No entities available");
|
||||
getFilterByEntity().should("be.disabled");
|
||||
assertFilterByEntityTooltipText("No entities available");
|
||||
});
|
||||
});
|
||||
|
||||
function getFilterByRun() {
|
||||
return cy.findByPlaceholderText("Filter by run type");
|
||||
}
|
||||
function getFilterByStartedAt() {
|
||||
return cy.findByPlaceholderText("Filter by started at");
|
||||
}
|
||||
|
||||
function getFilterByEntity() {
|
||||
return cy.findByPlaceholderText("Filter by entity");
|
||||
}
|
||||
|
||||
function getFilterByStatus() {
|
||||
return cy.findByPlaceholderText("Filter by status");
|
||||
}
|
||||
|
||||
function assertFilterByEntityTooltipText(text: string) {
|
||||
getFilterByEntity().trigger("mouseenter", {
|
||||
force: true,
|
||||
});
|
||||
cy.findByRole("tooltip").should("have.text", text);
|
||||
}
|
||||
|
||||
@@ -203,6 +203,7 @@ describe("issue 56567", () => {
|
||||
["All Users", allUsersPermission],
|
||||
["collection", "Curate"],
|
||||
["data", "No access"],
|
||||
["Data Analysts", "No access"],
|
||||
["nosql", "No access"],
|
||||
["readonly", "View"],
|
||||
];
|
||||
|
||||
@@ -1525,6 +1525,114 @@ SELECT 'group_2', 'sub_group_2', 52, 'group_2__sub_group_2';
|
||||
const scalarContainer = () => cy.findByTestId("scalar-container");
|
||||
const previousValue = () => cy.findByTestId("scalar-previous-value");
|
||||
|
||||
describe("issue 67432", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
|
||||
it("should copy sorted table data in correct sorted order (metabase#67432)", () => {
|
||||
H.grantClipboardPermissions();
|
||||
|
||||
const ROWS_LIMIT = 5;
|
||||
const questionDetails = {
|
||||
name: "67432 Question",
|
||||
query: {
|
||||
"source-table": PRODUCTS_ID,
|
||||
fields: [
|
||||
["field", PRODUCTS.ID, null],
|
||||
["field", PRODUCTS.TITLE, null],
|
||||
["field", PRODUCTS.CATEGORY, null],
|
||||
],
|
||||
limit: ROWS_LIMIT,
|
||||
},
|
||||
};
|
||||
|
||||
H.createQuestionAndDashboard({
|
||||
questionDetails,
|
||||
cardDetails: {
|
||||
size_x: 16,
|
||||
size_y: 10,
|
||||
},
|
||||
}).then(({ body: { dashboard_id } }) => {
|
||||
H.visitDashboard(dashboard_id);
|
||||
});
|
||||
|
||||
// Wait for table to load
|
||||
H.tableInteractiveBody().should("be.visible");
|
||||
|
||||
// Sort by Category column (descending first click)
|
||||
H.tableHeaderClick("Category");
|
||||
|
||||
// Wait for sort to apply - the sort icon should appear
|
||||
H.tableHeaderColumn("Category")
|
||||
.closest("[data-testid=header-cell]")
|
||||
.icon("chevrondown")
|
||||
.should("exist");
|
||||
|
||||
// Collect the visual order of categories from the table
|
||||
const visualCategories = [];
|
||||
H.tableInteractiveBody()
|
||||
.find('[data-column-id="CATEGORY"]')
|
||||
.each(($cell) => {
|
||||
visualCategories.push($cell.text());
|
||||
})
|
||||
.then(() => {
|
||||
// Select multiple cells across rows by dragging
|
||||
const getNonPKCells = () =>
|
||||
H.tableInteractiveBody().find(
|
||||
'[data-selectable-cell]:not([data-column-id="ID"])',
|
||||
);
|
||||
|
||||
// Select cells in first two rows (4 cells: Title+Category for 2 rows)
|
||||
getNonPKCells()
|
||||
.eq(0)
|
||||
.trigger("mousedown", { which: 1 })
|
||||
.then(() => {
|
||||
const lastCellIndex = ROWS_LIMIT * 2 - 1;
|
||||
getNonPKCells()
|
||||
.should("have.length", ROWS_LIMIT * 2)
|
||||
.eq(lastCellIndex)
|
||||
.trigger("mouseover", { buttons: 1 });
|
||||
getNonPKCells()
|
||||
.should("have.length", ROWS_LIMIT * 2)
|
||||
.eq(lastCellIndex)
|
||||
.trigger("mouseup");
|
||||
});
|
||||
|
||||
// Copy to clipboard
|
||||
cy.realPress(["Meta", "c"]);
|
||||
|
||||
// Verify clipboard content has rows in sorted order
|
||||
H.readClipboard().then((clipboardText) => {
|
||||
// The clipboard should contain properly tab-separated content
|
||||
// with newlines between rows (not a single cell)
|
||||
const lines = clipboardText.split("\n");
|
||||
|
||||
// Should have header row + data rows (at least 6 lines: header + 5 data rows)
|
||||
expect(lines.length).to.be.eq(ROWS_LIMIT + 1);
|
||||
|
||||
// Header should be tab-separated with both columns
|
||||
const headerCells = lines[0].split("\t");
|
||||
expect(headerCells).to.include("Title");
|
||||
expect(headerCells).to.include("Category");
|
||||
|
||||
// Verify each data row is tab-separated and in the correct sorted order
|
||||
const clipboardCategories = lines.slice(1).map((line) => {
|
||||
const cells = line.split("\t");
|
||||
// Category is the second column
|
||||
return cells[1];
|
||||
});
|
||||
|
||||
// The categories in clipboard should match the visual order
|
||||
for (let i = 0; i < clipboardCategories.length; i++) {
|
||||
expect(clipboardCategories[i]).to.equal(visualCategories[i]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 63416", () => {
|
||||
const questionDetails = {
|
||||
name: "63416 Question",
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("scenarios > dashboard > dashboard back navigation", () => {
|
||||
cy.findByTestId("native-query-top-bar")
|
||||
.findByText("This question is written in SQL.")
|
||||
.should("be.visible");
|
||||
H.NativeEditor.get().should("not.be.visible");
|
||||
H.NativeEditor.get().should("not.exist");
|
||||
});
|
||||
|
||||
it("should display a back to the dashboard button in table x-ray dashboards", () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ interface Table {
|
||||
describe("bulk table operations", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
H.resetSnowplow();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
cy.intercept("POST", "/api/ee/data-studio/table/sync-schema").as(
|
||||
@@ -63,6 +64,9 @@ describe("bulk table operations", () => {
|
||||
cy.findByRole("heading", { name: /2 tables selected/ });
|
||||
|
||||
cy.findByRole("button", { name: /Sync settings/ }).click();
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_bulk_sync_settings_clicked",
|
||||
});
|
||||
cy.findByRole("button", { name: /Sync table schemas/ }).click();
|
||||
cy.findByRole("button", { name: /Sync triggered!/ }).should("be.visible");
|
||||
cy.get<number[]>("@tableIds").then((tableIds) => {
|
||||
@@ -73,6 +77,10 @@ describe("bulk table operations", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_table_schema_sync_started",
|
||||
result: "success",
|
||||
});
|
||||
|
||||
cy.findByRole("button", { name: /Re-scan tables/ }).click();
|
||||
cy.findByRole("button", { name: /Scan triggered!/ }).should("be.visible");
|
||||
@@ -85,6 +93,10 @@ describe("bulk table operations", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_table_fields_rescan_started",
|
||||
result: "success",
|
||||
});
|
||||
|
||||
cy.findByRole("button", { name: /Discard cached field values/ }).click();
|
||||
cy.findByRole("button", { name: /Discard triggered!/ }).should(
|
||||
@@ -98,6 +110,10 @@ describe("bulk table operations", () => {
|
||||
expect(response?.statusCode).to.eq(204);
|
||||
});
|
||||
});
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_table_field_values_discard_started",
|
||||
result: "success",
|
||||
});
|
||||
});
|
||||
|
||||
it(
|
||||
@@ -134,6 +150,9 @@ describe("bulk table operations", () => {
|
||||
cy.findByRole("button", { name: /Unpublish/ }).click();
|
||||
H.modal().findByText("Unpublish these tables").click();
|
||||
cy.wait("@unpublishTables");
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_table_unpublished",
|
||||
});
|
||||
H.DataStudio.nav().findByLabelText("Library").click();
|
||||
|
||||
H.DataStudio.Library.libraryPage().within(() => {
|
||||
@@ -155,15 +174,35 @@ describe("bulk table operations", () => {
|
||||
|
||||
H.selectHasValue("Owner", "").click();
|
||||
H.selectDropdown().contains("Bobby Tables").click();
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_bulk_attribute_updated",
|
||||
event_detail: "owner",
|
||||
result: "success",
|
||||
});
|
||||
|
||||
H.selectHasValue("Visibility type", "").click();
|
||||
H.selectDropdown().contains("Gold").click();
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_bulk_attribute_updated",
|
||||
event_detail: "layer",
|
||||
result: "success",
|
||||
});
|
||||
|
||||
H.selectHasValue("Entity type", "").click();
|
||||
H.selectDropdown().contains("Person").click();
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_bulk_attribute_updated",
|
||||
event_detail: "entity_type",
|
||||
result: "success",
|
||||
});
|
||||
|
||||
H.selectHasValue("Source", "").click();
|
||||
H.selectDropdown().contains("Ingested").click();
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_bulk_attribute_updated",
|
||||
event_detail: "data_source",
|
||||
result: "success",
|
||||
});
|
||||
H.undoToastList().should("have.length", 4);
|
||||
TablePicker.getTable("Orders")
|
||||
.findByTestId("table-owner")
|
||||
|
||||
@@ -7,6 +7,7 @@ describe("Search", () => {
|
||||
beforeEach(() => {
|
||||
cy.signInAsAdmin();
|
||||
H.restore("postgres-writable");
|
||||
H.resetSnowplow();
|
||||
H.activateToken("bleeding-edge");
|
||||
H.resetTestTable({ type: "postgres", table: "multi_schema" });
|
||||
H.resyncDatabase({ dbId: WRITABLE_DB_ID });
|
||||
@@ -19,6 +20,9 @@ describe("Search", () => {
|
||||
TablePicker.getTables().should("have.length", 3);
|
||||
TablePicker.getTable("Analytic Events").should("be.visible");
|
||||
TablePicker.getTable("Animals").should("be.visible");
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_table_picker_search_performed",
|
||||
});
|
||||
});
|
||||
|
||||
it("should support wildcard search with *", () => {
|
||||
@@ -26,10 +30,19 @@ describe("Search", () => {
|
||||
|
||||
TablePicker.getSearchInput().type("irds");
|
||||
TablePicker.get().findByText("No tables found").should("be.visible");
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_table_picker_search_performed",
|
||||
});
|
||||
|
||||
TablePicker.getSearchInput().clear().type("*irds");
|
||||
TablePicker.getTables().should("have.length", 1);
|
||||
TablePicker.getTable("Birds").should("be.visible");
|
||||
H.expectUnstructuredSnowplowEvent(
|
||||
{
|
||||
event: "data_studio_table_picker_search_performed",
|
||||
},
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow using shift key to select multiple tables", () => {
|
||||
@@ -105,6 +118,8 @@ describe("Search", () => {
|
||||
it("should select/deselect databases and schemas", () => {
|
||||
H.DataModel.visitDataStudio();
|
||||
TablePicker.getSearchInput().type("a");
|
||||
// wait for the tables to be loaded
|
||||
TablePicker.getTables().should("have.length", 4);
|
||||
const postgres = "Writable Postgres12";
|
||||
const sampleDatabaseName = "Sample Database";
|
||||
const domesticSchema = "Domestic";
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import {
|
||||
SAMPLE_DB_ID,
|
||||
SAMPLE_DB_SCHEMA_ID,
|
||||
USER_GROUPS,
|
||||
WRITABLE_DB_ID,
|
||||
} from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import { DataModel } from "e2e/support/helpers";
|
||||
import {
|
||||
NODATA_USER_ID,
|
||||
ORDERS_QUESTION_ID,
|
||||
} from "e2e/support/cypress_sample_instance_data";
|
||||
import type { TableId } from "metabase-types/api";
|
||||
|
||||
const { H } = cy;
|
||||
const { TablePicker, TableSection, FieldSection, PreviewSection } = H.DataModel;
|
||||
|
||||
const { ALL_USERS_GROUP } = USER_GROUPS;
|
||||
const {
|
||||
FEEDBACK,
|
||||
FEEDBACK_ID,
|
||||
ORDERS,
|
||||
ORDERS_ID,
|
||||
PEOPLE_ID,
|
||||
PRODUCTS,
|
||||
PRODUCTS_ID,
|
||||
REVIEWS,
|
||||
@@ -27,12 +26,10 @@ const {
|
||||
const MYSQL_DB_ID = SAMPLE_DB_ID + 1;
|
||||
const MYSQL_DB_SCHEMA_ID = `${MYSQL_DB_ID}:`;
|
||||
|
||||
const CUSTOM_MAPPING_ERROR =
|
||||
"You need unrestricted data access on this table to map custom display values.";
|
||||
|
||||
describe("scenarios > data studio > datamodel", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
H.resetSnowplow();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
|
||||
@@ -574,6 +571,12 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
openFilterPopover();
|
||||
selectFilterOption("Visibility type", "Gold");
|
||||
applyFilters();
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_table_picker_filters_applied",
|
||||
});
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_table_picker_search_performed",
|
||||
});
|
||||
|
||||
cy.get<TableId>("@goldTableId").then(expectTableVisible);
|
||||
cy.get<TableId>("@silverTableId").then(expectTableNotVisible);
|
||||
@@ -1064,31 +1067,6 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
cy.findByText("New description").should("be.visible");
|
||||
});
|
||||
|
||||
it("should allow changing the table name with data model permissions only", () => {
|
||||
setDataModelPermissions({ tableIds: [ORDERS_ID] });
|
||||
|
||||
cy.signIn("none");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: ORDERS_ID,
|
||||
});
|
||||
|
||||
TableSection.getNameInput().clear().type("New orders").blur();
|
||||
cy.wait("@updateTable");
|
||||
verifyAndCloseToast("Table name updated");
|
||||
TableSection.getNameInput().should("have.value", "New orders");
|
||||
cy.signOut();
|
||||
|
||||
cy.signInAsNormalUser();
|
||||
H.startNewQuestion();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("People").should("be.visible");
|
||||
cy.findByText("New orders").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow clearing the table description", () => {
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
@@ -1107,6 +1085,76 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("No description yet").should("be.visible");
|
||||
});
|
||||
|
||||
it("should allow analysts to edit all table metadata even without data access", () => {
|
||||
H.setUserAsAnalyst(NODATA_USER_ID);
|
||||
|
||||
cy.signIn("nodata");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: ORDERS_ID,
|
||||
});
|
||||
|
||||
cy.log("change table name");
|
||||
TableSection.getNameInput().clear().type("Analyst Orders").blur();
|
||||
cy.wait("@updateTable");
|
||||
verifyAndCloseToast("Table name updated");
|
||||
TableSection.getNameInput().should("have.value", "Analyst Orders");
|
||||
|
||||
cy.log("change table description");
|
||||
TableSection.getDescriptionInput()
|
||||
.clear()
|
||||
.type("Description by analyst")
|
||||
.blur();
|
||||
cy.wait("@updateTable");
|
||||
verifyAndCloseToast("Table description updated");
|
||||
TableSection.getDescriptionInput().should(
|
||||
"have.value",
|
||||
"Description by analyst",
|
||||
);
|
||||
|
||||
cy.log("change field name");
|
||||
TableSection.getFieldNameInput("Tax")
|
||||
.clear()
|
||||
.type("Analyst Tax")
|
||||
.blur();
|
||||
cy.wait("@updateField");
|
||||
verifyAndCloseToast("Name of Tax updated");
|
||||
TableSection.getFieldNameInput("Analyst Tax").should("be.visible");
|
||||
|
||||
cy.log("change field description");
|
||||
TableSection.getFieldDescriptionInput("Total")
|
||||
.clear()
|
||||
.type("Total edited by analyst")
|
||||
.blur();
|
||||
cy.wait("@updateField");
|
||||
verifyAndCloseToast("Description of Total updated");
|
||||
|
||||
cy.log("verify changes in data reference as admin");
|
||||
cy.signInAsAdmin();
|
||||
cy.visit(`/reference/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}`);
|
||||
cy.get("main").within(() => {
|
||||
cy.findByText("Analyst Orders").should("be.visible");
|
||||
cy.findByText("Description by analyst").should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("verify changes in question picker as normal user");
|
||||
cy.signInAsNormalUser();
|
||||
H.startNewQuestion();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("People").should("be.visible");
|
||||
cy.findByText("Analyst Orders").should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("verify field changes in table visualization");
|
||||
H.openOrdersTable();
|
||||
H.tableHeaderColumn("Analyst Tax").should("be.visible");
|
||||
H.tableHeaderColumn("Tax", { scrollIntoView: false }).should(
|
||||
"not.exist",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field name and description", () => {
|
||||
@@ -1139,43 +1187,6 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow changing the field name with data model permissions only", () => {
|
||||
setDataModelPermissions({ tableIds: [ORDERS_ID] });
|
||||
cy.signIn("none");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: ORDERS_ID,
|
||||
});
|
||||
|
||||
TableSection.getFieldNameInput("Tax").clear().type("New tax").blur();
|
||||
cy.wait("@updateField");
|
||||
verifyAndCloseToast("Name of Tax updated");
|
||||
TableSection.getFieldNameInput("New tax").should("be.visible");
|
||||
TableSection.getField("New tax").should("be.visible");
|
||||
|
||||
cy.log("verify preview");
|
||||
TableSection.clickField("New tax");
|
||||
FieldSection.getPreviewButton().click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify viz as normal user");
|
||||
cy.signInAsNormalUser();
|
||||
H.openOrdersTable();
|
||||
H.tableHeaderColumn("New tax").should("be.visible");
|
||||
H.tableHeaderColumn("Tax", { scrollIntoView: false }).should(
|
||||
"not.exist",
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow changing the field description", () => {
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
@@ -1242,6 +1253,83 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("No description yet").should("be.visible");
|
||||
});
|
||||
|
||||
it("should allow analysts to edit field metadata but not preview data without data access", () => {
|
||||
H.setUserAsAnalyst(NODATA_USER_ID);
|
||||
cy.signIn("nodata");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: ORDERS_ID,
|
||||
});
|
||||
|
||||
cy.log("change field name from table section");
|
||||
TableSection.getFieldNameInput("Tax")
|
||||
.clear()
|
||||
.type("Analyst Tax Field")
|
||||
.blur();
|
||||
cy.wait("@updateField");
|
||||
verifyAndCloseToast("Name of Tax updated");
|
||||
TableSection.getFieldNameInput("Analyst Tax Field").should(
|
||||
"be.visible",
|
||||
);
|
||||
TableSection.getField("Analyst Tax Field").should("be.visible");
|
||||
|
||||
cy.log("change field description from table section");
|
||||
TableSection.getFieldDescriptionInput("Total")
|
||||
.clear()
|
||||
.type("Analyst total description")
|
||||
.blur();
|
||||
cy.wait("@updateField");
|
||||
verifyAndCloseToast("Description of Total updated");
|
||||
TableSection.getFieldDescriptionInput("Total").should(
|
||||
"have.value",
|
||||
"Analyst total description",
|
||||
);
|
||||
|
||||
cy.log("navigate to field detail and change semantic type");
|
||||
TableSection.clickField("Discount");
|
||||
FieldSection.getSemanticTypeInput()
|
||||
.should("have.value", "Discount")
|
||||
.click();
|
||||
H.popover().findByText("Currency").click();
|
||||
cy.wait("@updateField");
|
||||
verifyAndCloseToast("Semantic type of Discount updated");
|
||||
FieldSection.getSemanticTypeInput().should("have.value", "Currency");
|
||||
|
||||
cy.log("verify table preview is blocked without data permissions");
|
||||
FieldSection.getPreviewButton().click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify detail preview is also blocked");
|
||||
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify field changes in data reference as admin");
|
||||
cy.signInAsAdmin();
|
||||
cy.visit(
|
||||
`/reference/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/fields/${ORDERS.TOTAL}`,
|
||||
);
|
||||
cy.get("main").within(() => {
|
||||
cy.findByText("Total").should("be.visible");
|
||||
cy.findByText("Analyst total description").should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("verify field changes in table visualization as normal user");
|
||||
cy.signInAsNormalUser();
|
||||
H.openOrdersTable();
|
||||
H.tableHeaderColumn("Analyst Tax Field").should("be.visible");
|
||||
H.tableHeaderColumn("Tax", { scrollIntoView: false }).should(
|
||||
"not.exist",
|
||||
);
|
||||
H.tableHeaderColumn("Discount ($)").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sorting", () => {
|
||||
@@ -1534,46 +1622,6 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow changing the field name with data model permissions only", () => {
|
||||
setDataModelPermissions({ tableIds: [ORDERS_ID] });
|
||||
cy.signIn("none");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: ORDERS_ID,
|
||||
fieldId: ORDERS.TOTAL,
|
||||
});
|
||||
|
||||
FieldSection.getNameInput().clear().type("New total").blur();
|
||||
cy.wait("@updateField");
|
||||
verifyAndCloseToast("Name of Total updated");
|
||||
FieldSection.getNameInput().should("have.value", "New total");
|
||||
TableSection.getFieldNameInput("New total")
|
||||
.scrollIntoView()
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify preview");
|
||||
TableSection.clickField("New total");
|
||||
FieldSection.getPreviewButton().click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify viz as normal user");
|
||||
cy.signInAsNormalUser();
|
||||
H.openOrdersTable();
|
||||
H.tableHeaderColumn("New total").should("be.visible");
|
||||
H.tableHeaderColumn("Total", { scrollIntoView: false }).should(
|
||||
"not.exist",
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow changing the field description", () => {
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
@@ -1981,84 +2029,6 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
cy.findByLabelText("Left column").should("contain.text", "User ID");
|
||||
});
|
||||
|
||||
it("should allow to change the field foreign key target with no permissions to Reviews table", () => {
|
||||
setDataModelPermissions({
|
||||
tableIds: [ORDERS_ID, PRODUCTS_ID, PEOPLE_ID],
|
||||
});
|
||||
|
||||
cy.signIn("none");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: ORDERS_ID,
|
||||
fieldId: ORDERS.USER_ID,
|
||||
});
|
||||
FieldSection.getSemanticTypeFkTarget()
|
||||
.should("have.value", "People → ID")
|
||||
.click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Reviews → ID").should("not.exist");
|
||||
cy.findByText("Products → ID").click();
|
||||
});
|
||||
cy.wait("@updateField");
|
||||
H.undoToast().should(
|
||||
"contain.text",
|
||||
"Semantic type of User ID updated",
|
||||
);
|
||||
FieldSection.getSemanticTypeFkTarget().should(
|
||||
"have.value",
|
||||
"Products → ID",
|
||||
);
|
||||
|
||||
cy.log("verify preview");
|
||||
FieldSection.getPreviewButton().click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify viz as normal user");
|
||||
cy.signInAsNormalUser();
|
||||
H.openTable({
|
||||
database: SAMPLE_DB_ID,
|
||||
table: ORDERS_ID,
|
||||
mode: "notebook",
|
||||
});
|
||||
cy.icon("join_left_outer").click();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Products").click();
|
||||
});
|
||||
cy.findByLabelText("Left column").should("contain.text", "User ID");
|
||||
});
|
||||
|
||||
it("should not allow setting foreign key target for inaccessible tables", () => {
|
||||
setDataModelPermissions({ tableIds: [REVIEWS_ID] });
|
||||
|
||||
cy.signIn("none");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: REVIEWS_ID,
|
||||
fieldId: REVIEWS.PRODUCT_ID,
|
||||
});
|
||||
FieldSection.getDisplayValuesInput().click();
|
||||
|
||||
H.popover().within(() => {
|
||||
cy.findByRole("option", { name: /Use original value/ })
|
||||
.should("be.visible")
|
||||
.and("not.have.attr", "data-combobox-disabled");
|
||||
cy.findByRole("option", { name: /Use foreign key/ })
|
||||
.should("be.visible")
|
||||
.and("have.attr", "data-combobox-disabled", "true");
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow to change the type to 'Currency' and choose the currency (metabase#59052)", () => {
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
@@ -2238,6 +2208,56 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow analysts to change the foreign key target without data access", () => {
|
||||
H.setUserAsAnalyst(NODATA_USER_ID);
|
||||
cy.signIn("nodata");
|
||||
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: ORDERS_ID,
|
||||
fieldId: ORDERS.USER_ID,
|
||||
});
|
||||
|
||||
FieldSection.getSemanticTypeFkTarget()
|
||||
.should("have.value", "People → ID")
|
||||
.click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Reviews → ID").should("be.visible");
|
||||
cy.findByText("Products → ID").click();
|
||||
});
|
||||
cy.wait("@updateField");
|
||||
H.undoToast().should(
|
||||
"contain.text",
|
||||
"Semantic type of User ID updated",
|
||||
);
|
||||
FieldSection.getSemanticTypeFkTarget().should(
|
||||
"have.value",
|
||||
"Products → ID",
|
||||
);
|
||||
|
||||
cy.log("verify preview is blocked without data permissions");
|
||||
FieldSection.getPreviewButton().click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify FK target change works in query builder as normal user");
|
||||
cy.signInAsNormalUser();
|
||||
H.openTable({
|
||||
database: SAMPLE_DB_ID,
|
||||
table: ORDERS_ID,
|
||||
mode: "notebook",
|
||||
});
|
||||
cy.icon("join_left_outer").click();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Products").click();
|
||||
});
|
||||
cy.findByLabelText("Left column").should("contain.text", "User ID");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Behavior", () => {
|
||||
@@ -2653,34 +2673,6 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
.and("have.value", "Title");
|
||||
});
|
||||
|
||||
it("should allow to change foreign key target for accessible tables", () => {
|
||||
setDataModelPermissions({
|
||||
tableIds: [ORDERS_ID, REVIEWS_ID, PRODUCTS_ID],
|
||||
});
|
||||
|
||||
cy.signIn("none");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: REVIEWS_ID,
|
||||
fieldId: REVIEWS.PRODUCT_ID,
|
||||
});
|
||||
|
||||
FieldSection.getDisplayValuesInput().click();
|
||||
H.popover().findByText("Use foreign key").click();
|
||||
H.popover().findByText("Title").click();
|
||||
cy.wait("@updateFieldDimension");
|
||||
H.undoToast().should(
|
||||
"contain.text",
|
||||
"Display values of Product ID updated",
|
||||
);
|
||||
|
||||
cy.signInAsNormalUser();
|
||||
H.openReviewsTable({ limit: 1 });
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Rustic Paper Wallet").should("be.visible");
|
||||
});
|
||||
|
||||
it("should allow 'Custom mapping' null values", () => {
|
||||
const remappedNullValue = "nothin";
|
||||
|
||||
@@ -2868,54 +2860,6 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should show a proper error message when using custom mapping", () => {
|
||||
setDataModelPermissions({ tableIds: [REVIEWS_ID] });
|
||||
|
||||
cy.signIn("none");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: REVIEWS_ID,
|
||||
fieldId: REVIEWS.RATING,
|
||||
});
|
||||
FieldSection.getDisplayValuesInput().click();
|
||||
|
||||
H.popover().within(() => {
|
||||
cy.findByRole("option", { name: /Use original value/ })
|
||||
.should("be.visible")
|
||||
.and("not.have.attr", "data-combobox-disabled");
|
||||
cy.findByRole("option", { name: /Custom mapping/ })
|
||||
.should("be.visible")
|
||||
.and("have.attr", "data-combobox-disabled", "true");
|
||||
});
|
||||
|
||||
cy.signInAsAdmin();
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: REVIEWS_ID,
|
||||
fieldId: REVIEWS.RATING,
|
||||
});
|
||||
FieldSection.getDisplayValuesInput().click();
|
||||
H.popover().findByText("Custom mapping").click();
|
||||
cy.wait("@updateFieldDimension");
|
||||
H.undoToast().should(
|
||||
"contain.text",
|
||||
"Display values of Rating updated",
|
||||
);
|
||||
|
||||
cy.signIn("none");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: REVIEWS_ID,
|
||||
fieldId: REVIEWS.RATING,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText(CUSTOM_MAPPING_ERROR).should("exist");
|
||||
});
|
||||
|
||||
it("should allow 'Custom mapping' option only for 'Search box' filtering type (metabase#16322)", () => {
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
@@ -3009,6 +2953,105 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
.eq(10) // 1st data row, 2nd column (User ID)
|
||||
.should("have.text", "2023-10-07T01:34:35.462-07:00");
|
||||
});
|
||||
|
||||
it("should allow analysts to change display values to use foreign key without data access", () => {
|
||||
H.setUserAsAnalyst(NODATA_USER_ID);
|
||||
cy.signIn("nodata");
|
||||
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: REVIEWS_ID,
|
||||
fieldId: REVIEWS.PRODUCT_ID,
|
||||
});
|
||||
|
||||
FieldSection.getDisplayValuesInput().click();
|
||||
H.popover().findByText("Use foreign key").click();
|
||||
H.popover().findByText("Title").click();
|
||||
cy.wait("@updateFieldDimension");
|
||||
H.undoToast().should(
|
||||
"contain.text",
|
||||
"Display values of Product ID updated",
|
||||
);
|
||||
|
||||
FieldSection.getDisplayValuesInput().should(
|
||||
"have.value",
|
||||
"Use foreign key",
|
||||
);
|
||||
FieldSection.getDisplayValuesFkTargetInput().should(
|
||||
"have.value",
|
||||
"Title",
|
||||
);
|
||||
|
||||
cy.log("verify preview is blocked without data permissions");
|
||||
FieldSection.getPreviewButton().click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify display value change works as normal user");
|
||||
cy.signInAsNormalUser();
|
||||
H.openReviewsTable({ limit: 1 });
|
||||
H.main().findByText("Rustic Paper Wallet").should("be.visible");
|
||||
});
|
||||
|
||||
it("should disable custom mapping for analysts without data access", () => {
|
||||
H.setUserAsAnalyst(NODATA_USER_ID);
|
||||
cy.signIn("nodata");
|
||||
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: REVIEWS_ID,
|
||||
fieldId: REVIEWS.RATING,
|
||||
});
|
||||
|
||||
cy.log("verify custom mapping is disabled without data access");
|
||||
FieldSection.getDisplayValuesInput().click();
|
||||
H.popover().within(() => {
|
||||
cy.findByRole("option", { name: /Use original value/ })
|
||||
.should("be.visible")
|
||||
.and("not.have.attr", "data-combobox-disabled");
|
||||
cy.findByRole("option", { name: /Custom mapping/ })
|
||||
.should("be.visible")
|
||||
.and("have.attr", "data-combobox-disabled", "true");
|
||||
});
|
||||
|
||||
cy.log("verify admin can set up custom mapping");
|
||||
cy.signInAsAdmin();
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: REVIEWS_ID,
|
||||
fieldId: REVIEWS.RATING,
|
||||
});
|
||||
FieldSection.getDisplayValuesInput().click();
|
||||
H.popover().findByText("Custom mapping").click();
|
||||
cy.wait("@updateFieldValues");
|
||||
H.undoToast().should(
|
||||
"contain.text",
|
||||
"Display values of Rating updated",
|
||||
);
|
||||
H.undoToast().icon("close").click({ force: true });
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByDisplayValue("1").click().clear().type("Terrible");
|
||||
cy.findByDisplayValue("5").click().clear().type("Amazing");
|
||||
cy.button("Save").click();
|
||||
});
|
||||
cy.wait("@updateFieldValues");
|
||||
H.undoToast().should(
|
||||
"contain.text",
|
||||
"Display values of Rating updated",
|
||||
);
|
||||
|
||||
cy.log("verify custom mapping works as normal user");
|
||||
cy.signInAsNormalUser();
|
||||
H.openReviewsTable();
|
||||
H.main().findByText("Terrible").should("be.visible");
|
||||
H.main().findAllByText("Amazing").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unfold JSON", { tags: "@external" }, () => {
|
||||
@@ -3480,7 +3523,7 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
fieldId: ORDERS.PRODUCT_ID,
|
||||
});
|
||||
|
||||
DataModel.TableSection.getVisibilityTypeInput().click();
|
||||
H.DataModel.TableSection.getVisibilityTypeInput().click();
|
||||
H.popover().findByText("Copper").click();
|
||||
cy.wait("@updateTable");
|
||||
|
||||
@@ -3868,27 +3911,6 @@ function clickAway() {
|
||||
cy.get("body").click(0, 0);
|
||||
}
|
||||
|
||||
const setDataModelPermissions = ({
|
||||
tableIds = [],
|
||||
}: {
|
||||
tableIds: TableId[];
|
||||
}) => {
|
||||
const permissions = Object.fromEntries(tableIds.map((id) => [id, "all"]));
|
||||
|
||||
// @ts-expect-error invalid cy.updatePermissionsGraph typing
|
||||
cy.updatePermissionsGraph({
|
||||
[ALL_USERS_GROUP]: {
|
||||
[SAMPLE_DB_ID]: {
|
||||
"data-model": {
|
||||
schemas: {
|
||||
PUBLIC: permissions,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type TableSummary = {
|
||||
id: TableId;
|
||||
db_id: number;
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import {
|
||||
SAMPLE_DB_ID,
|
||||
SAMPLE_DB_SCHEMA_ID,
|
||||
USER_GROUPS,
|
||||
} from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DB_ID, SAMPLE_DB_SCHEMA_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { NODATA_USER_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
|
||||
const { H } = cy;
|
||||
const { SegmentList, SegmentEditor, SegmentRevisionHistory } = H.DataModel;
|
||||
const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID, PEOPLE, PEOPLE_ID } =
|
||||
SAMPLE_DATABASE;
|
||||
const { ALL_USERS_GROUP } = USER_GROUPS;
|
||||
|
||||
describe(
|
||||
"scenarios > data studio > data model > segments",
|
||||
@@ -614,12 +610,12 @@ describe(
|
||||
});
|
||||
});
|
||||
|
||||
describe("Readonly access with data model permissions", () => {
|
||||
describe("Readonly access for data analysts", () => {
|
||||
it("should show segments in list but hide New segment button for non-admin", () => {
|
||||
createTestSegment({ name: "Readonly Test Segment" });
|
||||
|
||||
setDataModelPermissions({ tableIds: [ORDERS_ID] });
|
||||
cy.signIn("none");
|
||||
H.setUserAsAnalyst(NODATA_USER_ID);
|
||||
cy.signIn("nodata");
|
||||
|
||||
cy.log("verify segment is visible in list");
|
||||
visitDataStudioSegments(ORDERS_ID);
|
||||
@@ -636,9 +632,7 @@ describe(
|
||||
cy.visit(
|
||||
`/data-studio/data/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}/segments/new`,
|
||||
);
|
||||
H.main()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
cy.url().should("include", "/unauthorized");
|
||||
});
|
||||
|
||||
it("should display segment detail in readonly mode for non-admin", () => {
|
||||
@@ -648,8 +642,8 @@ describe(
|
||||
});
|
||||
|
||||
cy.get<number>("@segmentId").then((segmentId) => {
|
||||
setDataModelPermissions({ tableIds: [ORDERS_ID] });
|
||||
cy.signIn("none");
|
||||
H.setUserAsAnalyst(NODATA_USER_ID);
|
||||
cy.signIn("nodata");
|
||||
|
||||
visitDataModelSegment(ORDERS_ID, segmentId);
|
||||
|
||||
@@ -766,20 +760,3 @@ function verifySegmentNotInQueryBuilder(
|
||||
H.getNotebookStep("data").button("Filter").click();
|
||||
H.popover().findByText(segmentName).should("not.exist");
|
||||
}
|
||||
|
||||
function setDataModelPermissions({ tableIds = [] }: { tableIds: number[] }) {
|
||||
const permissions = Object.fromEntries(tableIds.map((id) => [id, "all"]));
|
||||
|
||||
// @ts-expect-error invalid cy.updatePermissionsGraph typing
|
||||
cy.updatePermissionsGraph({
|
||||
[ALL_USERS_GROUP]: {
|
||||
[SAMPLE_DB_ID]: {
|
||||
"data-model": {
|
||||
schemas: {
|
||||
PUBLIC: permissions,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,14 +22,9 @@ describe("scenarios > data studio > library", () => {
|
||||
H.DataModel.visitDataStudio();
|
||||
H.DataStudio.nav().findByLabelText("Library").click();
|
||||
|
||||
cy.log("Closing the modal should send you back");
|
||||
H.modal().button("Cancel").click();
|
||||
H.DataModel.get().should("exist");
|
||||
|
||||
cy.log("Create library via modal");
|
||||
H.DataStudio.nav().findByLabelText("Library").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Create your Library").should("be.visible");
|
||||
cy.log("Create library via inline empty state");
|
||||
H.DataStudio.Library.libraryPage().within(() => {
|
||||
cy.findByText("A source of truth for analytics").should("be.visible");
|
||||
cy.findByText("Create my Library").click();
|
||||
});
|
||||
|
||||
@@ -111,7 +106,7 @@ describe("scenarios > data studio > library", () => {
|
||||
|
||||
cy.log("Publish a table from the 'New' menu");
|
||||
H.DataStudio.Library.newButton().click();
|
||||
H.popover().findByText("Publish a table").click();
|
||||
H.popover().findByText("Published table").click();
|
||||
|
||||
cy.log("Select a table and click 'Publish'");
|
||||
H.entityPickerModalItem(3, "Orders").click();
|
||||
@@ -127,7 +122,7 @@ describe("scenarios > data studio > library", () => {
|
||||
"Verify tables in the entity picker are disabled if already published",
|
||||
);
|
||||
H.DataStudio.Library.newButton().click();
|
||||
H.popover().findByText("Publish a table").click();
|
||||
H.popover().findByText("Published table").click();
|
||||
H.entityPickerModalItem(3, "Orders").should("have.attr", "data-disabled");
|
||||
H.entityPickerModalItem(3, "People").should(
|
||||
"not.have.attr",
|
||||
@@ -198,7 +193,7 @@ describe("scenarios > data studio > library", () => {
|
||||
|
||||
cy.log("Publish a table via the +New menu");
|
||||
H.DataStudio.Library.newButton().click();
|
||||
H.popover().findByText("Publish a table").click();
|
||||
H.popover().findByText("Published table").click();
|
||||
H.entityPickerModalItem(3, "Orders").click();
|
||||
H.entityPickerModal().button("Publish").click();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SAMPLE_DB_ID, SAMPLE_DB_SCHEMA_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { NODATA_USER_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
|
||||
const { H } = cy;
|
||||
const { MeasureList, MeasureEditor, MeasureRevisionHistory } = H.DataModel;
|
||||
@@ -389,6 +390,76 @@ describe("scenarios > data studio > data model > measures", () => {
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Readonly access for data analysts", () => {
|
||||
it("should show measures in list but hide New measure button for non-admin", () => {
|
||||
createTestMeasure({ name: "Readonly Test Measure" });
|
||||
|
||||
H.setUserAsAnalyst(NODATA_USER_ID);
|
||||
cy.signIn("nodata");
|
||||
|
||||
cy.log("verify measure is visible in list");
|
||||
visitDataStudioMeasures(ORDERS_ID);
|
||||
MeasureList.getMeasure("Readonly Test Measure")
|
||||
.scrollIntoView()
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify New measure button is not visible");
|
||||
MeasureList.get()
|
||||
.findByRole("link", { name: /New measure/i })
|
||||
.should("not.exist");
|
||||
|
||||
cy.log("verify direct navigation to new measure page is blocked");
|
||||
cy.visit(
|
||||
`/data-studio/data/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}/measures/new`,
|
||||
);
|
||||
cy.url().should("include", "/unauthorized");
|
||||
});
|
||||
|
||||
it("should display measure detail in readonly mode for non-admin", () => {
|
||||
createTestMeasure({
|
||||
name: "Readonly Detail Measure",
|
||||
description: "Test description for readonly",
|
||||
});
|
||||
|
||||
cy.get<number>("@measureId").then((measureId) => {
|
||||
H.setUserAsAnalyst(NODATA_USER_ID);
|
||||
cy.signIn("nodata");
|
||||
|
||||
visitDataModelMeasure(ORDERS_ID, measureId);
|
||||
|
||||
cy.log("verify measure name input is disabled");
|
||||
MeasureEditor.get()
|
||||
.findByDisplayValue("Readonly Detail Measure")
|
||||
.should("be.disabled");
|
||||
|
||||
cy.log("verify description is displayed as plain text");
|
||||
MeasureEditor.get().findByText("Description").should("be.visible");
|
||||
MeasureEditor.get()
|
||||
.findByText("Test description for readonly")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify Save button is not visible");
|
||||
MeasureEditor.get()
|
||||
.findByRole("button", { name: /Save/i })
|
||||
.should("not.exist");
|
||||
|
||||
cy.log("verify Remove measure option is hidden in actions menu");
|
||||
MeasureEditor.getActionsButton().click();
|
||||
H.popover().findByText("Preview").should("be.visible");
|
||||
H.popover().findByText("Remove measure").should("not.exist");
|
||||
cy.realPress("Escape");
|
||||
|
||||
cy.log("verify revision history is still accessible");
|
||||
MeasureEditor.getRevisionHistoryTab().click();
|
||||
MeasureRevisionHistory.get().within(() => {
|
||||
cy.findByText(/created this measure/i)
|
||||
.scrollIntoView()
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function visitDataStudioTable(tableId: number) {
|
||||
|
||||
@@ -19,15 +19,15 @@ describe("scenarios > data studio > snippets", () => {
|
||||
H.DataStudio.Library.visit();
|
||||
|
||||
H.DataStudio.Library.newButton().click();
|
||||
H.popover().findByText("New snippet").click();
|
||||
H.popover().findByText("Snippet").click();
|
||||
|
||||
H.DataStudio.Snippets.newPage().should("be.visible");
|
||||
H.DataStudio.Snippets.saveButton().should("be.disabled");
|
||||
|
||||
H.DataStudio.Snippets.editor.type("SELECT * FROM orders");
|
||||
H.DataStudio.Snippets.saveButton().should("be.disabled");
|
||||
H.DataStudio.Snippets.saveButton().should("be.enabled");
|
||||
|
||||
H.DataStudio.Snippets.nameInput().type("Test snippet");
|
||||
H.DataStudio.Snippets.nameInput().clear().type("Test snippet");
|
||||
H.DataStudio.Snippets.saveButton().should("be.enabled");
|
||||
|
||||
H.DataStudio.Snippets.descriptionInput().type(
|
||||
@@ -121,6 +121,42 @@ describe("scenarios > data studio > snippets", () => {
|
||||
|
||||
H.DataStudio.Snippets.editPage().should("be.visible");
|
||||
});
|
||||
|
||||
it("should preserve unsaved content changes when description or name is edited", () => {
|
||||
cy.log("Navigate to a snippet and edit its content");
|
||||
H.createSnippet({
|
||||
name: "Test snippet",
|
||||
content: "SELECT * FROM orders",
|
||||
});
|
||||
H.DataStudio.Library.visit();
|
||||
H.DataStudio.Library.libraryPage().findByText("Test snippet").click();
|
||||
H.DataStudio.Snippets.editor.type("1");
|
||||
|
||||
cy.log("Edit its name");
|
||||
cy.findByPlaceholderText("Name").type("1").blur();
|
||||
H.undoToast().findByText("Snippet name updated").should("be.visible");
|
||||
H.undoToast().icon("close").click();
|
||||
|
||||
cy.log("Edit its description");
|
||||
H.DataStudio.Snippets.descriptionInput().type("desc").blur();
|
||||
H.undoToast()
|
||||
.findByText("Snippet description updated")
|
||||
.should("be.visible");
|
||||
H.undoToast().icon("close").click();
|
||||
|
||||
cy.log("Verify unsaved changes are preserved");
|
||||
H.DataStudio.Snippets.editor
|
||||
.value()
|
||||
.should("eq", "SELECT * FROM orders1");
|
||||
|
||||
cy.log(
|
||||
"Verify Save button saves the content without reverting the name and description changes",
|
||||
);
|
||||
H.DataStudio.Snippets.saveButton().click();
|
||||
H.undoToast().findByText("Snippet content updated").should("be.visible");
|
||||
cy.findByPlaceholderText("Name").should("have.value", "Test snippet1");
|
||||
H.DataStudio.Snippets.editPage().findByText("desc").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("description", () => {
|
||||
@@ -178,7 +214,7 @@ describe("scenarios > data studio > snippets", () => {
|
||||
H.DataStudio.Library.visit();
|
||||
|
||||
H.DataStudio.Library.newButton().click();
|
||||
H.popover().findByText("New snippet folder").click();
|
||||
H.popover().findByText("Snippet folder").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Give your folder a name").type("Test Folder");
|
||||
@@ -194,9 +230,9 @@ describe("scenarios > data studio > snippets", () => {
|
||||
.should("be.visible");
|
||||
|
||||
H.DataStudio.Library.newButton().click();
|
||||
H.popover().findByText("New snippet").click();
|
||||
H.popover().findByText("Snippet").click();
|
||||
|
||||
H.DataStudio.Snippets.nameInput().type("Folder snippet");
|
||||
H.DataStudio.Snippets.nameInput().clear().type("Folder snippet");
|
||||
H.DataStudio.Snippets.editor.type("SELECT 1");
|
||||
H.DataStudio.Snippets.saveButton().click();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CardType,
|
||||
CollectionId,
|
||||
PythonTransformTableAliases,
|
||||
TransformId,
|
||||
TransformSourceCheckpointStrategy,
|
||||
TransformTagId,
|
||||
} from "metabase-types/api";
|
||||
@@ -42,6 +43,9 @@ describe("scenarios > admin > transforms", () => {
|
||||
cy.intercept("POST", "/api/ee/transform-tag").as("createTag");
|
||||
cy.intercept("PUT", "/api/ee/transform-tag/*").as("updateTag");
|
||||
cy.intercept("DELETE", "/api/ee/transform-tag/*").as("deleteTag");
|
||||
cy.intercept("POST", "/api/ee/dependencies/check_transform").as(
|
||||
"checkTransformDependencies",
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -747,6 +751,49 @@ LIMIT
|
||||
});
|
||||
});
|
||||
|
||||
describe("ownership", () => {
|
||||
it("should be able to view and manage transform ownership", () => {
|
||||
createMbqlTransform({ visitTransform: true });
|
||||
H.DataStudio.Transforms.settingsTab().click();
|
||||
|
||||
cy.log("verify the ownership section is displayed");
|
||||
getTransformsTargetContent().within(() => {
|
||||
cy.findByText("Ownership").should("be.visible");
|
||||
cy.findByText("Specify who is responsible for this transform.").should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.findByText("Owner").should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("change the owner to another user");
|
||||
getTransformsTargetContent().within(() => {
|
||||
cy.findByLabelText("Owner").click();
|
||||
});
|
||||
H.popover().findByText("Robert Tableton").click();
|
||||
cy.wait("@updateTransform");
|
||||
H.undoToast().findByText("Transform owner updated").should("be.visible");
|
||||
H.undoToast().icon("close").click();
|
||||
|
||||
cy.log("set an external email as owner");
|
||||
getTransformsTargetContent().within(() => {
|
||||
cy.findByLabelText("Owner").click();
|
||||
cy.findByLabelText("Owner").clear().type("external@example.com");
|
||||
});
|
||||
H.popover().findByText("external@example.com").click();
|
||||
cy.wait("@updateTransform");
|
||||
H.undoToast().findByText("Transform owner updated").should("be.visible");
|
||||
H.undoToast().icon("close").click();
|
||||
|
||||
cy.log("clear the owner");
|
||||
getTransformsTargetContent().within(() => {
|
||||
cy.findByLabelText("Owner").click();
|
||||
});
|
||||
H.popover().findByText("No owner").click();
|
||||
cy.wait("@updateTransform");
|
||||
H.undoToast().findByText("Transform owner updated").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tags", () => {
|
||||
it("should be able to add and remove tags", () => {
|
||||
createMbqlTransform({ visitTransform: true });
|
||||
@@ -954,7 +1001,7 @@ LIMIT
|
||||
|
||||
// Make a second change while first is still in progress
|
||||
// Select any available checkpoint field
|
||||
getFieldPicker().should("be.visible");
|
||||
getFieldPicker().scrollIntoView().should("be.visible");
|
||||
getFieldPicker().click();
|
||||
|
||||
// Click the first available option in the popover
|
||||
@@ -989,7 +1036,7 @@ LIMIT
|
||||
// The source strategy select should be visible
|
||||
// (Currently only one option "checkpoint" is available, so select might not be shown)
|
||||
// The checkpoint field select should be visible
|
||||
getFieldPicker().should("be.visible");
|
||||
getFieldPicker().scrollIntoView().should("be.visible");
|
||||
|
||||
cy.log("Select a checkpoint field");
|
||||
getFieldPicker().click();
|
||||
@@ -1505,6 +1552,109 @@ LIMIT
|
||||
H.assertQueryBuilderRowCount(1);
|
||||
});
|
||||
|
||||
it("should be possible to continue editing a transform after closing check dependencies modal (metabase#68272)", () => {
|
||||
const transformTableName = "output_table";
|
||||
const dependentCardName = "Question depending on transform";
|
||||
|
||||
cy.log("create MBQL transform with name and score columns");
|
||||
H.getTableId({ name: SOURCE_TABLE, databaseId: WRITABLE_DB_ID }).then(
|
||||
(sourceTableId) => {
|
||||
H.getFieldId({ tableId: sourceTableId, name: "name" }).then(
|
||||
(nameFieldId) => {
|
||||
H.getFieldId({ tableId: sourceTableId, name: "score" }).then(
|
||||
(scoreFieldId) => {
|
||||
H.createTransform(
|
||||
{
|
||||
name: "MBQL transform with deps",
|
||||
source: {
|
||||
type: "query",
|
||||
query: {
|
||||
database: WRITABLE_DB_ID,
|
||||
type: "query",
|
||||
query: {
|
||||
"source-table": sourceTableId,
|
||||
fields: [
|
||||
["field", nameFieldId, null],
|
||||
["field", scoreFieldId, null],
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
target: {
|
||||
type: "table",
|
||||
database: WRITABLE_DB_ID,
|
||||
name: transformTableName,
|
||||
schema: TARGET_SCHEMA,
|
||||
},
|
||||
},
|
||||
{ wrapId: true },
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
cy.get<TransformId>("@transformId").then((transformId) => {
|
||||
cy.log("run the transform to create the output table");
|
||||
cy.request("POST", `/api/ee/transform/${transformId}/run`);
|
||||
H.waitForSucceededTransformRuns();
|
||||
H.resyncDatabase({
|
||||
dbId: WRITABLE_DB_ID,
|
||||
tableName: transformTableName,
|
||||
});
|
||||
|
||||
cy.log("create a question that depends on the score column");
|
||||
H.getTableId({
|
||||
databaseId: WRITABLE_DB_ID,
|
||||
name: transformTableName,
|
||||
}).then((tableId) => {
|
||||
H.createQuestion({
|
||||
name: dependentCardName,
|
||||
database: WRITABLE_DB_ID,
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
filter: [
|
||||
">",
|
||||
["field", "score", { "base-type": "type/Integer" }],
|
||||
10,
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
H.visitTransform(transformId);
|
||||
|
||||
cy.log("remove score column (breaking change)");
|
||||
H.DataStudio.Transforms.editDefinition().click();
|
||||
H.getNotebookStep("data").findByLabelText("Pick columns").click();
|
||||
H.popover().findByLabelText("Score").click();
|
||||
H.DataStudio.Transforms.saveChangesButton().click();
|
||||
|
||||
cy.wait("@checkTransformDependencies");
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByText(
|
||||
"These changes will break some other things. Save anyway?",
|
||||
).should("be.visible");
|
||||
cy.findByText(dependentCardName).should("be.visible");
|
||||
|
||||
cy.log("cancel to continue editing");
|
||||
cy.button("Cancel").click();
|
||||
});
|
||||
|
||||
H.DataStudio.Transforms.editDefinition().should("not.exist");
|
||||
H.DataStudio.Transforms.saveChangesButton()
|
||||
.should("be.visible")
|
||||
.and("be.enabled");
|
||||
|
||||
H.getNotebookStep("data").findByLabelText("Pick columns").click();
|
||||
H.popover().findByLabelText("Name").should("be.disabled");
|
||||
H.popover().findByLabelText("Score").should("not.be.checked");
|
||||
});
|
||||
});
|
||||
|
||||
it("should be able to update a Python query", { tags: ["@python"] }, () => {
|
||||
setPythonRunnerSettings();
|
||||
cy.log("create a new transform");
|
||||
@@ -2134,6 +2284,97 @@ LIMIT
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"should navigate to the common library when clicking 'common' in an import statement",
|
||||
{ tags: ["@python"] },
|
||||
() => {
|
||||
visitTransformListPage();
|
||||
cy.button("Create a transform").click();
|
||||
H.popover().findByText("Python script").click();
|
||||
cy.get(".cm-clickable-token").should("be.visible").click();
|
||||
H.modal().button("Discard changes").click();
|
||||
cy.url().should("include", "/data-studio/transforms/library/common.py");
|
||||
cy.findByTestId("python-library-header").should("be.visible");
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"should open the common library in a new tab when cmd-clicking 'common' in an import statement",
|
||||
{ tags: ["@python"] },
|
||||
() => {
|
||||
visitTransformListPage();
|
||||
cy.window().then((win) => {
|
||||
cy.stub(win, "open").as("windowOpen");
|
||||
});
|
||||
cy.button("Create a transform").click();
|
||||
H.popover().findByText("Python script").click();
|
||||
cy.get(".cm-clickable-token")
|
||||
.should("be.visible")
|
||||
.click({ metaKey: true });
|
||||
|
||||
cy.get("@windowOpen").should(
|
||||
"have.been.calledWithMatch",
|
||||
"/data-studio/transforms/library/common.py",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"should be able to run a transform with default import common even without custom library code",
|
||||
{ tags: ["@python"] },
|
||||
() => {
|
||||
setPythonRunnerSettings();
|
||||
|
||||
visitTransformListPage();
|
||||
cy.button("Create a transform").click();
|
||||
H.popover().findByText("Python script").click();
|
||||
|
||||
cy.log("import common should be included by default");
|
||||
H.PythonEditor.value().should("contain", "import common");
|
||||
|
||||
cy.log(
|
||||
"write a transform that imports common but does not use it - should still run",
|
||||
);
|
||||
H.PythonEditor.clear().type(
|
||||
dedent`
|
||||
import common
|
||||
import pandas as pd
|
||||
|
||||
def transform():
|
||||
return pd.DataFrame([{"result": 42}])
|
||||
`,
|
||||
{ allowFastSet: true },
|
||||
);
|
||||
|
||||
cy.findByTestId("python-data-picker")
|
||||
.findByText("Select a table…")
|
||||
.click();
|
||||
|
||||
H.entityPickerModal().within(() => {
|
||||
cy.findByText("Schema a").click();
|
||||
cy.findByText("Animals").click();
|
||||
});
|
||||
|
||||
getQueryEditor().button("Save").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name").clear().type("Default common transform");
|
||||
cy.findByLabelText("Table name").clear().type("default_common");
|
||||
cy.button("Save").click();
|
||||
});
|
||||
|
||||
H.DataStudio.Transforms.runTab().click();
|
||||
runTransformAndWaitForSuccess();
|
||||
H.DataStudio.Transforms.settingsTab().click();
|
||||
getTableLink().click();
|
||||
H.queryBuilderHeader()
|
||||
.findByText("Default Common")
|
||||
.should("be.visible");
|
||||
H.assertQueryBuilderRowCount(1);
|
||||
cy.findByTestId("scalar-value").should("have.text", "42");
|
||||
},
|
||||
);
|
||||
|
||||
function visitCommonLibrary(path = "common.py") {
|
||||
cy.visit(`/data-studio/transforms/library/${path}`);
|
||||
}
|
||||
@@ -2148,7 +2389,7 @@ LIMIT
|
||||
cy.log("create a collection from the transforms list");
|
||||
visitTransformListPage();
|
||||
cy.button("Create a transform").click();
|
||||
H.popover().findByText("New collection").click();
|
||||
H.popover().findByText("Transform folder").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name").type("Marketing Transforms");
|
||||
@@ -2161,7 +2402,7 @@ LIMIT
|
||||
|
||||
cy.log("create a nested collection");
|
||||
cy.button("Create a transform").click();
|
||||
H.popover().findByText("New collection").click();
|
||||
H.popover().findByText("Transform folder").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name").type("Q4 Reports");
|
||||
@@ -2588,6 +2829,85 @@ LIMIT
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("read-only remote sync", () => {
|
||||
beforeEach(() => {
|
||||
cy.log("create a transform");
|
||||
createSqlTransform({
|
||||
sourceQuery: `SELECT * FROM "${TARGET_SCHEMA}"."${SOURCE_TABLE}"`,
|
||||
});
|
||||
cy.log("set up remote sync");
|
||||
H.setupGitSync();
|
||||
H.configureGit("read-only");
|
||||
});
|
||||
|
||||
it("should make the transform list page read-only", () => {
|
||||
cy.log("visit transforms page");
|
||||
visitTransformListPage();
|
||||
|
||||
cy.log("'Create a transform' menu button is not displayed");
|
||||
cy.button("Create a transform").should("not.exist");
|
||||
|
||||
cy.log("clicking Python library navigates to the library editor");
|
||||
getTransformsList().findByText("Python library").click();
|
||||
|
||||
cy.log("python library editor is read-only");
|
||||
cy.url().should("include", "/data-studio/transforms/library/common.py");
|
||||
cy.findByRole("alert")
|
||||
.contains(/The Python library is not editable/)
|
||||
.should("be.visible");
|
||||
|
||||
H.DataStudio.PythonLibrary.editor().within(() => {
|
||||
cy.findByRole("textbox").should(
|
||||
"have.attr",
|
||||
"contenteditable",
|
||||
"false",
|
||||
);
|
||||
cy.findByRole("textbox").should("have.attr", "aria-readonly", "true");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not allow editing a transform", () => {
|
||||
cy.log("visit transform");
|
||||
cy.visit("/data-studio/transforms/1");
|
||||
|
||||
cy.log("'edit definition' button is not displayed");
|
||||
H.DataStudio.Transforms.editDefinition().should("not.exist");
|
||||
|
||||
cy.log("visit the Run tab");
|
||||
H.DataStudio.Transforms.runTab().click();
|
||||
|
||||
cy.log("schedule tags are not editable");
|
||||
cy.findByLabelText("Tags").should("be.visible");
|
||||
cy.findByLabelText("Tags").should("be.disabled");
|
||||
|
||||
cy.log("visit the Settings tab");
|
||||
H.DataStudio.Transforms.settingsTab().click();
|
||||
|
||||
cy.log("'Change target' button is not displayed");
|
||||
cy.findByRole("button", { name: /Change target/ }).should("not.exist");
|
||||
|
||||
cy.log("'Only process new and changed data' switch is not displayed");
|
||||
cy.findByRole("switch", {
|
||||
name: /Only process new and changed data/,
|
||||
}).should("be.disabled");
|
||||
|
||||
cy.log("visiting edit mode url directly redirects to view-only mode");
|
||||
cy.visit("/data-studio/transforms/1/edit");
|
||||
cy.url().should("not.include", "/edit");
|
||||
|
||||
H.DataStudio.Transforms.header()
|
||||
.findByRole("img", { name: "ellipsis icon" })
|
||||
.click();
|
||||
|
||||
cy.log("ellipsis menu does not have move or delete options");
|
||||
cy.findByRole("menu").within(() => {
|
||||
cy.findByRole("menuitem", { name: /History/ }).should("be.visible");
|
||||
cy.findByRole("menuitem", { name: /Move/ }).should("not.exist");
|
||||
cy.findByRole("menuitem", { name: /Delete/ }).should("not.exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("scenarios > admin > transforms > databases without :schemas", () => {
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
import { WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { ADMIN_PERSONAL_COLLECTION_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import type { TransformId } from "metabase-types/api";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
const TABLE_NAME = "test_transform_table";
|
||||
const TABLE_DISPLAY_NAME = "Test Transform Table";
|
||||
const TABLE_TRANSFORM = "Test Transform";
|
||||
const TABLE_BASED_QUESTION_BROKEN_FIELD =
|
||||
"Table-based question with broken field";
|
||||
const TABLE_BASED_QUESTION_BROKEN_EXPRESSION =
|
||||
"Table-based question with broken expression";
|
||||
const TABLE_BASED_QUESTION_BROKEN_FILTER =
|
||||
"Table-based question with broken filter";
|
||||
const TABLE_BASED_QUESTION_BROKEN_BREAKOUT =
|
||||
"Table-based question with broken breakout";
|
||||
const TABLE_BASED_QUESTION_BROKEN_AGGREGATION =
|
||||
"Table-based question with broken aggregation";
|
||||
const TABLE_BASED_QUESTION_BROKEN_EXPLICIT_JOIN =
|
||||
"Table-based question with broken explicit join";
|
||||
const TABLE_BASED_QUESTION = "Table-based question";
|
||||
const QUESTION_BASED_QUESTION_BROKEN_FILTER =
|
||||
"Question-based question with broken filter";
|
||||
const TABLE_BASED_MODEL = "Table-based model";
|
||||
const MODEL_BASED_MODEL_BROKEN_AGGREGATION =
|
||||
"Model-based model with broken aggregation";
|
||||
|
||||
const BROKEN_TABLE_DEPENDENCIES = [TABLE_DISPLAY_NAME];
|
||||
const BROKEN_TABLE_DEPENDENTS = [
|
||||
TABLE_BASED_QUESTION_BROKEN_FIELD,
|
||||
TABLE_BASED_QUESTION_BROKEN_EXPRESSION,
|
||||
TABLE_BASED_QUESTION_BROKEN_FILTER,
|
||||
TABLE_BASED_QUESTION_BROKEN_BREAKOUT,
|
||||
TABLE_BASED_QUESTION_BROKEN_AGGREGATION,
|
||||
TABLE_BASED_QUESTION_BROKEN_EXPLICIT_JOIN,
|
||||
];
|
||||
const BROKEN_QUESTION_DEPENDENCIES = [TABLE_BASED_QUESTION];
|
||||
const BROKEN_QUESTION_DEPENDENTS = [QUESTION_BASED_QUESTION_BROKEN_FILTER];
|
||||
const BROKEN_MODEL_DEPENDENCIES = [TABLE_BASED_MODEL];
|
||||
const BROKEN_MODEL_DEPENDENTS = [MODEL_BASED_MODEL_BROKEN_AGGREGATION];
|
||||
|
||||
const BROKEN_DEPENDENCIES = [
|
||||
...BROKEN_TABLE_DEPENDENCIES,
|
||||
...BROKEN_QUESTION_DEPENDENCIES,
|
||||
...BROKEN_MODEL_DEPENDENCIES,
|
||||
];
|
||||
|
||||
const BROKEN_DEPENDENCIES_SORTED_BY_NAME = [
|
||||
TABLE_BASED_MODEL,
|
||||
TABLE_BASED_QUESTION,
|
||||
TABLE_DISPLAY_NAME,
|
||||
];
|
||||
|
||||
const BROKEN_DEPENDENCIES_SORTED_BY_LOCATION = [
|
||||
TABLE_BASED_QUESTION, // Bobby Tables's personal collection
|
||||
TABLE_BASED_MODEL, // Our analytics
|
||||
TABLE_DISPLAY_NAME, // Writable Postgres
|
||||
];
|
||||
|
||||
const BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_ERRORS = [
|
||||
TABLE_BASED_QUESTION, // 1 error: PRICE
|
||||
TABLE_BASED_MODEL, // 1 error: AMOUNT
|
||||
TABLE_DISPLAY_NAME, // 2 errors: SCORE, STATUS
|
||||
];
|
||||
|
||||
const BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_WITH_ERRORS = [
|
||||
TABLE_BASED_QUESTION, // 1 question
|
||||
TABLE_BASED_MODEL, // 1 model
|
||||
TABLE_DISPLAY_NAME, // 6 questions
|
||||
];
|
||||
|
||||
const BROKEN_DEPENDENTS = [
|
||||
...BROKEN_TABLE_DEPENDENTS,
|
||||
...BROKEN_QUESTION_DEPENDENTS,
|
||||
...BROKEN_MODEL_DEPENDENTS,
|
||||
];
|
||||
|
||||
describe("scenarios > dependencies > broken list", () => {
|
||||
beforeEach(() => {
|
||||
H.restore("postgres-writable");
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
createContent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dropTransformTable();
|
||||
});
|
||||
|
||||
describe("analysis", () => {
|
||||
it("should show broken dependencies", () => {
|
||||
H.DependencyDiagnostics.visitBrokenDependencies();
|
||||
checkList({
|
||||
visibleEntities: BROKEN_DEPENDENCIES,
|
||||
hiddenEntities: BROKEN_DEPENDENTS,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sidebar", () => {
|
||||
it("should show broken dependents", () => {
|
||||
H.DependencyDiagnostics.visitBrokenDependencies();
|
||||
|
||||
cy.log("table dependents");
|
||||
H.DependencyDiagnostics.list().findByText(TABLE_DISPLAY_NAME).click();
|
||||
checkSidebar({
|
||||
entityName: TABLE_DISPLAY_NAME,
|
||||
transformName: TABLE_TRANSFORM,
|
||||
missingColumns: ["score", "status"],
|
||||
brokenDependents: BROKEN_TABLE_DEPENDENTS,
|
||||
});
|
||||
|
||||
cy.log("question dependents");
|
||||
H.DependencyDiagnostics.list().findByText(TABLE_BASED_QUESTION).click();
|
||||
checkSidebar({
|
||||
entityName: TABLE_BASED_QUESTION,
|
||||
missingColumns: ["PRICE"],
|
||||
brokenDependents: BROKEN_QUESTION_DEPENDENTS,
|
||||
});
|
||||
|
||||
cy.log("model dependents");
|
||||
H.DependencyDiagnostics.list().findByText(TABLE_BASED_MODEL).click();
|
||||
checkSidebar({
|
||||
entityName: TABLE_BASED_MODEL,
|
||||
missingColumns: ["AMOUNT"],
|
||||
brokenDependents: BROKEN_MODEL_DEPENDENTS,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
it("should search for entities", () => {
|
||||
H.DependencyDiagnostics.visitBrokenDependencies();
|
||||
H.DependencyDiagnostics.searchInput().type(TABLE_DISPLAY_NAME);
|
||||
checkList({
|
||||
visibleEntities: [TABLE_DISPLAY_NAME],
|
||||
hiddenEntities: [TABLE_BASED_QUESTION, TABLE_BASED_MODEL],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("filtering", () => {
|
||||
it("should filter entities by type", () => {
|
||||
H.DependencyDiagnostics.visitBrokenDependencies();
|
||||
H.DependencyDiagnostics.filterButton().click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Table").click();
|
||||
cy.findByText("Question").click();
|
||||
cy.findByText("Model").click();
|
||||
});
|
||||
|
||||
cy.log("only tables");
|
||||
H.popover().findByText("Table").click();
|
||||
checkList({
|
||||
visibleEntities: BROKEN_TABLE_DEPENDENCIES,
|
||||
hiddenEntities: [
|
||||
...BROKEN_QUESTION_DEPENDENCIES,
|
||||
...BROKEN_MODEL_DEPENDENCIES,
|
||||
],
|
||||
});
|
||||
|
||||
cy.log("only questions");
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Table").click();
|
||||
cy.findByText("Question").click();
|
||||
});
|
||||
checkList({
|
||||
visibleEntities: BROKEN_QUESTION_DEPENDENCIES,
|
||||
hiddenEntities: [
|
||||
...BROKEN_TABLE_DEPENDENCIES,
|
||||
...BROKEN_MODEL_DEPENDENCIES,
|
||||
],
|
||||
});
|
||||
|
||||
cy.log("only models");
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Question").click();
|
||||
cy.findByText("Model").click();
|
||||
});
|
||||
checkList({
|
||||
visibleEntities: BROKEN_MODEL_DEPENDENCIES,
|
||||
hiddenEntities: [
|
||||
...BROKEN_TABLE_DEPENDENCIES,
|
||||
...BROKEN_QUESTION_DEPENDENCIES,
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter entities by location", () => {
|
||||
H.DependencyDiagnostics.visitBrokenDependencies();
|
||||
H.DependencyDiagnostics.filterButton().click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Include items in personal collections").click();
|
||||
});
|
||||
checkList({
|
||||
visibleEntities: [
|
||||
...BROKEN_TABLE_DEPENDENCIES,
|
||||
...BROKEN_MODEL_DEPENDENCIES,
|
||||
],
|
||||
hiddenEntities: BROKEN_QUESTION_DEPENDENCIES,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sorting", () => {
|
||||
it("should sort by name", () => {
|
||||
H.DependencyDiagnostics.visitBrokenDependencies();
|
||||
|
||||
cy.log("sorted by name by default");
|
||||
checkListSorting({
|
||||
visibleEntities: BROKEN_DEPENDENCIES_SORTED_BY_NAME,
|
||||
});
|
||||
|
||||
cy.log("sorted by name ascending");
|
||||
H.DependencyDiagnostics.list().findByText("Dependency").click();
|
||||
checkListSorting({
|
||||
visibleEntities: BROKEN_DEPENDENCIES_SORTED_BY_NAME,
|
||||
});
|
||||
|
||||
cy.log("sorted by name descending");
|
||||
H.DependencyDiagnostics.list().findByText("Dependency").click();
|
||||
checkListSorting({
|
||||
visibleEntities: [...BROKEN_DEPENDENCIES_SORTED_BY_NAME].reverse(),
|
||||
});
|
||||
});
|
||||
|
||||
it("should sort by location", () => {
|
||||
H.DependencyDiagnostics.visitBrokenDependencies();
|
||||
|
||||
cy.log("sorted by location ascending");
|
||||
H.DependencyDiagnostics.list().findByText("Location").click();
|
||||
checkListSorting({
|
||||
visibleEntities: BROKEN_DEPENDENCIES_SORTED_BY_LOCATION,
|
||||
});
|
||||
|
||||
cy.log("sorted by location descending");
|
||||
H.DependencyDiagnostics.list().findByText("Location").click();
|
||||
checkListSorting({
|
||||
visibleEntities: [...BROKEN_DEPENDENCIES_SORTED_BY_LOCATION].reverse(),
|
||||
});
|
||||
});
|
||||
|
||||
it("should sort by dependents errors", () => {
|
||||
H.DependencyDiagnostics.visitBrokenDependencies();
|
||||
|
||||
cy.log("sorted by dependents errors ascending");
|
||||
H.DependencyDiagnostics.list().findByText("Problems").click();
|
||||
checkListSorting({
|
||||
visibleEntities: [
|
||||
...BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_ERRORS,
|
||||
].reverse(),
|
||||
});
|
||||
|
||||
cy.log("sorted by dependents errors descending");
|
||||
H.DependencyDiagnostics.list().findByText("Problems").click();
|
||||
checkListSorting({
|
||||
visibleEntities: BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_ERRORS,
|
||||
});
|
||||
});
|
||||
|
||||
it("should sort by dependents with errors", () => {
|
||||
H.DependencyDiagnostics.visitBrokenDependencies();
|
||||
|
||||
cy.log("sorted by dependents with errors ascending");
|
||||
H.DependencyDiagnostics.list().findByText("Broken dependents").click();
|
||||
checkListSorting({
|
||||
visibleEntities: [
|
||||
...BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_WITH_ERRORS,
|
||||
].reverse(),
|
||||
});
|
||||
|
||||
cy.log("sorted by dependents with errors descending");
|
||||
H.DependencyDiagnostics.list().findByText("Broken dependents").click();
|
||||
checkListSorting({
|
||||
visibleEntities: BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_WITH_ERRORS,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createContent() {
|
||||
createTransform();
|
||||
createTableContent();
|
||||
createQuestionContent();
|
||||
createModelContent();
|
||||
breakTransform();
|
||||
}
|
||||
|
||||
function dropTransformTable() {
|
||||
cy.get<TransformId>("@transformId").then((transformId) => {
|
||||
cy.request("DELETE", `/api/ee/transform/${transformId}/table`);
|
||||
});
|
||||
}
|
||||
|
||||
function createTransform() {
|
||||
H.createTransform(
|
||||
{
|
||||
name: TABLE_TRANSFORM,
|
||||
source: {
|
||||
type: "query",
|
||||
query: {
|
||||
type: "native",
|
||||
database: WRITABLE_DB_ID,
|
||||
native: {
|
||||
query: "SELECT 1 as score, 'active' as status",
|
||||
},
|
||||
},
|
||||
},
|
||||
target: {
|
||||
type: "table",
|
||||
database: WRITABLE_DB_ID,
|
||||
schema: "public",
|
||||
name: TABLE_NAME,
|
||||
},
|
||||
},
|
||||
{ wrapId: true },
|
||||
);
|
||||
cy.get<TransformId>("@transformId").then((transformId) => {
|
||||
H.runTransform(transformId);
|
||||
H.resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: TABLE_NAME });
|
||||
H.waitForTransformRuns(
|
||||
(runs) =>
|
||||
runs.length === 1 && runs.every((run) => run.status === "succeeded"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function breakTransform() {
|
||||
cy.get<TransformId>("@transformId").then((transformId) => {
|
||||
cy.request("PUT", `/api/ee/transform/${transformId}`, {
|
||||
source: {
|
||||
type: "query",
|
||||
query: {
|
||||
type: "native",
|
||||
database: WRITABLE_DB_ID,
|
||||
native: {
|
||||
query: "SELECT 1 as score_new, 'active' as status_new",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
H.runTransform(transformId);
|
||||
H.waitForTransformRuns(
|
||||
(runs) =>
|
||||
runs.length === 2 && runs.every((run) => run.status === "succeeded"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function createTableContent() {
|
||||
H.getTableId({ name: TABLE_NAME }).then((tableId) => {
|
||||
H.getFieldId({ tableId, name: "score" }).then((scoreFieldId) => {
|
||||
H.getFieldId({ tableId, name: "status" }).then((statusFieldId) => {
|
||||
H.createQuestion({
|
||||
name: TABLE_BASED_QUESTION_BROKEN_FIELD,
|
||||
database: WRITABLE_DB_ID,
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
fields: [["field", statusFieldId, null]],
|
||||
},
|
||||
});
|
||||
|
||||
H.createQuestion({
|
||||
name: TABLE_BASED_QUESTION_BROKEN_EXPRESSION,
|
||||
database: WRITABLE_DB_ID,
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
expressions: {
|
||||
Expression: ["+", ["field", scoreFieldId, null], 0],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
H.createQuestion({
|
||||
name: TABLE_BASED_QUESTION_BROKEN_FILTER,
|
||||
database: WRITABLE_DB_ID,
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
filter: ["=", ["field", scoreFieldId, null], 0],
|
||||
},
|
||||
});
|
||||
|
||||
H.createQuestion({
|
||||
name: TABLE_BASED_QUESTION_BROKEN_BREAKOUT,
|
||||
database: WRITABLE_DB_ID,
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
breakout: [["field", scoreFieldId, null]],
|
||||
},
|
||||
});
|
||||
|
||||
H.createQuestion({
|
||||
name: TABLE_BASED_QUESTION_BROKEN_AGGREGATION,
|
||||
database: WRITABLE_DB_ID,
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
aggregation: [["avg", ["field", scoreFieldId, null]]],
|
||||
},
|
||||
});
|
||||
|
||||
H.createQuestion({
|
||||
name: TABLE_BASED_QUESTION_BROKEN_EXPLICIT_JOIN,
|
||||
database: WRITABLE_DB_ID,
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
joins: [
|
||||
{
|
||||
"source-table": tableId,
|
||||
alias: TABLE_DISPLAY_NAME,
|
||||
condition: [
|
||||
"=",
|
||||
["field", statusFieldId, null],
|
||||
[
|
||||
"field",
|
||||
statusFieldId,
|
||||
{ "join-alias": TABLE_DISPLAY_NAME },
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
aggregation: [
|
||||
[
|
||||
"max",
|
||||
["field", scoreFieldId, { "join-alias": TABLE_DISPLAY_NAME }],
|
||||
],
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createQuestionContent() {
|
||||
H.getTableId({ name: TABLE_NAME }).then((tableId) => {
|
||||
H.createQuestion({
|
||||
name: TABLE_BASED_QUESTION,
|
||||
database: WRITABLE_DB_ID,
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
},
|
||||
collection_id: ADMIN_PERSONAL_COLLECTION_ID,
|
||||
}).then(({ body: card }) => {
|
||||
H.createQuestion({
|
||||
name: QUESTION_BASED_QUESTION_BROKEN_FILTER,
|
||||
query: {
|
||||
"source-table": `card__${card.id}`,
|
||||
filter: [">", ["field", "PRICE", { "base-type": "type/Float" }], 10],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createModelContent() {
|
||||
H.getTableId({ name: TABLE_NAME }).then((tableId) => {
|
||||
H.createQuestion({
|
||||
name: TABLE_BASED_MODEL,
|
||||
type: "model",
|
||||
database: WRITABLE_DB_ID,
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
},
|
||||
}).then(({ body: card }) => {
|
||||
H.createQuestion({
|
||||
name: MODEL_BASED_MODEL_BROKEN_AGGREGATION,
|
||||
type: "model",
|
||||
query: {
|
||||
"source-table": `card__${card.id}`,
|
||||
aggregation: [
|
||||
["distinct", ["field", "AMOUNT", { "base-type": "type/Integer" }]],
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function checkList({
|
||||
visibleEntities = [],
|
||||
hiddenEntities = [],
|
||||
}: {
|
||||
visibleEntities?: string[];
|
||||
hiddenEntities?: string[];
|
||||
}) {
|
||||
H.DependencyDiagnostics.list().within(() => {
|
||||
visibleEntities.forEach((name) => {
|
||||
cy.findByText(name).should("exist");
|
||||
});
|
||||
hiddenEntities.forEach((name) => {
|
||||
cy.findByText(name).should("not.exist");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function checkSidebar({
|
||||
entityName,
|
||||
transformName,
|
||||
missingColumns,
|
||||
brokenDependents,
|
||||
}: {
|
||||
entityName: string;
|
||||
transformName?: string;
|
||||
missingColumns?: string[];
|
||||
brokenDependents?: string[];
|
||||
}) {
|
||||
H.DependencyDiagnostics.sidebar().within(() => {
|
||||
H.DependencyDiagnostics.Sidebar.header()
|
||||
.findByText(entityName)
|
||||
.should("be.visible");
|
||||
if (transformName) {
|
||||
H.DependencyDiagnostics.Sidebar.transformSection()
|
||||
.findByText(transformName)
|
||||
.should("exist");
|
||||
}
|
||||
if (missingColumns) {
|
||||
H.DependencyDiagnostics.Sidebar.missingColumnsSection().within(() => {
|
||||
missingColumns.forEach((column) => {
|
||||
cy.findByText(column).should("exist");
|
||||
});
|
||||
});
|
||||
}
|
||||
if (brokenDependents) {
|
||||
H.DependencyDiagnostics.Sidebar.brokenDependentsSection().within(() => {
|
||||
brokenDependents.forEach((dependent) => {
|
||||
cy.findByText(dependent).should("exist");
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function checkListSorting({ visibleEntities }: { visibleEntities: string[] }) {
|
||||
H.DependencyDiagnostics.list().within(() => {
|
||||
visibleEntities.forEach((name, index) => {
|
||||
cy.findByText(name)
|
||||
.parents("[data-index]")
|
||||
.should("have.attr", "data-index", index.toString());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -193,10 +193,6 @@ describe("scenarios > dependencies > dependency checks", () => {
|
||||
});
|
||||
cy.get("@updateTransform.all").should("have.length", 0);
|
||||
|
||||
H.DataStudio.Transforms.editDefinition().click();
|
||||
H.getNotebookStep("data").findByLabelText("Pick columns").click();
|
||||
H.popover().findByLabelText("Score").click();
|
||||
|
||||
cy.log("confirm breaking changes");
|
||||
H.DataStudio.Transforms.saveChangesButton().click();
|
||||
H.modal().within(() => {
|
||||
|
||||
@@ -611,25 +611,25 @@ describe("scenarios > dependencies > dependency graph", () => {
|
||||
});
|
||||
|
||||
describe("dependent filtering", () => {
|
||||
function verifyFilter({
|
||||
filterName,
|
||||
visibleItems,
|
||||
hiddenItems,
|
||||
}: {
|
||||
filterName: string;
|
||||
visibleItems: string[];
|
||||
hiddenItems: string[];
|
||||
}) {
|
||||
function toggleFilter({ filterName }: { filterName: string }) {
|
||||
H.DependencyGraph.dependencyPanel().icon("filter").click();
|
||||
H.popover().findByText(filterName).click();
|
||||
H.DependencyGraph.dependencyPanel().icon("filter").click();
|
||||
}
|
||||
|
||||
function verifyItems({
|
||||
visibleItems = [],
|
||||
hiddenItems = [],
|
||||
}: {
|
||||
visibleItems?: string[];
|
||||
hiddenItems?: string[];
|
||||
}) {
|
||||
H.DependencyGraph.dependencyPanel().within(() => {
|
||||
visibleItems.forEach((item) =>
|
||||
cy.findByText(item).should("be.visible"),
|
||||
);
|
||||
hiddenItems.forEach((item) => cy.findByText(item).should("not.exist"));
|
||||
});
|
||||
H.popover().findByText(filterName).click();
|
||||
H.DependencyGraph.dependencyPanel().icon("filter").click();
|
||||
}
|
||||
|
||||
it("should be able to filter questions", () => {
|
||||
@@ -666,16 +666,20 @@ describe("scenarios > dependencies > dependency graph", () => {
|
||||
.findByLabelText(TABLE_DISPLAY_NAME)
|
||||
.findByText("5 questions")
|
||||
.click();
|
||||
verifyFilter({
|
||||
filterName: "In a dashboard",
|
||||
visibleItems: ["Question in dashboard"],
|
||||
hiddenItems: [
|
||||
|
||||
verifyItems({
|
||||
visibleItems: [
|
||||
"Question in dashboard",
|
||||
"Question in root collection",
|
||||
"Question in first collection",
|
||||
"Question in second collection",
|
||||
"Question in personal collection",
|
||||
],
|
||||
});
|
||||
verifyFilter({
|
||||
filterName: "Not in personal collection",
|
||||
toggleFilter({
|
||||
filterName: "Include items in personal collections",
|
||||
});
|
||||
verifyItems({
|
||||
visibleItems: [
|
||||
"Question in dashboard",
|
||||
"Question in root collection",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
const { H } = cy;
|
||||
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import {
|
||||
ADMIN_PERSONAL_COLLECTION_ID,
|
||||
@@ -15,6 +13,7 @@ import type {
|
||||
} from "metabase-types/api";
|
||||
import { createMockParameter } from "metabase-types/api/mocks";
|
||||
|
||||
const { H } = cy;
|
||||
const { ORDERS_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE;
|
||||
|
||||
const MODEL_FOR_QUESTION_DATA_SOURCE = "Model for question data source";
|
||||
@@ -105,8 +104,8 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
describe("analysis", () => {
|
||||
it("should show unreferenced entities", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
H.DependencyDiagnostics.list().within(() => {
|
||||
ENTITY_NAMES.forEach((name) => {
|
||||
cy.findByText(name).should("be.visible");
|
||||
});
|
||||
@@ -115,8 +114,8 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
|
||||
it("should not show referenced entities", () => {
|
||||
createEntities({ withReferences: true });
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
H.DependencyDiagnostics.list().within(() => {
|
||||
ENTITY_NAMES.forEach((name) => {
|
||||
cy.findByText(name).should("not.exist");
|
||||
});
|
||||
@@ -127,8 +126,10 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
describe("search", () => {
|
||||
it("should search for entities", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DataStudio.Tasks.searchInput().type(MODEL_FOR_QUESTION_DATA_SOURCE);
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
H.DependencyDiagnostics.searchInput().type(
|
||||
MODEL_FOR_QUESTION_DATA_SOURCE,
|
||||
);
|
||||
checkList({
|
||||
visibleEntities: [MODEL_FOR_QUESTION_DATA_SOURCE],
|
||||
hiddenEntities: [MODEL_FOR_MODEL_DATA_SOURCE],
|
||||
@@ -137,8 +138,8 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
|
||||
it("should search for entities with type filters", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DataStudio.Tasks.searchInput().type("tag");
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
H.DependencyDiagnostics.searchInput().type("tag");
|
||||
checkList({
|
||||
visibleEntities: [
|
||||
MODEL_FOR_NATIVE_QUESTION_CARD_TAG,
|
||||
@@ -147,7 +148,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
hiddenEntities: [MODEL_FOR_QUESTION_DATA_SOURCE],
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.filterButton().click();
|
||||
H.DependencyDiagnostics.filterButton().click();
|
||||
H.popover().findByText("Snippet").click();
|
||||
checkList({
|
||||
visibleEntities: [MODEL_FOR_NATIVE_QUESTION_CARD_TAG],
|
||||
@@ -159,10 +160,10 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
describe("filters", () => {
|
||||
it("should filter entities by type", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
checkList({ visibleEntities: ENTITY_NAMES });
|
||||
|
||||
H.DataStudio.Tasks.filterButton().click();
|
||||
H.DependencyDiagnostics.filterButton().click();
|
||||
H.popover().findByText("Model").click();
|
||||
checkList({ hiddenEntities: [MODEL_FOR_NATIVE_QUESTION_CARD_TAG] });
|
||||
|
||||
@@ -189,7 +190,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
|
||||
it("should filter by location", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
checkList({
|
||||
visibleEntities: [
|
||||
MODEL_FOR_MODEL_DATA_SOURCE,
|
||||
@@ -198,7 +199,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
],
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.filterButton().click();
|
||||
H.DependencyDiagnostics.filterButton().click();
|
||||
H.popover().findByText("Include items in personal collections").click();
|
||||
checkList({
|
||||
visibleEntities: [
|
||||
@@ -222,8 +223,8 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
describe("sorting", () => {
|
||||
it("should sort by name", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DataStudio.Tasks.searchInput().type("Model for");
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
H.DependencyDiagnostics.searchInput().type("Model for");
|
||||
|
||||
cy.log("sorted by name by default");
|
||||
checkListSorting({
|
||||
@@ -231,13 +232,13 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
});
|
||||
|
||||
cy.log("sorted by name ascending");
|
||||
H.DataStudio.Tasks.list().findByText("Name").click();
|
||||
H.DependencyDiagnostics.list().findByText("Name").click();
|
||||
checkListSorting({
|
||||
visibleEntities: MODELS_SORTED_BY_NAME,
|
||||
});
|
||||
|
||||
cy.log("sorted by name descending");
|
||||
H.DataStudio.Tasks.list().findByText("Name").click();
|
||||
H.DependencyDiagnostics.list().findByText("Name").click();
|
||||
checkListSorting({
|
||||
visibleEntities: [...MODELS_SORTED_BY_NAME].reverse(),
|
||||
});
|
||||
@@ -245,17 +246,17 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
|
||||
it("should sort by location", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DataStudio.Tasks.searchInput().type("Model for");
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
H.DependencyDiagnostics.searchInput().type("Model for");
|
||||
|
||||
cy.log("sorted by location ascending");
|
||||
H.DataStudio.Tasks.list().findByText("Location").click();
|
||||
H.DependencyDiagnostics.list().findByText("Location").click();
|
||||
checkListSorting({
|
||||
visibleEntities: MODELS_SORTED_BY_LOCATION,
|
||||
});
|
||||
|
||||
cy.log("sorted by location descending");
|
||||
H.DataStudio.Tasks.list().findByText("Location").click();
|
||||
H.DependencyDiagnostics.list().findByText("Location").click();
|
||||
checkListSorting({
|
||||
visibleEntities: [...MODELS_SORTED_BY_LOCATION].reverse(),
|
||||
});
|
||||
@@ -265,9 +266,9 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
describe("sidebar", () => {
|
||||
it("should show the sidebar for supported entities", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
|
||||
H.DataStudio.Tasks.list()
|
||||
H.DependencyDiagnostics.list()
|
||||
.findByText(MODEL_FOR_QUESTION_DATA_SOURCE)
|
||||
.click();
|
||||
checkSidebar({
|
||||
@@ -276,21 +277,25 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
creatorName: "Bobby Tables",
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.list().findByText(MODEL_FOR_MODEL_DATA_SOURCE).click();
|
||||
H.DependencyDiagnostics.list()
|
||||
.findByText(MODEL_FOR_MODEL_DATA_SOURCE)
|
||||
.click();
|
||||
checkSidebar({
|
||||
entityName: MODEL_FOR_MODEL_DATA_SOURCE,
|
||||
locationName: "First collection",
|
||||
creatorName: "Bobby Tables",
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.list().findByText(SEGMENT_FOR_QUESTION_FILTER).click();
|
||||
H.DependencyDiagnostics.list()
|
||||
.findByText(SEGMENT_FOR_QUESTION_FILTER)
|
||||
.click();
|
||||
checkSidebar({
|
||||
entityName: SEGMENT_FOR_QUESTION_FILTER,
|
||||
locationName: "Orders",
|
||||
creatorName: "Bobby Tables",
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.list()
|
||||
H.DependencyDiagnostics.list()
|
||||
.findByText(METRIC_FOR_QUESTION_AGGREGATION)
|
||||
.click();
|
||||
checkSidebar({
|
||||
@@ -299,7 +304,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
creatorName: "Bobby Tables",
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.list()
|
||||
H.DependencyDiagnostics.list()
|
||||
.findByText(METRIC_FOR_MODEL_AGGREGATION)
|
||||
.click();
|
||||
checkSidebar({
|
||||
@@ -308,7 +313,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
creatorName: "Bobby Tables",
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.list()
|
||||
H.DependencyDiagnostics.list()
|
||||
.findByText(SNIPPET_FOR_NATIVE_QUESTION_CARD_TAG)
|
||||
.click();
|
||||
checkSidebar({
|
||||
@@ -987,7 +992,7 @@ function checkList({
|
||||
visibleEntities?: string[];
|
||||
hiddenEntities?: string[];
|
||||
}) {
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
H.DependencyDiagnostics.list().within(() => {
|
||||
visibleEntities.forEach((name) => {
|
||||
cy.findByText(name).should("be.visible");
|
||||
});
|
||||
@@ -998,7 +1003,7 @@ function checkList({
|
||||
}
|
||||
|
||||
function checkListSorting({ visibleEntities }: { visibleEntities: string[] }) {
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
H.DependencyDiagnostics.list().within(() => {
|
||||
visibleEntities.forEach((name, index) => {
|
||||
cy.findByText(name)
|
||||
.parents("[data-index]")
|
||||
@@ -1016,17 +1021,17 @@ function checkSidebar({
|
||||
locationName?: string;
|
||||
creatorName?: string;
|
||||
}) {
|
||||
H.DataStudio.Tasks.sidebar().within(() => {
|
||||
H.DataStudio.Tasks.Sidebar.header()
|
||||
H.DependencyDiagnostics.sidebar().within(() => {
|
||||
H.DependencyDiagnostics.Sidebar.header()
|
||||
.findByText(entityName)
|
||||
.should("be.visible");
|
||||
if (locationName) {
|
||||
H.DataStudio.Tasks.Sidebar.locationInfo()
|
||||
H.DependencyDiagnostics.Sidebar.locationSection()
|
||||
.findByText(locationName)
|
||||
.should("be.visible");
|
||||
}
|
||||
if (creatorName) {
|
||||
H.DataStudio.Tasks.Sidebar.creationInfo().should(
|
||||
H.DependencyDiagnostics.Sidebar.creationSection().should(
|
||||
"contain.text",
|
||||
creatorName,
|
||||
);
|
||||
|
||||
@@ -1236,6 +1236,155 @@ describe("documents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("anchor links", () => {
|
||||
// Helper to create filler paragraphs for scroll tests
|
||||
const createFillerParagraphs = (count: number, startIndex: number) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
type: "paragraph",
|
||||
attrs: { _id: `filler-paragraph-${startIndex + i}` },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `This is filler paragraph ${startIndex + i} to make the document long enough to require scrolling. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
H.createDocument({
|
||||
name: "Anchor Test Document",
|
||||
document: {
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 1, _id: "heading-block-1" },
|
||||
content: [{ type: "text", text: "First Heading" }],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { _id: "paragraph-block-1" },
|
||||
content: [{ type: "text", text: "Some content here" }],
|
||||
},
|
||||
// Add filler content to ensure scrolling is needed
|
||||
...createFillerParagraphs(15, 1),
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 2, _id: "heading-block-2" },
|
||||
content: [{ type: "text", text: "Second Heading" }],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { _id: "paragraph-block-2" },
|
||||
content: [{ type: "text", text: "More content here" }],
|
||||
},
|
||||
// More filler to push blockquote down
|
||||
...createFillerParagraphs(10, 16),
|
||||
{
|
||||
type: "blockquote",
|
||||
attrs: { _id: "blockquote-block-1" },
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { _id: "quote-paragraph" },
|
||||
content: [{ type: "text", text: "A nice quote" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
type: "doc",
|
||||
},
|
||||
collection_id: null,
|
||||
alias: "document",
|
||||
idAlias: "documentId",
|
||||
});
|
||||
});
|
||||
|
||||
it("should show anchor link icon on left side when hovering over a heading", () => {
|
||||
H.visitDocument("@documentId");
|
||||
|
||||
H.documentContent()
|
||||
.findByRole("heading", { name: "First Heading" })
|
||||
.realHover();
|
||||
|
||||
// Filter to visible one since all blocks have hidden buttons
|
||||
cy.get('[data-testid="anchor-link-menu"]')
|
||||
.filter(":visible")
|
||||
.first()
|
||||
.findByRole("button", { name: /copy link/i })
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should copy anchor URL to clipboard when clicking anchor link", () => {
|
||||
H.visitDocument("@documentId");
|
||||
|
||||
cy.wrap(
|
||||
Cypress.automation("remote:debugger:protocol", {
|
||||
command: "Browser.grantPermissions",
|
||||
params: {
|
||||
permissions: ["clipboardReadWrite", "clipboardSanitizedWrite"],
|
||||
origin: window.location.origin,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
H.documentContent()
|
||||
.findByRole("heading", { name: "First Heading" })
|
||||
.realHover();
|
||||
|
||||
// Filter to visible one since all blocks have hidden buttons
|
||||
cy.get('[data-testid="anchor-link-menu"]')
|
||||
.filter(":visible")
|
||||
.first()
|
||||
.findByRole("button", { name: /copy link/i })
|
||||
.click();
|
||||
|
||||
cy.get("body").findByText("Copied!").should("be.visible");
|
||||
|
||||
cy.window().then((win) => {
|
||||
win.navigator.clipboard.readText().then((text) => {
|
||||
expect(text).to.include("/document/");
|
||||
expect(text).to.include("#heading-block-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should scroll to the correct block when navigating with anchor hash", () => {
|
||||
cy.get("@documentId").then((documentId) => {
|
||||
cy.visit(`/document/${documentId}#heading-block-2`);
|
||||
|
||||
H.documentContent()
|
||||
.findByRole("heading", { name: "Second Heading" })
|
||||
.should("be.visible");
|
||||
|
||||
H.documentContent()
|
||||
.findByRole("heading", { name: "First Heading" })
|
||||
.should("not.be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should still show comments menu on right side (regression check)", () => {
|
||||
H.visitDocument("@documentId");
|
||||
|
||||
H.documentContent()
|
||||
.findByRole("heading", { name: "First Heading" })
|
||||
.realHover();
|
||||
|
||||
// Filter to visible one since all blocks have hidden menus
|
||||
cy.get('[data-testid="anchor-link-menu"]')
|
||||
.filter(":visible")
|
||||
.first()
|
||||
.findByRole("button", { name: /copy link/i })
|
||||
.should("be.visible");
|
||||
|
||||
// Comments button uses ForwardRefLink, so it's a link role not button
|
||||
cy.get('[data-testid="comments-menu"]')
|
||||
.filter(":visible")
|
||||
.first()
|
||||
.findByRole("link", { name: /comments/i })
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should display an error toast when creating a new document fails", () => {
|
||||
// setup
|
||||
|
||||
@@ -54,5 +54,15 @@ describe(
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show CORS setting", () => {
|
||||
cy.visit("/admin/embedding");
|
||||
|
||||
cy.findByTestId("admin-layout-content").within(() => {
|
||||
cy.findByTestId("embedding-app-origins-sdk-setting").should(
|
||||
"not.exist",
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -46,4 +46,12 @@ describe("scenarios > embedding > admin settings > starter", () => {
|
||||
cy.icon("gem").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show CORS setting", () => {
|
||||
cy.visit("/admin/embedding");
|
||||
|
||||
cy.findByTestId("admin-layout-content").within(() => {
|
||||
cy.findByTestId("embedding-app-origins-sdk-setting").should("not.exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import { embedModalEnableEmbedding } from "e2e/support/helpers";
|
||||
import {
|
||||
ORDERS_COUNT_QUESTION_ID,
|
||||
ORDERS_QUESTION_ID,
|
||||
} from "e2e/support/cypress_sample_instance_data";
|
||||
|
||||
import {
|
||||
getEmbedSidebar,
|
||||
@@ -19,7 +21,9 @@ describe("scenarios > embedding > sdk iframe embed setup > common", () => {
|
||||
H.activateToken("bleeding-edge");
|
||||
H.enableTracking();
|
||||
H.updateSetting("enable-embedding-simple", true);
|
||||
H.updateSetting("show-simple-embed-terms", false);
|
||||
H.updateSetting("enable-embedding-static", true);
|
||||
H.updateSetting("show-static-embed-terms", false);
|
||||
|
||||
cy.intercept("GET", "/api/dashboard/**").as("dashboard");
|
||||
|
||||
@@ -77,8 +81,6 @@ describe("scenarios > embedding > sdk iframe embed setup > common", () => {
|
||||
|
||||
cy.wait("@dashboard");
|
||||
|
||||
H.embedModalEnableEmbedding();
|
||||
|
||||
cy.get("[data-iframe-loaded]", { timeout: 20000 }).should("have.length", 1);
|
||||
|
||||
H.modal().should("exist");
|
||||
@@ -92,6 +94,26 @@ describe("scenarios > embedding > sdk iframe embed setup > common", () => {
|
||||
});
|
||||
|
||||
describe("auth type switch", () => {
|
||||
it("allows to select the `guest` item even when static embedding setting is disabled", () => {
|
||||
H.updateSetting("enable-embedding-static", false);
|
||||
|
||||
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
|
||||
|
||||
visitNewEmbedPage({ waitForResource: false });
|
||||
|
||||
cy.findByLabelText("Guest").should("be.enabled");
|
||||
});
|
||||
|
||||
it("allows to select the `Metabase Account` item even when simple embedding setting is disabled", () => {
|
||||
H.updateSetting("enable-embedding-simple", false);
|
||||
|
||||
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
|
||||
|
||||
visitNewEmbedPage({ waitForResource: false });
|
||||
|
||||
cy.findByLabelText("Metabase account (SSO)").should("be.enabled");
|
||||
});
|
||||
|
||||
it("should reset experience to a default only when switching from SSO to Guest auth type and the current experience does not support guest embeds", () => {
|
||||
visitNewEmbedPage();
|
||||
|
||||
@@ -124,8 +146,6 @@ describe("scenarios > embedding > sdk iframe embed setup > common", () => {
|
||||
H.openSharingMenu("Embed");
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
embedModalEnableEmbedding();
|
||||
|
||||
H.waitForSimpleEmbedIframesToLoad();
|
||||
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ORDERS_COUNT_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
|
||||
import { visitNewEmbedPage } from "./helpers";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("scenarios > embedding > sdk iframe embed setup > common (oss and starter)", () => {
|
||||
describe("OSS", { tags: "@OSS" }, () => runTests({ token: null }));
|
||||
|
||||
describe("Starter", () => runTests({ token: "starter" }));
|
||||
|
||||
function runTests({ token }: { token: "starter" | null }) {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
|
||||
if (token) {
|
||||
H.activateToken(token);
|
||||
}
|
||||
|
||||
H.enableTracking();
|
||||
|
||||
cy.intercept("GET", "/api/dashboard/**").as("dashboard");
|
||||
|
||||
H.mockEmbedJsToDevServer();
|
||||
});
|
||||
|
||||
it("allows to select the `guest` item even when static embedding setting is disabled", () => {
|
||||
H.updateSetting("enable-embedding-static", false);
|
||||
|
||||
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
|
||||
|
||||
visitNewEmbedPage({ waitForResource: false });
|
||||
|
||||
cy.findByLabelText("Guest").should("be.enabled");
|
||||
});
|
||||
|
||||
it("does not allow to select the `Metabase Account`, when token feature is missing (oss)", () => {
|
||||
H.updateSetting("enable-embedding-simple", false);
|
||||
|
||||
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
|
||||
|
||||
visitNewEmbedPage({ waitForResource: false });
|
||||
|
||||
cy.findByLabelText("Metabase account (SSO)").should("be.disabled");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -4,19 +4,21 @@ import { getEmbedSidebar } from "./helpers";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
const { IS_ENTERPRISE } = Cypress.env();
|
||||
const IS_OSS = !IS_ENTERPRISE;
|
||||
const MB_EDITION = IS_ENTERPRISE ? "ee" : "oss";
|
||||
|
||||
const DATA_BY_MB_EDITION = {
|
||||
oss: {
|
||||
const DATA_BY_EMBEDDING_TYPE = {
|
||||
guest: {
|
||||
path: "/admin/embedding/guest",
|
||||
token: null,
|
||||
authMethodLabel: "Guest",
|
||||
cardTestId: "guest-embeds-setting-card",
|
||||
cardText:
|
||||
"To continue, enable guest embeds and agree to the usage conditions.",
|
||||
embeddingSettingName: "enable-embedding-static",
|
||||
showTermsSettingName: "show-static-embed-terms",
|
||||
},
|
||||
ee: {
|
||||
modular: {
|
||||
path: "/admin/embedding",
|
||||
token: "bleeding-edge",
|
||||
authMethodLabel: "Metabase account (SSO)",
|
||||
cardTestId: "sdk-setting-card",
|
||||
cardText:
|
||||
"To continue, enable modular embedding and agree to the usage conditions.",
|
||||
@@ -25,19 +27,24 @@ const DATA_BY_MB_EDITION = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
describe(
|
||||
`scenarios > embedding > sdk iframe embed setup > enable embed js > ${MB_EDITION}`,
|
||||
{ ...(IS_OSS && { tags: "@OSS" }) },
|
||||
() => {
|
||||
const { embeddingSettingName, showTermsSettingName, cardTestId, cardText } =
|
||||
DATA_BY_MB_EDITION[MB_EDITION];
|
||||
Object.entries(DATA_BY_EMBEDDING_TYPE).forEach(([key, value]) => {
|
||||
describe(`scenarios > embedding > sdk iframe embed setup > enable embed js (EE) > ${key}`, () => {
|
||||
const {
|
||||
path,
|
||||
token,
|
||||
authMethodLabel,
|
||||
embeddingSettingName,
|
||||
showTermsSettingName,
|
||||
cardTestId,
|
||||
cardText,
|
||||
} = value;
|
||||
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
|
||||
if (IS_ENTERPRISE) {
|
||||
H.activateToken("bleeding-edge");
|
||||
if (token) {
|
||||
H.activateToken(token);
|
||||
}
|
||||
|
||||
H.mockEmbedJsToDevServer();
|
||||
@@ -47,7 +54,7 @@ describe(
|
||||
H.updateSetting(embeddingSettingName, false);
|
||||
H.updateSetting(showTermsSettingName, true);
|
||||
|
||||
cy.visit("/admin/embedding");
|
||||
cy.visit(path);
|
||||
|
||||
cy.findAllByTestId(cardTestId)
|
||||
.first()
|
||||
@@ -55,6 +62,8 @@ describe(
|
||||
cy.findByText("New embed").click();
|
||||
});
|
||||
|
||||
cy.findByLabelText(authMethodLabel).click();
|
||||
|
||||
embedModalEnableEmbeddingCard().within(() => {
|
||||
cy.findByText(cardText).should("exist");
|
||||
});
|
||||
@@ -111,7 +120,7 @@ describe(
|
||||
H.updateSetting(embeddingSettingName, true);
|
||||
H.updateSetting(showTermsSettingName, true);
|
||||
|
||||
cy.visit("/admin/embedding");
|
||||
cy.visit(path);
|
||||
|
||||
cy.findAllByTestId(cardTestId)
|
||||
.first()
|
||||
@@ -119,6 +128,8 @@ describe(
|
||||
cy.findByText("New embed").click();
|
||||
});
|
||||
|
||||
cy.findByLabelText(authMethodLabel).click();
|
||||
|
||||
embedModalEnableEmbeddingCard().within(() => {
|
||||
cy.findByText("Agree to the usage conditions to continue.").should(
|
||||
"exist",
|
||||
@@ -141,7 +152,7 @@ describe(
|
||||
H.updateSetting(embeddingSettingName, true);
|
||||
H.updateSetting(showTermsSettingName, false);
|
||||
|
||||
cy.visit("/admin/embedding");
|
||||
cy.visit(path);
|
||||
|
||||
cy.findAllByTestId(cardTestId)
|
||||
.first()
|
||||
@@ -149,7 +160,9 @@ describe(
|
||||
cy.findByText("New embed").click();
|
||||
});
|
||||
|
||||
cy.findByLabelText(authMethodLabel).click();
|
||||
|
||||
getEmbedSidebar().contains(cardText).should("not.exist");
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { embedModalEnableEmbeddingCard } from "e2e/support/helpers";
|
||||
|
||||
import { getEmbedSidebar } from "./helpers";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("scenarios > embedding > sdk iframe embed setup > enable embed js (oss and starter)", () => {
|
||||
describe("OSS", { tags: "@OSS" }, () => runTests({ token: null }));
|
||||
|
||||
describe("Starter", () => runTests({ token: "starter" }));
|
||||
|
||||
function runTests({ token }: { token: "starter" | null }) {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
|
||||
if (token) {
|
||||
H.activateToken(token);
|
||||
}
|
||||
|
||||
H.mockEmbedJsToDevServer();
|
||||
});
|
||||
|
||||
describe("guest", () => {
|
||||
it("shows the Enable to Continue button and enables embedding on click", () => {
|
||||
H.updateSetting("enable-embedding-static", false);
|
||||
H.updateSetting("show-static-embed-terms", true);
|
||||
|
||||
cy.visit("/admin/embedding");
|
||||
|
||||
cy.findAllByTestId("guest-embeds-setting-card")
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.findByText("New embed").click();
|
||||
});
|
||||
|
||||
embedModalEnableEmbeddingCard().within(() => {
|
||||
cy.findByText(
|
||||
"To continue, enable guest embeds and agree to the usage conditions.",
|
||||
).should("exist");
|
||||
});
|
||||
|
||||
cy.log("shows tooltip with fair usage info");
|
||||
embedModalEnableEmbeddingCard()
|
||||
.findByLabelText("info icon")
|
||||
.trigger("mouseover");
|
||||
|
||||
H.hovercard()
|
||||
.contains(
|
||||
/You should, however, read the license text linked above as that is the actual license that you will be agreeing to by enabling this feature/,
|
||||
)
|
||||
.should("be.visible");
|
||||
|
||||
embedModalEnableEmbeddingCard()
|
||||
.findByLabelText("info icon")
|
||||
.trigger("mouseout");
|
||||
|
||||
cy.findByRole("button", { name: "Agree and enable" }).should(
|
||||
"be.visible",
|
||||
);
|
||||
|
||||
cy.log("preview panel should show placeholder");
|
||||
cy.get('[alt="No results"]').should("be.visible");
|
||||
|
||||
cy.findByRole("button", { name: "Agree and enable" }).click();
|
||||
|
||||
cy.log("button should change to Enabled state");
|
||||
cy.findByRole("button", { name: /Enabled/ })
|
||||
.should("be.visible")
|
||||
.should("be.disabled");
|
||||
|
||||
// Going to the next step and selecting "Orders in a dashboard" explicitely
|
||||
// because sometimes it selects another one that's been used recently
|
||||
// see EMB-1106
|
||||
cy.log(
|
||||
"Navigating to embed flow step 2 and selecting an item to embed",
|
||||
);
|
||||
cy.findByRole("button", { name: "Next" }).click();
|
||||
cy.get('[data-testid="embed-recent-item-card"]')
|
||||
.should("have.length.greaterThan", 0)
|
||||
.contains("Orders in a dashboard")
|
||||
.click();
|
||||
|
||||
cy.log("Preview should load after embedding is enabled");
|
||||
H.waitForSimpleEmbedIframesToLoad();
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findByText("Orders in a dashboard", { timeout: 60_000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the enable card with fair usage terms when embedding is already enabled", () => {
|
||||
H.updateSetting("enable-embedding-static", true);
|
||||
H.updateSetting("show-static-embed-terms", true);
|
||||
|
||||
cy.visit("/admin/embedding");
|
||||
|
||||
cy.findAllByTestId("guest-embeds-setting-card")
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.findByText("New embed").click();
|
||||
});
|
||||
|
||||
embedModalEnableEmbeddingCard().within(() => {
|
||||
cy.findByText("Agree to the usage conditions to continue.").should(
|
||||
"exist",
|
||||
);
|
||||
|
||||
cy.findByText(
|
||||
"To continue, enable guest embeds and agree to the usage conditions.",
|
||||
).should("not.exist");
|
||||
});
|
||||
|
||||
cy.log("shows tooltip with fair usage info");
|
||||
getEmbedSidebar().findByLabelText("info icon").trigger("mouseover");
|
||||
|
||||
H.hovercard()
|
||||
.contains(
|
||||
/You should, however, read the license text linked above as that is the actual license that you will be agreeing to by enabling this feature/,
|
||||
)
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("hides the enable card when embedding is already enabled", () => {
|
||||
H.updateSetting("enable-embedding-static", true);
|
||||
H.updateSetting("show-static-embed-terms", false);
|
||||
|
||||
cy.visit("/admin/embedding");
|
||||
|
||||
cy.findAllByTestId("guest-embeds-setting-card")
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.findByText("New embed").click();
|
||||
});
|
||||
|
||||
getEmbedSidebar()
|
||||
.contains(
|
||||
"To continue, enable guest embeds and agree to the usage conditions.",
|
||||
)
|
||||
.should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Metabase account (sso)", () => {
|
||||
it("does not show the Enable to Continue button and disables item", () => {
|
||||
H.updateSetting("enable-embedding-simple", false);
|
||||
H.updateSetting("show-simple-embed-terms", true);
|
||||
|
||||
cy.visit("/admin/embedding");
|
||||
|
||||
cy.findAllByTestId("guest-embeds-setting-card")
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.findByText("New embed").click();
|
||||
});
|
||||
|
||||
embedModalEnableEmbeddingCard().within(() => {
|
||||
cy.findByText(
|
||||
"To continue, enable guest embeds and agree to the usage conditions.",
|
||||
).should("not.exist");
|
||||
});
|
||||
|
||||
cy.findByLabelText("Metabase account (SSO)").should("be.disabled");
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -56,6 +56,7 @@ describe(suiteTitle, () => {
|
||||
navigateToEmbedOptionsStep({
|
||||
experience: "dashboard",
|
||||
resourceName: "Dashboard with Parameters",
|
||||
preselectSso: true,
|
||||
});
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
@@ -85,6 +86,7 @@ describe(suiteTitle, () => {
|
||||
navigateToEmbedOptionsStep({
|
||||
experience: "dashboard",
|
||||
resourceName: "Dashboard with Parameters",
|
||||
preselectSso: true,
|
||||
});
|
||||
|
||||
cy.log("set default value for id");
|
||||
@@ -127,6 +129,7 @@ describe(suiteTitle, () => {
|
||||
navigateToEmbedOptionsStep({
|
||||
experience: "dashboard",
|
||||
resourceName: "Dashboard with Parameters",
|
||||
preselectSso: true,
|
||||
});
|
||||
|
||||
cy.log("hide both parameters");
|
||||
@@ -179,6 +182,7 @@ describe(suiteTitle, () => {
|
||||
navigateToEmbedOptionsStep({
|
||||
experience: "chart",
|
||||
resourceName: "Question with Parameters",
|
||||
preselectSso: true,
|
||||
});
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
@@ -223,6 +227,7 @@ describe(suiteTitle, () => {
|
||||
navigateToEmbedOptionsStep({
|
||||
experience: "dashboard",
|
||||
resourceName: "Orders in a dashboard",
|
||||
preselectSso: true,
|
||||
});
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
@@ -236,6 +241,7 @@ describe(suiteTitle, () => {
|
||||
navigateToEmbedOptionsStep({
|
||||
experience: "chart",
|
||||
resourceName: "Orders, Count",
|
||||
preselectSso: true,
|
||||
});
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
@@ -246,7 +252,10 @@ describe(suiteTitle, () => {
|
||||
});
|
||||
|
||||
it("should not show parameter settings for exploration template", () => {
|
||||
navigateToEntitySelectionStep({ experience: "exploration" });
|
||||
navigateToEntitySelectionStep({
|
||||
experience: "exploration",
|
||||
preselectSso: true,
|
||||
});
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.log("go to embed options step");
|
||||
|
||||
@@ -12,10 +12,7 @@ const { H } = cy;
|
||||
const DASHBOARD_NAME = "Orders in a dashboard";
|
||||
const QUESTION_NAME = "Orders, Count";
|
||||
|
||||
const suiteTitle =
|
||||
"scenarios > embedding > sdk iframe embed setup > get code step";
|
||||
|
||||
describe(suiteTitle, () => {
|
||||
describe("scenarios > embedding > sdk iframe embed setup > get code step", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
H.resetSnowplow();
|
||||
@@ -160,6 +157,18 @@ describe(suiteTitle, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should track embed_wizard_options_completed with settings=default properly (metabase#68285)", () => {
|
||||
navigateToGetCodeStep({
|
||||
experience: "chart",
|
||||
resourceName: QUESTION_NAME,
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_options_completed",
|
||||
event_detail: "settings=default",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not include useExistingUserSession when SSO is selected", () => {
|
||||
enableJwtAuth();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import {
|
||||
JWT_SHARED_SECRET,
|
||||
embedModalEnableEmbedding,
|
||||
entityPickerModal,
|
||||
getParametersContainer,
|
||||
} from "e2e/support/helpers";
|
||||
@@ -376,8 +377,13 @@ describe("scenarios > embedding > sdk iframe embed setup > guest-embed", () => {
|
||||
cy.findByText("Back").click();
|
||||
|
||||
cy.findByLabelText("Guest").should("be.visible").should("be.checked");
|
||||
cy.findByLabelText("Metabase account (SSO)").click();
|
||||
|
||||
cy.findByLabelText("Metabase account (SSO)").click();
|
||||
});
|
||||
|
||||
embedModalEnableEmbedding();
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText("Next").click();
|
||||
cy.findByText("Next").click();
|
||||
cy.findByText("Get code").click();
|
||||
|
||||
@@ -36,15 +36,10 @@ export const visitNewEmbedPage = (
|
||||
cy.findByText("New embed").click();
|
||||
});
|
||||
|
||||
cy.get("body").then(($body) => {
|
||||
const isEmbeddingDisabled =
|
||||
$body.find('[data-testid="enable-embedding-card"]').length > 0;
|
||||
|
||||
if (isEmbeddingDisabled) {
|
||||
embedModalEnableEmbedding();
|
||||
}
|
||||
|
||||
cy.get("body").then(() => {
|
||||
if (waitForResource) {
|
||||
embedModalEnableEmbedding();
|
||||
|
||||
cy.wait("@dashboard");
|
||||
|
||||
cy.get("[data-iframe-loaded]", { timeout: 20000 }).should(
|
||||
@@ -103,6 +98,7 @@ export const navigateToEntitySelectionStep = (
|
||||
|
||||
if (preselectSso || !isQuestionOrDashboardExperience) {
|
||||
cy.findByLabelText("Metabase account (SSO)").click();
|
||||
embedModalEnableEmbedding();
|
||||
}
|
||||
|
||||
const labelByExperience = match(experience)
|
||||
|
||||
@@ -3,7 +3,10 @@ import {
|
||||
ORDERS_COUNT_QUESTION_ID,
|
||||
ORDERS_DASHBOARD_ID,
|
||||
} from "e2e/support/cypress_sample_instance_data";
|
||||
import { mockEmbedJsToDevServer } from "e2e/support/helpers";
|
||||
import {
|
||||
embedModalEnableEmbedding,
|
||||
mockEmbedJsToDevServer,
|
||||
} from "e2e/support/helpers";
|
||||
|
||||
import {
|
||||
getEmbedSidebar,
|
||||
@@ -221,7 +224,11 @@ describe(suiteTitle, () => {
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByLabelText("Metabase account (SSO)").click();
|
||||
});
|
||||
|
||||
embedModalEnableEmbedding();
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText("Browser").click();
|
||||
cy.findByText("Next").click();
|
||||
cy.findByText("Select a collection to embed").should("be.visible");
|
||||
@@ -304,7 +311,11 @@ describe(suiteTitle, () => {
|
||||
it("can open a collection picker from browser empty state", () => {
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByLabelText("Metabase account (SSO)").click();
|
||||
});
|
||||
|
||||
embedModalEnableEmbedding();
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText("Browser").click();
|
||||
cy.findByText("Next").click();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ORDERS_COUNT_QUESTION_ID,
|
||||
ORDERS_QUESTION_ID,
|
||||
} from "e2e/support/cypress_sample_instance_data";
|
||||
import { embedModalEnableEmbedding } from "e2e/support/helpers";
|
||||
|
||||
import {
|
||||
assertDashboard,
|
||||
@@ -66,48 +67,6 @@ describe(suiteTitle, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the `guest embeds disabled` icon + tooltip when guest embeds disabled", () => {
|
||||
H.updateSetting("enable-embedding-static", false);
|
||||
|
||||
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
|
||||
|
||||
visitNewEmbedPage({ waitForResource: false });
|
||||
|
||||
cy.url().should("not.match", /\/admin\/embedding\/guest/);
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByTestId("guest-embeds-disabled-info-icon").should("be.visible");
|
||||
cy.findByTestId("guest-embeds-disabled-info-icon").click();
|
||||
});
|
||||
|
||||
H.hovercard()
|
||||
.findByText(/You can enable guest embeds in/)
|
||||
.should("exist");
|
||||
|
||||
H.hovercard()
|
||||
.findByText(/admin settings/)
|
||||
.click();
|
||||
|
||||
cy.findAllByTestId(/(sdk-setting-card|guest-embeds-setting-card)/)
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.url().should("match", /\/admin\/embedding\/guest/);
|
||||
cy.findByText("Enable guest embeds").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the `guest embeds disabled` icon + tooltip when guest embeds enabled", () => {
|
||||
H.updateSetting("enable-embedding-static", true);
|
||||
|
||||
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
|
||||
|
||||
visitNewEmbedPage({ waitForResource: false });
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByTestId("guest-embeds-disabled-info-icon").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the most recent question from the activity log when selected", () => {
|
||||
const questionName = "Orders, Count";
|
||||
|
||||
@@ -165,7 +124,11 @@ describe(suiteTitle, () => {
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByLabelText("Metabase account (SSO)").click();
|
||||
});
|
||||
|
||||
embedModalEnableEmbedding();
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText("Browser").click();
|
||||
cy.findByText("Next").click();
|
||||
});
|
||||
@@ -307,7 +270,11 @@ describe(suiteTitle, () => {
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByLabelText("Metabase account (SSO)").click();
|
||||
});
|
||||
|
||||
embedModalEnableEmbedding();
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText("Metabot").click();
|
||||
cy.findByText("Next").click();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { mockEmbedJsToDevServer } from "e2e/support/helpers";
|
||||
import {
|
||||
embedModalEnableEmbedding,
|
||||
mockEmbedJsToDevServer,
|
||||
} from "e2e/support/helpers";
|
||||
|
||||
import {
|
||||
codeBlock,
|
||||
@@ -208,7 +211,11 @@ describe(suiteTitle, () => {
|
||||
cy.button("Back").click();
|
||||
|
||||
cy.findByLabelText("Metabase account (SSO)").click();
|
||||
});
|
||||
|
||||
embedModalEnableEmbedding();
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.button("Next").click();
|
||||
cy.button("Next").click();
|
||||
|
||||
|
||||
@@ -166,7 +166,8 @@ describe("scenarios > embedding > sdk iframe embed options passthrough", () => {
|
||||
);
|
||||
|
||||
cy.log("2. clicking on the column value should not show the popover");
|
||||
cy.findAllByText("37.65").first().should("be.visible").click();
|
||||
cy.findAllByText("37.65").first().should("be.visible");
|
||||
cy.findAllByText("37.65").first().click();
|
||||
cy.findByText(/Filter by this value/).should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,6 +60,13 @@ const assertEditorDiffState = (opts: { exists: boolean }) => {
|
||||
.should(should);
|
||||
};
|
||||
|
||||
const sendCodgenBotMessage = (message: string) => {
|
||||
H.sendMetabotMessage(message);
|
||||
cy.wait("@metabotAgent").its("request.body").should("deep.include", {
|
||||
profile_id: "transforms_codegen",
|
||||
});
|
||||
};
|
||||
|
||||
const assertAcceptRejectUI = (opts: { visible: boolean }) => {
|
||||
const should = opts.visible ? "be.visible" : "not.exist";
|
||||
acceptSuggestionBtn().should(should);
|
||||
@@ -96,7 +103,7 @@ describe(
|
||||
createMockNativeTransformJSON(null, WRITABLE_DB_ID, "SELECT 1"),
|
||||
),
|
||||
});
|
||||
H.sendMetabotMessage(
|
||||
sendCodgenBotMessage(
|
||||
"Create a new native SQL transform that gives me the number 1",
|
||||
);
|
||||
assertSuggestionInSidebar({ newSourcePartial: "SELECT 1" });
|
||||
@@ -115,7 +122,7 @@ describe(
|
||||
createMockNativeTransformJSON(null, WRITABLE_DB_ID, "SELECT 2"),
|
||||
),
|
||||
});
|
||||
H.sendMetabotMessage("Make this give me the number 2 instead");
|
||||
sendCodgenBotMessage("Make this give me the number 2 instead");
|
||||
assertSuggestionInSidebar({
|
||||
oldSourcePartial: "SELECT 1",
|
||||
newSourcePartial: "SELECT 2",
|
||||
@@ -137,7 +144,7 @@ describe(
|
||||
createMockNativeTransformJSON(null, WRITABLE_DB_ID, "SELECT 4"),
|
||||
),
|
||||
});
|
||||
H.sendMetabotMessage("Make this give me the number 4 instead");
|
||||
sendCodgenBotMessage("Make this give me the number 4 instead");
|
||||
assertSuggestionInSidebar({
|
||||
oldSourcePartial: "SELECT 3",
|
||||
newSourcePartial: "SELECT 4",
|
||||
@@ -166,7 +173,7 @@ describe(
|
||||
),
|
||||
),
|
||||
});
|
||||
H.sendMetabotMessage(
|
||||
sendCodgenBotMessage(
|
||||
"Create a new native python transform that gives me the number 1",
|
||||
);
|
||||
assertSuggestionInSidebar({
|
||||
@@ -192,7 +199,7 @@ describe(
|
||||
),
|
||||
),
|
||||
});
|
||||
H.sendMetabotMessage("Make this give me the number 2 instead");
|
||||
sendCodgenBotMessage("Make this give me the number 2 instead");
|
||||
assertSuggestionInSidebar({
|
||||
oldSourcePartial: "pd.DataFrame({'value': [1]})",
|
||||
newSourcePartial: "pd.DataFrame({'value': [2]})",
|
||||
@@ -226,7 +233,7 @@ describe(
|
||||
),
|
||||
),
|
||||
});
|
||||
H.sendMetabotMessage("Make this give me the number 4 instead");
|
||||
sendCodgenBotMessage("Make this give me the number 4 instead");
|
||||
assertSuggestionInSidebar({
|
||||
oldSourcePartial: "pd.DataFrame({'value': [3]})",
|
||||
newSourcePartial: "pd.DataFrame({'value': [4]})",
|
||||
@@ -274,7 +281,7 @@ describe(
|
||||
),
|
||||
),
|
||||
});
|
||||
H.sendMetabotMessage(
|
||||
sendCodgenBotMessage(
|
||||
"Create a transform that queries the Test Model",
|
||||
);
|
||||
assertSuggestionInSidebar({ newSourcePartial: "SELECT * FROM" });
|
||||
@@ -319,7 +326,7 @@ describe(
|
||||
),
|
||||
});
|
||||
});
|
||||
H.sendMetabotMessage(
|
||||
sendCodgenBotMessage(
|
||||
"Update my SQL transform to select 2 instead of 1.",
|
||||
);
|
||||
assertSuggestionInSidebar({
|
||||
@@ -352,7 +359,7 @@ describe(
|
||||
),
|
||||
});
|
||||
});
|
||||
H.sendMetabotMessage("Make this give me the number 4 instead");
|
||||
sendCodgenBotMessage("Make this give me the number 4 instead");
|
||||
assertSuggestionInSidebar({
|
||||
oldSourcePartial: "SELECT 3",
|
||||
newSourcePartial: "SELECT 4",
|
||||
@@ -396,7 +403,7 @@ describe(
|
||||
),
|
||||
});
|
||||
});
|
||||
H.sendMetabotMessage(
|
||||
sendCodgenBotMessage(
|
||||
"Update my SQL transform to select 2 instead of 1.",
|
||||
);
|
||||
assertSuggestionInSidebar({
|
||||
@@ -438,7 +445,7 @@ describe(
|
||||
),
|
||||
),
|
||||
});
|
||||
H.sendMetabotMessage("Make this give me the number 4 instead");
|
||||
sendCodgenBotMessage("Make this give me the number 4 instead");
|
||||
assertSuggestionInSidebar({
|
||||
oldSourcePartial: "pd.DataFrame({'value': [3]})",
|
||||
newSourcePartial: "pd.DataFrame({'value': [4]})",
|
||||
|
||||
@@ -435,7 +435,7 @@ describe("issue 14302", () => {
|
||||
expect(xhr.response.body.error).not.to.exist;
|
||||
});
|
||||
|
||||
H.NativeEditor.get().should("not.be.visible");
|
||||
H.NativeEditor.get().should("not.exist");
|
||||
cy.get("[data-testid=cell-data]").should("contain", "51");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Showing 1 row");
|
||||
|
||||
@@ -682,8 +682,7 @@ describe("issue 59110", () => {
|
||||
|
||||
H.NativeEditor.get().then((editor) => {
|
||||
const { height } = editor[0].getBoundingClientRect();
|
||||
const SLOPPY_CLICK_THRESHOLD = 30;
|
||||
const diff = height - SLOPPY_CLICK_THRESHOLD;
|
||||
const diff = height + 20;
|
||||
|
||||
cy.log("drag the border to hide the editor");
|
||||
|
||||
@@ -698,12 +697,11 @@ describe("issue 59110", () => {
|
||||
.trigger("mousemove", {
|
||||
clientX: coordsDrag.x,
|
||||
clientY: coordsDrag.y - diff,
|
||||
})
|
||||
.trigger("mouseup");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
H.NativeEditor.get().should("not.be.visible");
|
||||
H.NativeEditor.get().should("not.exist");
|
||||
cy.findByTestId("visibility-toggler")
|
||||
.findByText(/open editor/i)
|
||||
.should("be.visible")
|
||||
@@ -762,7 +760,9 @@ describe("issue 60719", () => {
|
||||
|
||||
describe("issue 59356", () => {
|
||||
function typeRunShortcut() {
|
||||
cy.get("body").type("{ctrl+enter}{cmd+enter}");
|
||||
const isMac = Cypress.platform === "darwin";
|
||||
const metaKey = isMac ? "Meta" : "Control";
|
||||
cy.realPress([metaKey, "Enter"]);
|
||||
}
|
||||
|
||||
function getLoader() {
|
||||
@@ -974,3 +974,43 @@ describe("issue 51717", () => {
|
||||
cy.get("@infoSidebar").should("contain", "You created this");
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 59075", () => {
|
||||
const WINDOW_HEIGHT = 1000;
|
||||
const BUTTON_INDEX = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsNormalUser();
|
||||
|
||||
H.startNewNativeQuestion();
|
||||
cy.viewport(1024, WINDOW_HEIGHT);
|
||||
});
|
||||
|
||||
it("should not be possible to resize the native query editor too far (metabase#59075)", () => {
|
||||
cy.findByTestId("drag-handle").then((handle) => {
|
||||
const coordsDrag = handle[0].getBoundingClientRect();
|
||||
|
||||
cy.wrap(handle)
|
||||
.trigger("mousedown", {
|
||||
button: BUTTON_INDEX,
|
||||
clientX: coordsDrag.x,
|
||||
clientY: coordsDrag.y,
|
||||
force: true,
|
||||
})
|
||||
// Drag to the bottom of the screen
|
||||
.trigger("mousemove", {
|
||||
button: BUTTON_INDEX,
|
||||
clientX: coordsDrag.x,
|
||||
clientY: WINDOW_HEIGHT + 10,
|
||||
force: true,
|
||||
})
|
||||
.trigger("mouseup");
|
||||
});
|
||||
|
||||
H.NativeEditor.get().then((editor) => {
|
||||
const { bottom } = editor.get()[0].getBoundingClientRect();
|
||||
cy.wrap(bottom).should("be.lessThan", WINDOW_HEIGHT - 50);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("scenarios > auth > signin > SSO", () => {
|
||||
cy.findByLabelText("Password").type("123");
|
||||
cy.button("Sign in").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("Password: did not match stored password");
|
||||
cy.contains("did not match stored password");
|
||||
});
|
||||
|
||||
it("should pass `redirect` search params from Google button screen to email/password screen (metabase#16216)", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const { H } = cy;
|
||||
|
||||
import { WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DB_ID, WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import type {
|
||||
@@ -1421,17 +1421,36 @@ describe("issue #47005", () => {
|
||||
H.restore("postgres-12");
|
||||
cy.signInAsNormalUser();
|
||||
|
||||
H.createQuestion({
|
||||
H.createCardWithQuery({
|
||||
name: "Question A",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
stages: [
|
||||
{
|
||||
source: {
|
||||
type: "table",
|
||||
id: ORDERS_ID,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}).then(({ body: question }) => {
|
||||
H.createQuestion(
|
||||
H.createCardWithQuery(
|
||||
{
|
||||
name: "Question B",
|
||||
metadata: {
|
||||
cardIds: [question.id],
|
||||
},
|
||||
query: {
|
||||
"source-table": "card__" + question.id,
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
stages: [
|
||||
{
|
||||
source: {
|
||||
type: "card",
|
||||
id: question.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ visitQuestion: true },
|
||||
@@ -1454,13 +1473,30 @@ describe("issue 66210", () => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
|
||||
H.createQuestion({
|
||||
H.createCardWithQuery({
|
||||
name: METRIC_NAME,
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
},
|
||||
type: "metric",
|
||||
query: {
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
stages: [
|
||||
{
|
||||
source: {
|
||||
type: "table",
|
||||
id: ORDERS_ID,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
name: "Count",
|
||||
value: {
|
||||
type: "operator",
|
||||
operator: "count",
|
||||
args: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
cy.visit("/");
|
||||
@@ -1477,3 +1513,102 @@ describe("issue 66210", () => {
|
||||
H.entityPickerModalLevel(1).findByText(METRIC_NAME).should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue #67903", () => {
|
||||
beforeEach(() => {
|
||||
cy.viewport(1000, 800);
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
|
||||
it("shoult not show preview table headers on top of other elements (metabase#67903)", () => {
|
||||
H.startNewQuestion();
|
||||
H.miniPickerBrowseAll().click();
|
||||
H.entityPickerModalItem(1, "Orders").click();
|
||||
H.getNotebookStep("data").findByTestId("step-preview-button").click();
|
||||
H.queryBuilderHeader().findByLabelText("View SQL").click();
|
||||
cy.findByTestId("table-header").should("not.be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 68574", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
|
||||
const questionDetails: NativeQuestionDetails = {
|
||||
name: "Question 1",
|
||||
native: {
|
||||
query: "SELECT * FROM ORDERS WHERE CREATED_AT > {{ start }}",
|
||||
"template-tags": {
|
||||
start: {
|
||||
type: "date",
|
||||
name: "start",
|
||||
"display-name": "Start",
|
||||
id: "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: [
|
||||
createMockParameter({
|
||||
id: "1",
|
||||
slug: "start",
|
||||
required: true,
|
||||
name: "Start",
|
||||
type: "date/single",
|
||||
target: ["variable", ["template-tag", "start"]],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
H.createNativeQuestion(questionDetails, { wrapId: true });
|
||||
});
|
||||
|
||||
it("should be possible to run a query for a empty required parameter without a default value (metabase#68574)", () => {
|
||||
updateFormattingSettings({
|
||||
date_style: "D MMMM, YYYY",
|
||||
date_abbreviate: false,
|
||||
});
|
||||
visitQuestion("2024-01-01");
|
||||
assertParameterFormat("1 January, 2024");
|
||||
|
||||
cy.log("change the date format");
|
||||
updateFormattingSettings({
|
||||
date_style: "dddd, MMMM D, YYYY",
|
||||
date_abbreviate: false,
|
||||
});
|
||||
visitQuestion("2024-01-01");
|
||||
assertParameterFormat("Monday, January 1, 2024");
|
||||
|
||||
cy.log("enable date abbreviation");
|
||||
updateFormattingSettings({
|
||||
date_style: "dddd, MMMM D, YYYY",
|
||||
date_abbreviate: true,
|
||||
});
|
||||
visitQuestion("2024-01-01");
|
||||
assertParameterFormat("Mon, Jan 1, 2024");
|
||||
|
||||
cy.log("even when the setting is unset, it should render a valid format");
|
||||
updateFormattingSettings(undefined);
|
||||
visitQuestion("2024-01-01");
|
||||
assertParameterFormat("January 1, 2024");
|
||||
});
|
||||
|
||||
function updateFormattingSettings(settings: any) {
|
||||
H.updateSetting("custom-formatting", {
|
||||
"type/Temporal": settings,
|
||||
});
|
||||
}
|
||||
|
||||
function visitQuestion(value: string) {
|
||||
cy.get("@questionId").then((id) => {
|
||||
cy.visit(`/question/${id}?start=${value}`);
|
||||
});
|
||||
}
|
||||
|
||||
function assertParameterFormat(value: string) {
|
||||
cy.findByTestId("parameter-value-widget-target")
|
||||
.should("be.visible")
|
||||
.should("contain.text", value);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -487,6 +487,10 @@ describe("scenarios > dashboard > download pdf", () => {
|
||||
});
|
||||
|
||||
H.openSharingMenu("Export as PDF");
|
||||
cy.findByTestId("status-root-container")
|
||||
.should("contain", "Downloading")
|
||||
.and("contain", `Dashboard for saving pdf dashboard - ${date}`);
|
||||
|
||||
cy.log("We're adding a 'Metabase-' prefix for non-whitelabelled instances");
|
||||
cy.verifyDownload(`Metabase - saving pdf dashboard - ${date}.pdf`);
|
||||
});
|
||||
@@ -512,6 +516,10 @@ describe("[snowplow] scenarios > dashboard", () => {
|
||||
H.visitDashboard(dashboard.id);
|
||||
H.openSharingMenu("Export as PDF");
|
||||
|
||||
cy.findByTestId("status-root-container")
|
||||
.should("contain", "Downloading")
|
||||
.and("contain", "Dashboard for test dashboard");
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "dashboard_pdf_exported",
|
||||
dashboard_id: dashboard.id,
|
||||
|
||||
@@ -181,6 +181,8 @@ describe("Embed JS modal display", () => {
|
||||
|
||||
H.openSharingMenu("Embed");
|
||||
|
||||
cy.findByLabelText("Metabase account (SSO)").click();
|
||||
|
||||
H.embedModalEnableEmbeddingCard().within(() => {
|
||||
cy.findByText(/modular embedding/).should("be.visible");
|
||||
});
|
||||
@@ -196,8 +198,9 @@ describe("Embed JS modal display", () => {
|
||||
H.visitDashboard("@dashboardId");
|
||||
H.openSharingMenu("Embed");
|
||||
|
||||
it("should open Embed JS modal with the `enable simple embedding` card", () => {
|
||||
it("should open Embed JS modal with the `enable guest embedding` card", () => {
|
||||
H.activateToken("pro-self-hosted");
|
||||
H.updateSetting("enable-embedding-static", false);
|
||||
H.visitDashboard("@dashboardId");
|
||||
|
||||
H.openSharingMenu("Embed");
|
||||
|
||||
@@ -2,6 +2,7 @@ const { H } = cy;
|
||||
import { USERS } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import { embedModalEnableEmbedding } from "e2e/support/helpers";
|
||||
|
||||
const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE;
|
||||
const { admin, normal } = USERS;
|
||||
@@ -803,8 +804,8 @@ describe("scenarios > dashboard > subscriptions", () => {
|
||||
|
||||
H.openSharingMenu();
|
||||
H.sharingMenu().findByRole("menuitem", { name: "Embed" }).click();
|
||||
cy.findByRole("button", { name: "Agree and enable" }).click();
|
||||
cy.findByLabelText("Metabase account (SSO)").click();
|
||||
embedModalEnableEmbedding();
|
||||
cy.findByLabelText("Allow subscriptions").check().should("be.checked");
|
||||
H.getIframeBody().within(() => {
|
||||
cy.button("Subscriptions").click();
|
||||
|
||||
@@ -1120,6 +1120,9 @@ describe("scenarios > visualizations > bar chart", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Open the data reference sidebar to squish the data further
|
||||
cy.findByLabelText("Learn about your data").click();
|
||||
|
||||
cy.wait("@dataset");
|
||||
H.echartsContainer().should("be.visible");
|
||||
|
||||
|
||||
@@ -696,6 +696,99 @@ describe("scenarios > visualizations > table > dashboards context", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should update row heights correctly when sorting with text wrapping enabled (metabase#61164)", () => {
|
||||
// This test verifies that when sorting changes, row heights are recalculated
|
||||
// based on the new row content at each position (not the old cached heights)
|
||||
H.createQuestionAndDashboard({
|
||||
questionDetails: {
|
||||
name: "reviews for sorting test",
|
||||
type: "model",
|
||||
query: {
|
||||
"source-table": SAMPLE_DATABASE.REVIEWS_ID,
|
||||
limit: 10,
|
||||
},
|
||||
visualization_settings: {
|
||||
"table.column_widths": [200, 100, 100, 100, 100],
|
||||
column_settings: {
|
||||
'["name","BODY"]': {
|
||||
text_wrapping: true,
|
||||
},
|
||||
},
|
||||
"table.columns": [
|
||||
{
|
||||
name: "BODY",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "RATING",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "ID",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "PRODUCT_ID",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "REVIEWER",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
dashboardDetails: {
|
||||
name: "Dashboard",
|
||||
},
|
||||
cardDetails: {
|
||||
size_x: 24,
|
||||
size_y: 12,
|
||||
},
|
||||
}).then(({ body: { dashboard_id } }) => {
|
||||
H.visitDashboard(dashboard_id);
|
||||
|
||||
// Wait for table to render, then sort and verify rows don't overlap
|
||||
H.tableInteractive()
|
||||
.find("[data-index=0]")
|
||||
.should("exist")
|
||||
.then(() => {
|
||||
H.tableHeaderClick("Rating");
|
||||
|
||||
// Verify rows don't overlap by checking their bounding rects
|
||||
H.tableInteractive()
|
||||
.find("[role=row]")
|
||||
.then(($rows) => {
|
||||
const rects = $rows
|
||||
.toArray()
|
||||
.map((row) => row.getBoundingClientRect())
|
||||
.sort((a, b) => a.top - b.top);
|
||||
|
||||
// Each row's top should equal the previous row's bottom (no overlap)
|
||||
for (let i = 1; i < rects.length; i++) {
|
||||
expect(rects[i].top).to.equal(rects[i - 1].bottom);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort again (descending) to verify heights update on subsequent sorts
|
||||
H.tableHeaderClick("Rating");
|
||||
|
||||
H.tableInteractive()
|
||||
.find("[role=row]")
|
||||
.then(($rows) => {
|
||||
const rects = $rows
|
||||
.toArray()
|
||||
.map((row) => row.getBoundingClientRect())
|
||||
.sort((a, b) => a.top - b.top);
|
||||
|
||||
for (let i = 1; i < rects.length; i++) {
|
||||
expect(rects[i].top).to.equal(rects[i - 1].bottom);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should support the row index setting", () => {
|
||||
H.visitDashboard(ORDERS_DASHBOARD_ID);
|
||||
H.editDashboard();
|
||||
|
||||
@@ -65,14 +65,16 @@
|
||||
"Adds to `user` a set of boolean flags indicating whether or not current user has access to advanced permissions.
|
||||
This function is meant to be used for GET /api/user/current."
|
||||
[user]
|
||||
(let [permissions-set @api/*current-user-permissions-set*
|
||||
user-id api/*current-user-id*]
|
||||
(let [permissions-set @api/*current-user-permissions-set*
|
||||
user-id api/*current-user-id*
|
||||
can-access-data-model (perms/user-has-any-perms-of-type? user-id :perms/manage-table-metadata)]
|
||||
(update user :permissions assoc
|
||||
:can_access_setting (perms/set-has-application-permission-of-type? permissions-set :setting)
|
||||
:can_access_subscription (perms/set-has-application-permission-of-type? permissions-set :subscription)
|
||||
:can_access_monitoring (perms/set-has-application-permission-of-type? permissions-set :monitoring)
|
||||
:can_access_data_model (perms/user-has-any-perms-of-type? user-id :perms/manage-table-metadata)
|
||||
:can_access_data_model can-access-data-model
|
||||
:can_access_db_details (perms/user-has-any-perms-of-type? user-id :perms/manage-database)
|
||||
:is_data_analyst api/*is-data-analyst?*
|
||||
:is_group_manager api/*is-group-manager?*)))
|
||||
|
||||
(defenterprise current-user-has-application-permissions?
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{:name :sso-saml
|
||||
:available (premium-features/enable-sso-saml?)
|
||||
:enabled (sso-settings/saml-enabled)}
|
||||
{:name :sso-slack
|
||||
:available (premium-features/enable-sso-slack?)
|
||||
:enabled (sso-settings/slack-connect-enabled)}
|
||||
{:name :scim
|
||||
:available (premium-features/enable-scim?)
|
||||
:enabled (boolean (scim/scim-enabled))}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user