Compare commits

...

4 Commits

Author SHA1 Message Date
Fraser
afa79587c2 Lint without building cljs (#83718) 2026-10-07 16:06:33 +13:00
Romeo Van Snick
2b271c6987 Remove unreferenced images from app/assets/img and app/img (#82788)
* Remove unreferenced static images

* Remove unreferenced images from app/img

* Remove four more unreferenced images kept by a loose name match
2026-10-07 14:03:22 +11:00
Timothy S. Dean
37d5c5b042 Fix the revision history of an edited transform test (#83818) 2026-10-06 18:19:47 -06:00
Maz Ameli
30d6158ea1 Replace dashboard loading spinner with an instant skeleton (DSN-749) (#83288)
* Replace dashboard loading spinner with an instant skeleton (DSN-749)

Dashboards no longer show a loading spinner when opened. Instead they render
a skeleton of the dashboard shell and card layout instantly:

- New DashboardSkeleton renders the header chrome (title, action buttons,
  tabs), a full-width border, a secondary-background body, filter-widget
  placeholders, and the card grid constrained to a centered 1024px column.
- New DashboardGridSkeleton draws the card grid. When the layout is cached
  in Redux from a prior visit (getLastSeenTabDashcards), it reproduces
  react-grid-layout's real geometry so the cards land where the real ones
  will; heading/text cards render title-style line skeletons. Otherwise a
  generic placeholder layout is used.
- Grid no longer shows a spinner while refetching; the header no longer
  shows a spinner while its collection query loads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Draw the real dashboard layout before query metadata loads (DSN-749)

fetchDashboard awaited both GET /api/dashboard/:id and the slower
/query_metadata before any of the dashboard reached the store, even though
the GET alone already holds the card layout. On a fresh open it now
publishes the GET response's normalized dashboard and dashcards as soon as
it arrives (dashboardLayoutFetched), so the loading skeleton switches from
the generic layout to the dashboard's real one without waiting for
metadata. The full load transition is unchanged, and a refetch of a
dashboard already on screen publishes nothing early.

The virtual-card copy is extracted into copyVirtualCardsToCards so the
early layout and the fulfilled payload are built the same way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Render dashboard skeletons inside the real dashboard layout (DSN-749)

Replace the separate full-page dashboard skeleton with skeletons that the
header, filter panel and card grid each render while the dashboard loads,
so the surrounding page, widths and theming are the real ones and can't
drift.

- Grid renders DashboardGridSkeleton when there's no dashboard yet. This
  also covers public links, static embeds, X-rays and SDK dashboards, which
  showed "This dashboard is empty" while loading. Its spinner is restored
  for refetches of an already-open dashboard.
- The skeletons read the dashboard as last seen in the in-memory Redux
  cache: its width (assuming the default fixed width when it isn't
  cached), its tabs (a tab row only for two or more), the cards of the tab
  the user is landing on, and its header filters (excluding inline and
  hidden ones).
- The grid skeleton takes its geometry from the grid constants, including
  a new shared GRID_MARGINS, and drops the heightPx special case.
- The skeletons are marked aria-busy, and the grid announces "Loading…".
- waitForDashboardToLoad waits for a loading dashboard in tests, since
  waitForLoaderToBeRemoved no longer does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Draw no placeholder cards until the dashboard's layout is known (DSN-749)

While a dashboard that isn't in the in-memory Redux cache loads, the grid
skeleton used to draw a generic layout of placeholder cards, which rarely
matched the dashboard about to appear. It now draws no cards until the
dashboard's real layout is known, so the content area stays empty under
the header skeleton and then fills with the real layout. The grid still
marks itself busy and announces "Loading…".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Size the dashboard header skeleton from the real title (DSN-749)

The header skeleton drew a bare title bar, but the real title area is
taller: the title is an EditableText box with its own padding, border and
line height, and the last-edit label sits on a row beneath it. The header
grew when the dashboard arrived, pushing the page down.

The title and label bars now sit over invisible copies of the real
elements, so the skeleton takes their exact height without copying their
styles. The label row is only reserved when the real header would show
it; a dashboard that isn't cached is assumed to have an edit history.

Also replace a file path in a CSS comment, and note in dashboard_grid.ts
that the grid skeleton's mobile container query repeats MOBILE_BREAKPOINT.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-07 00:06:34 +00:00
98 changed files with 1595 additions and 378 deletions

View File

@@ -1,6 +1,6 @@
## Linting and Formatting
- **Lint:** `bun run lint-oxlint-pure`
- **Lint:** `bun run lint-oxlint`
- Run oxlint on the codebase
- **Format:** `bun run format`
- Format code with oxfmt (`oxfmt --write`); use `bun run lint-format-pure` to check without writing

View File

@@ -39,7 +39,7 @@ what and who may depend on whom.**
| module elements, tiers, `enforceSharedTiers`, `enforcePublicApi` | `frontend/lint/module-boundaries.mjs` |
| shared sub-tiers and levels, cluster rules | `frontend/lint/shared-tiers.mjs` |
| standalone boundaries lint (all violations, incl. grandfathered) | `bun run module-boundaries` (config `eslint.config.module-boundaries.mjs`) |
| PR lint (only enforced modules fail CI) | `bun run lint-oxlint-pure` |
| PR lint (only enforced modules fail CI) | `bun run lint-oxlint` |
| side-effect-free directories and their exceptions | `frontend/build/shared/rspack/side-effect-free-modules.js` (`SIDE_EFFECT_FREE_PATHS`, `SIDE_EFFECT_PATHS`) |
| the lint rules that enforce that promise | `metabase/no-module-side-effects`, `metabase/no-base-api-access` in `frontend/lint/eslint-plugin-metabase/rules/` |
| public-api enforcement | `metabase/enforce-module-public-api` rule, driven by `getPublicApiModules()` |
@@ -199,7 +199,7 @@ prefix; never generic `-shared` / `-feature` suffixes.
```
grep -rn "<old path>" frontend enterprise/frontend e2e # must be empty
bunx eslint --no-warn-ignored <touched files>
bun run lint-oxlint-pure # enforce-module-public-api included
bun run lint-oxlint # enforce-module-public-api included
bun run type-check-pure
bun run test-unit-keep-cljs <touched folders' specs>
bun run module-boundaries 2>&1 | tail -1 # after; no violation may name the new files

View File

@@ -172,7 +172,7 @@ export const OverviewStory = {
- **Scope controls per story.** A static matrix should hide knobs it ignores: `parameters.controls = { include: [...] }` (or `{ disable: true }`). Wire any kept knob (e.g. `children`) through to the cells so it does something.
- `loki.config.js` has a `storiesFilter`; check it before renaming a story that's already covered.
Validate: `bun run lint-oxlint-pure -- <files>` and `bun run type-check-pure`.
Validate: `bun run lint-oxlint -- <files>` and `bun run type-check-pure`.
### Optional: verify in the browser yourself
@@ -217,7 +217,7 @@ General gotchas when extending Mantine components — adapt per component:
- **Built-in icons use `currentColor`**, so they inherit the slot's text color.
- For text on a brand fill, follow the existing convention (`text-primary-inverse`) unless the user wants pure white; flag the dark-theme implication.
After edits: `npx stylelint <css>`, `LINT_CSS_MODULES=true bun run lint-oxlint-pure -- <files>`, `bun run type-check-pure`.
After edits: `npx stylelint <css>`, `LINT_CSS_MODULES=true bun run lint-oxlint -- <files>`, `bun run type-check-pure`.
## Phase 5 — Iterate with the user

View File

@@ -38,17 +38,13 @@ jobs:
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Compile CLJS
run: bun run build-pure:cljs
- name: Find CSS variables that are used but never defined
run: bun run find-never-defined-css-variables
- name: Run oxfmt formatting linter
run: bun run lint-format-pure
- name: Run oxlint linter (including module boundaries)
run: bun run lint-oxlint-pure
run: bun run lint-oxlint
- name: Verify lint configuration and behavior
run: bun run test-oxlint
- name: Run CSS linter

View File

@@ -178,7 +178,7 @@ The first rule of frontend style, is we want to avoid talking about frontend sty
We use [oxfmt](https://oxc.rs/) to format our JavaScript and TypeScript code, and it is enforced by CI. We recommend setting your editor to "format on save". You can also format code using `bun run format`, and verify it has been formatted correctly using `bun run lint-format-pure`. Oxfmt also sorts imports, so `bun run format` fixes import order too.
We use [oxlint](https://oxc.rs/) to enforce JavaScript and TypeScript lint rules, including module boundaries. Run `bun run lint-oxlint` to lint, or `bun run lint-oxlint-fix` to apply lint fixes. Both commands build the CLJS output first. If you've already built it, `bun run lint-oxlint-pure` skips that step. For how the lint configuration works, see `frontend/lint/OXLINT.md`.
We use [oxlint](https://oxc.rs/) to enforce JavaScript and TypeScript lint rules, including module boundaries. Run `bun run lint-oxlint` to lint, or `bun run lint-oxlint-fix` to apply lint fixes. For how the lint configuration works, see `frontend/lint/OXLINT.md`.
### Miscellaneous notes on coding style

View File

@@ -6,6 +6,7 @@
[metabase-enterprise.transform-testing.run-tracking :as transform-testing.run-tracking]
[metabase.revisions.core :as revisions]
[metabase.revisions.events]
[metabase.revisions.models.revision.diff :as revision.diff]
[metabase.test :as mt]
[toucan2.core :as t2]))
@@ -82,3 +83,39 @@
(is (zero? (t2/count :model/Revision :model "TransformTestRun" :model_id run-id)))
(finally
(t2/delete! :model/TransformTestRun :id run-id)))))))
(deftest transform-test-revision-history-api-test
(testing "GET /api/revision/transform-test/:id describes a rename"
(mt/with-premium-features #{:transforms-basic :transforms-testing}
(mt/with-temporary-raw-setting-values [transforms-enabled "true"]
(mt/with-current-user (mt/user->id :crowberto)
(mt/with-temp [:model/Transform {transform-id :id} {}
:model/TransformTest {transform-test-id :id} {:transform_id transform-id
:name "Original"}]
(t2/update! :model/TransformTest transform-test-id {:name "Renamed"})
(is (=? [{:is_creation false
:description "renamed this Transform test from \"Original\" to \"Renamed\"."}
{:is_creation true
:description "created this."}]
(mt/user-http-request :crowberto :get 200 (str "revision/transform-test/" transform-test-id))))))))))
(deftest transform-test-revision-history-without-display-name-api-test
(testing "GET /api/revision/transform-test/:id loads when the model has no display name"
(mt/with-premium-features #{:transforms-basic :transforms-testing}
(mt/with-temporary-raw-setting-values [transforms-enabled "true"]
(mt/with-current-user (mt/user->id :crowberto)
(mt/with-temp [:model/Transform {transform-id :id} {}
:model/TransformTest {transform-test-id :id} {:transform_id transform-id
:name "Original"}]
(t2/update! :model/TransformTest transform-test-id {:name "Renamed"})
(mt/with-dynamic-fn-redefs [revision.diff/model-str->i18n-str
(fn [model-str]
(throw (IllegalArgumentException. (str "No matching clause: " model-str))))]
(mt/with-log-messages-for-level [messages [metabase.revisions.models.revision.diff :warn]]
(is (=? [{:is_creation false
:description "renamed this TransformTest from \"Original\" to \"Renamed\"."}
{:is_creation true
:description "created this."}]
(mt/user-http-request :crowberto :get 200 (str "revision/transform-test/" transform-test-id))))
(is (=? [{:level :warn, :message #".*TransformTest.*"}]
(messages)))))))))))

View File

@@ -483,7 +483,7 @@ const configs = [
],
"import/no-unresolved": [
"error",
{ ignore: ["@metabase/embedding-sdk-react"] },
{ ignore: ["@metabase/embedding-sdk-react", "^cljs/"] },
],
"metabase/no-direct-helper-import": "error",
"metabase/no-unsafe-element-filtering": "warn",

View File

@@ -0,0 +1,35 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const FLAGS = [
"--disable-nested-config",
"--max-warnings",
"0",
"--report-unused-disable-directives",
];
const DEFAULT_PATHS = ["enterprise/frontend", "frontend", "e2e"];
export function getOxlintArgs(args, pathExists) {
const hasPath = args.some(
(arg) => !arg.startsWith("-") && (pathExists(arg) || /[/.]/.test(arg)),
);
return [...FLAGS, ...args, ...(hasPath ? [] : DEFAULT_PATHS)];
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const oxlint = path.resolve(
import.meta.dirname,
"../../../node_modules/.bin/oxlint",
);
const result = spawnSync(
oxlint,
getOxlintArgs(process.argv.slice(2), fs.existsSync),
{ stdio: "inherit" },
);
if (result.error) {
throw result.error;
}
process.exit(result.status ?? 1);
}

View File

@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getOxlintArgs } from "../scripts/run-oxlint.mjs";
const flags = [
"--disable-nested-config",
"--max-warnings",
"0",
"--report-unused-disable-directives",
];
const defaultPaths = ["enterprise/frontend", "frontend", "e2e"];
const file = "frontend/src/metabase/dev.ts";
const pathExists = (arg) => arg === file;
test("should lint the default paths when no arguments are given", () => {
assert.deepEqual(getOxlintArgs([], pathExists), [...flags, ...defaultPaths]);
});
test("should lint the default paths when only --fix is given", () => {
assert.deepEqual(
getOxlintArgs(["--fix"], () => true),
[...flags, "--fix", ...defaultPaths],
);
});
test("should lint only an existing file when one is given", () => {
assert.deepEqual(getOxlintArgs([file], pathExists), [...flags, file]);
});
test("should lint the default paths when a flag value is not an existing path", () => {
assert.deepEqual(getOxlintArgs(["--format", "json"], pathExists), [
...flags,
"--format",
"json",
...defaultPaths,
]);
});
test("should lint only an existing file when it is given with --fix", () => {
assert.deepEqual(getOxlintArgs(["--fix", file], pathExists), [
...flags,
"--fix",
file,
]);
});
test("should pass a missing path to oxlint without the default paths", () => {
assert.deepEqual(getOxlintArgs(["frontend/src/nope.ts"], pathExists), [
...flags,
"frontend/src/nope.ts",
]);
});
test("should pass a missing file name to oxlint without the default paths", () => {
assert.deepEqual(getOxlintArgs(["dev.ts"], pathExists), [...flags, "dev.ts"]);
});
test("should lint only an existing directory when one is given", () => {
assert.deepEqual(
getOxlintArgs(["frontend"], (arg) => arg === "frontend"),
[...flags, "frontend"],
);
});

View File

@@ -164,3 +164,13 @@ export const FETCH_DASHBOARD_FULFILLED =
export const fetchDashboardFulfilled = createAction<
ReturnType<typeof fetchDashboard.fulfilled>["payload"]
>(FETCH_DASHBOARD_FULFILLED);
/**
* Dispatched by `fetchDashboard` as soon as the dashboard definition arrives,
* before the slower query-metadata request finishes. It carries the same
* normalized entities the fulfilled action will, so the loading skeleton can
* draw the dashboard's real card layout without waiting for the full load.
*/
export const dashboardLayoutFetched = createAction<
ReturnType<typeof fetchDashboard.fulfilled>["payload"]["entities"]
>("metabase/dashboard/DASHBOARD_LAYOUT_FETCHED");

View File

@@ -22,6 +22,7 @@ import {
getDashCardById,
getDashboardById,
getDashboardComplete,
getDashboardId,
getLinkTargetEntities,
getLoadingDashCards,
getParameterValues,
@@ -71,6 +72,8 @@ import type {
} from "metabase-types/api";
import { isVisualizerDashboardCard } from "metabase-types/guards/dashboard";
import { dashboardLayoutFetched } from "./core";
export const FETCH_DASHBOARD_CARD_DATA =
"metabase/dashboard/FETCH_DASHBOARD_CARD_DATA";
export const fetchDashboardCardDataAction = createAction<{
@@ -678,6 +681,30 @@ const dashboardSchema = new schema.Entity("dashboard", {
dashcards: [dashcardSchema],
});
/**
* Copies each virtual card's definition (headings, text, links, ...) from the
* dashcard's visualization settings onto its `card`, which the backend leaves
* empty for virtual cards. The dashboard can come straight from RTK Query,
* whose responses are deeply frozen, so this builds new objects rather than
* mutating in place.
*/
function copyVirtualCardsToCards(dashboard: Dashboard): Dashboard {
return {
...dashboard,
dashcards: dashboard.dashcards.map((dashcard) =>
isVirtualDashCard(dashcard)
? {
...dashcard,
card: {
...dashcard.card,
...dashcard.visualization_settings.virtual_card,
},
}
: dashcard,
),
};
}
let fetchDashboardCancellation: AbortController | null;
const EMPTY_LINK_TARGETS: DashboardLinkTargets = {
@@ -849,19 +876,41 @@ export const fetchDashboard = createAsyncThunk(
);
result = prefetchedDashboard;
} else {
const dashboardRequest = runRtkEndpoint(
{ id: dashId, dashboard_load_id: dashboardLoadId },
dispatch,
dashboardApi.endpoints.getDashboard,
{ signal: fetchDashboardCancellation.signal },
);
const queryMetadataRequest = runRtkEndpoint(
{ id: dashId, dashboard_load_id: dashboardLoadId },
dispatch,
dashboardApi.endpoints.getDashboardQueryMetadata,
{ forceRefetch: false },
);
// The dashboard definition already holds the card layout and usually
// arrives well before the query metadata, so publish it right away for
// the loading skeleton. Only on a fresh open: a dashboard that is
// already on screen never shows the skeleton.
const isOpeningDashboard = getDashboardId(getState()) === null;
if (isOpeningDashboard) {
dashboardRequest.then(
(response) =>
dispatch(
dashboardLayoutFetched(
normalize(copyVirtualCardsToCards(response), dashboardSchema)
.entities,
),
),
// A failed request is surfaced by the `Promise.all` below.
() => {},
);
}
const [response, queryMetadata] = await Promise.all([
runRtkEndpoint(
{ id: dashId, dashboard_load_id: dashboardLoadId },
dispatch,
dashboardApi.endpoints.getDashboard,
{ signal: fetchDashboardCancellation.signal },
),
runRtkEndpoint(
{ id: dashId, dashboard_load_id: dashboardLoadId },
dispatch,
dashboardApi.endpoints.getDashboardQueryMetadata,
{ forceRefetch: false },
),
dashboardRequest,
queryMetadataRequest,
]);
linkTargets = toLinkTargets(queryMetadata);
result = response;
@@ -871,24 +920,7 @@ export const fetchDashboard = createAsyncThunk(
const isUsingCachedResults = entities != null;
if (!isUsingCachedResults) {
// Copy over any virtual cards from the dashcard to the underlying
// card/question. The result can come straight from RTK Query, whose
// responses are deeply frozen, so build new objects rather than
// mutating in place.
result = {
...result,
dashcards: result.dashcards.map((card: DashboardCard) =>
card.visualization_settings?.virtual_card
? {
...card,
card: {
...(card.card ?? {}),
...card.visualization_settings.virtual_card,
},
}
: card,
),
};
result = copyVirtualCardsToCards(result);
}
if (result.param_fields) {

View File

@@ -13,9 +13,11 @@ import {
createMockStoreDashboard,
} from "__support__/state";
import { createMockEntitiesState } from "__support__/store";
import { waitFor } from "__support__/ui";
import { Api } from "metabase/api";
import type { DashboardState, State } from "metabase/redux/store";
import { isQuestionDashCard } from "metabase/utils/dashboard";
import { defer } from "metabase/utils/promise";
import type { Dashboard } from "metabase-types/api";
import {
createMockCard,
@@ -29,6 +31,7 @@ import {
import { createSampleDatabase } from "metabase-types/api/mocks/presets";
import { dashboardReducers } from "../reducers";
import { getLastSeenTabDashcards } from "../selectors";
import {
fetchCardDataAction,
@@ -79,6 +82,79 @@ function setup({
}
describe("fetchDashboard", () => {
describe("while the query metadata is still loading (DSN-749)", () => {
let queryMetadataRequest = defer();
const setupDeferredQueryMetadata = (dashboardId: number) => {
queryMetadataRequest = defer();
fetchMock.get(`path:/api/dashboard/${dashboardId}/query_metadata`, () =>
queryMetadataRequest.promise.then(() =>
createMockDashboardQueryMetadata(),
),
);
};
// Never leave the request pending, or teardown waits on it when a test fails.
afterEach(() => queryMetadataRequest.resolve());
it("publishes the dashboard layout before the fetch finishes", async () => {
const dashboard = createMockDashboard({
id: 1,
dashcards: [createMockDashboardCard({ id: 10, dashboard_id: 1 })],
});
const store = setup();
setupDashboardsEndpoints([dashboard]);
setupDeferredQueryMetadata(1);
const fetchResult = store.dispatch(
fetchDashboard({ dashId: 1, queryParams: {}, options: {} }),
);
// The skeleton can draw the real layout while the dashboard still loads.
await waitFor(() => {
expect(getLastSeenTabDashcards(store.getState(), 1)).toHaveLength(1);
});
expect(store.getState().dashboard.dashboardId).toBeNull();
queryMetadataRequest.resolve();
await expect(fetchResult).resolves.toHaveProperty(
"type",
"metabase/dashboard/FETCH_DASHBOARD/fulfilled",
);
expect(store.getState().dashboard.dashboardId).toBe(1);
});
it("does not publish the layout early when refetching a dashboard that is already on screen", async () => {
const store = setup({
dashboard: createMockDashboardState({
dashboardId: 1,
dashboards: {
1: createMockStoreDashboard({ id: 1, name: "Old", dashcards: [] }),
},
}),
});
setupDashboardsEndpoints([createMockDashboard({ id: 1, name: "New" })]);
setupDeferredQueryMetadata(1);
const fetchResult = store.dispatch(
fetchDashboard({ dashId: 1, queryParams: {}, options: {} }),
);
await waitFor(() => {
expect(
fetchMock.callHistory.called("path:/api/dashboard/1/query_metadata"),
).toBe(true);
});
// Let the dashboard GET resolve before checking nothing was merged early.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(store.getState().dashboard.dashboards[1].name).toBe("Old");
queryMetadataRequest.resolve();
await fetchResult;
expect(store.getState().dashboard.dashboards[1].name).toBe("New");
});
});
it("should cancel previous dashboard fetch when a new one is initiated (metabase#35959)", async () => {
const store = setup({
dashboards: [

View File

@@ -1,6 +1,5 @@
import cx from "classnames";
import { useMemo } from "react";
import { t } from "ttag";
import DashboardS from "metabase/css/dashboard.module.css";
import { DashboardHeader } from "metabase/dashboard/components/DashboardHeader";
@@ -10,7 +9,7 @@ import { isEmbeddingSdk } from "metabase/embedding-sdk/config";
import EmbedFrameS from "metabase/embedding/theme.module.css";
import { useSelector } from "metabase/redux";
import { FullWidthContainer } from "metabase/styled-components/layout/FullWidthContainer";
import { Box, Flex, Loader } from "metabase/ui";
import { Box, Flex } from "metabase/ui";
import { DASHBOARD_PDF_EXPORT_ROOT_ID } from "metabase/visualizations/lib/save-dashboard-pdf";
import type { DashboardCard } from "metabase-types/api";
@@ -54,12 +53,10 @@ const DashboardDefaultView = ({ className }: { className?: string }) => {
const tabHasCards = currentTabDashcards.length > 0;
const dashboardHasCards = dashboard && dashboard.dashcards.length > 0;
if (!dashboard) {
return <Loader size="lg" label={t`Loading…`} />;
}
const isEmpty = !dashboardHasCards || (dashboardHasCards && !tabHasCards);
const hasTabs = dashboard.tabs && dashboard.tabs.length > 1;
// While the dashboard loads, its header and grid render skeletons, so lay
// the page out as for a dashboard with cards.
const isEmpty = dashboard != null && (!dashboardHasCards || !tabHasCards);
const hasTabs = (dashboard?.tabs?.length ?? 0) > 1;
// Embedding SDK has parent containers that requires dashboard to be full height to avoid double scrollbars.
const isFullHeight = isEditing || isSharing || isEmbeddingSdk();
@@ -81,7 +78,7 @@ const DashboardDefaultView = ({ className }: { className?: string }) => {
flex="1 0 auto"
data-testid="dashboard"
>
{dashboard.archived && <DashboardArchivedEntityBanner />}
{dashboard?.archived && <DashboardArchivedEntityBanner />}
<Box
component="header"

View File

@@ -14,6 +14,7 @@ import {
DashboardGridConnected,
type DashboardGridProps,
} from "../../DashboardGrid";
import { DashboardGridSkeleton } from "../../DashboardGridSkeleton";
import {
DashboardEmptyState,
DashboardEmptyStateWithoutAddPrompt,
@@ -26,6 +27,7 @@ export const Grid = ({
}: Pick<DashboardGridProps, "className" | "style" | "p">) => {
const {
dashboard,
dashboardId,
selectedTabId,
isEditing,
onRefreshPeriodChange,
@@ -71,6 +73,11 @@ export const Grid = ({
const isEmpty = !dashboardHasCards || (dashboardHasCards && !tabHasCards);
if (!dashboard) {
return <DashboardGridSkeleton dashboardId={dashboardId} />;
}
// Only a refetch of a dashboard that's already open gets here.
if (isLoadingWithoutCards) {
return <Loader size="lg" label={t`Loading…`} />;
}

View File

@@ -28,6 +28,7 @@ import {
GRID_ASPECT_RATIO,
GRID_BREAKPOINTS,
GRID_COLUMNS,
GRID_MARGINS,
GRID_WIDTH,
MIN_ROW_HEIGHT,
} from "metabase/utils/dashboard_grid";
@@ -647,7 +648,7 @@ class DashboardGridInner extends Component<
breakpoints={GRID_BREAKPOINTS}
cols={GRID_COLUMNS}
width={width}
margin={{ desktop: [6, 6], mobile: [6, 10] }}
margin={GRID_MARGINS}
containerPadding={[0, 0]}
rowHeight={rowHeight}
onLayoutChange={this.onLayoutChange}

View File

@@ -0,0 +1,86 @@
.root {
width: 100%;
container-type: inline-size;
}
/*
* Mirrors react-grid-layout's geometry (see DashboardGrid). The column count,
* aspect ratio, minimum row height and margins come in as custom properties,
* set from the same constants the real grid uses. Lengths are in
* container-query width units, so the skeleton cards land at the size and
* position the real cards will, at any width.
*/
.grid {
--skeleton-row-height: max(
calc(100cqw / var(--grid-columns) / var(--grid-aspect-ratio)),
var(--grid-min-row-height)
);
--skeleton-col-width: calc(
(100cqw - var(--grid-margin-x) * (var(--grid-columns) - 1)) /
var(--grid-columns)
);
position: relative;
width: 100%;
height: calc(
(var(--skeleton-row-height) + var(--grid-margin-y)) * var(--skeleton-rows)
);
}
.card {
position: absolute;
overflow: hidden;
background-color: var(--mb-color-bg-dashboard-card);
border: 1px solid var(--mb-color-border);
border-radius: 0.75rem;
left: calc(
(var(--skeleton-col-width) + var(--grid-margin-x)) * var(--skeleton-col)
);
top: calc(
(var(--skeleton-row-height) + var(--grid-margin-y)) * var(--skeleton-row)
);
width: calc(
var(--skeleton-col-width) * var(--skeleton-w) + (var(--skeleton-w) - 1) *
var(--grid-margin-x)
);
height: calc(
var(--skeleton-row-height) * var(--skeleton-h) + (var(--skeleton-h) - 1) *
var(--grid-margin-y)
);
}
/* Heading and text cards have no card chrome — just their text lines. */
.transparent {
background-color: transparent;
border-color: transparent;
}
.textContent {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
height: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
.textContentHeading {
font-size: 1.25rem;
}
/* The grid's mobile breakpoint, MOBILE_BREAKPOINT. A container query can't read a custom property. */
@container (max-width: 752px) {
.grid {
display: flex;
flex-direction: column;
gap: var(--grid-mobile-margin-y);
height: auto;
}
.card {
position: static;
width: 100%;
height: calc(var(--skeleton-row-height) * var(--skeleton-h));
}
}

View File

@@ -0,0 +1,199 @@
import cx from "classnames";
import { t } from "ttag";
import visuallyHidden from "metabase/css/core/visually-hidden.module.css";
import {
getIsLastSeenDashboardFixedWidth,
getLastSeenTabDashcards,
} from "metabase/dashboard/selectors";
import { isHeadingDashCard, isTextDashCard } from "metabase/dashboard/utils";
import { useSelector } from "metabase/redux";
import type { StoreDashcard } from "metabase/redux/store";
import { Box, Skeleton } from "metabase/ui";
import {
GRID_ASPECT_RATIO,
GRID_MARGINS,
GRID_WIDTH,
MIN_ROW_HEIGHT,
} from "metabase/utils/dashboard_grid";
import ChartSkeleton from "metabase/visualizations/components/skeletons/ChartSkeleton";
import type { CardDisplayType, DashboardId } from "metabase-types/api";
import { isCardDisplayType } from "metabase-types/api";
import { FixedWidthContainer } from "../Dashboard/DashboardComponents";
import S from "./DashboardGridSkeleton.module.css";
type SkeletonCardContent =
| { kind: "chart"; display: CardDisplayType | undefined }
| { kind: "text"; lines: string[]; isHeading: boolean };
export type SkeletonCardPlacement = {
key: string;
content: SkeletonCardContent;
col: number;
row: number;
size_x: number;
size_y: number;
};
const MAX_TEXT_SKELETON_LINES = 12;
const MIN_TEXT_LINE_CHARS = 3;
const MAX_TEXT_LINE_CHARS = 40;
/** The grid geometry react-grid-layout uses, handed to the CSS. */
const GRID_GEOMETRY_STYLE = {
"--grid-columns": GRID_WIDTH,
"--grid-aspect-ratio": GRID_ASPECT_RATIO,
"--grid-min-row-height": `${MIN_ROW_HEIGHT}px`,
"--grid-margin-x": `${GRID_MARGINS.desktop[0]}px`,
"--grid-margin-y": `${GRID_MARGINS.desktop[1]}px`,
"--grid-mobile-margin-y": `${GRID_MARGINS.mobile[1]}px`,
};
const getTextLines = (text: string): string[] =>
text.split("\n").slice(0, MAX_TEXT_SKELETON_LINES);
const getCardContent = (dc: StoreDashcard): SkeletonCardContent => {
if (isHeadingDashCard(dc) || isTextDashCard(dc)) {
return {
kind: "text",
lines: getTextLines(dc.visualization_settings.text ?? ""),
isHeading: isHeadingDashCard(dc),
};
}
return {
kind: "chart",
display: isCardDisplayType(dc.card.display) ? dc.card.display : undefined,
};
};
export const getPlacements = (
cards: readonly StoreDashcard[],
): SkeletonCardPlacement[] =>
cards.map((dc) => ({
key: String(dc.id),
content: getCardContent(dc),
col: dc.col,
row: dc.row,
size_x: dc.size_x,
size_y: dc.size_y,
}));
const getRowCount = (placements: readonly SkeletonCardPlacement[]): number =>
placements.reduce((rows, { row, size_y }) => Math.max(rows, row + size_y), 0);
const getLineWidth = (chars: number): string => {
const clamped = Math.min(
Math.max(chars, MIN_TEXT_LINE_CHARS),
MAX_TEXT_LINE_CHARS,
);
return `min(${clamped}ch, 100%)`;
};
const TextCardSkeleton = ({
lines,
isHeading,
}: {
lines: string[];
isHeading: boolean;
}) => (
<div
className={cx(S.textContent, { [S.textContentHeading]: isHeading })}
data-testid="dashboard-skeleton-text"
>
{lines.map((line, index) => {
const trimmed = line.trim();
return trimmed.length === 0 ? (
<Box key={index} h="1.2em" />
) : (
<Skeleton
key={index}
height="1.2em"
width={getLineWidth(trimmed.length)}
radius="sm"
/>
);
})}
</div>
);
/**
* Draws card placements at the grid geometry the real cards will use, so the
* transition to the loaded dashboard is seamless. Heading and text cards render
* title-style line skeletons rather than a chart.
*/
export const CardLayoutSkeleton = ({
placements,
}: {
placements: readonly SkeletonCardPlacement[];
}) => (
<Box className={S.root}>
<Box
className={S.grid}
style={{
...GRID_GEOMETRY_STYLE,
"--skeleton-rows": getRowCount(placements),
}}
data-testid="dashboard-grid-skeleton-cards"
>
{placements.map(({ key, content, col, row, size_x, size_y }) => (
<Box
key={key}
className={cx(S.card, { [S.transparent]: content.kind === "text" })}
style={{
"--skeleton-col": col,
"--skeleton-row": row,
"--skeleton-w": size_x,
"--skeleton-h": size_y,
}}
>
{content.kind === "text" ? (
<TextCardSkeleton
lines={content.lines}
isHeading={content.isHeading}
/>
) : (
<ChartSkeleton display={content.display} />
)}
</Box>
))}
</Box>
</Box>
);
/**
* The dashboard's card grid as a skeleton, shown while the dashboard loads so
* that no spinner is needed. Cards are only drawn once the dashboard's real
* layout is known, from the in-memory Redux cache: the cards of the tab the
* user is landing on, at the dashboard's own width. Until then the grid stays
* empty rather than showing cards the dashboard may not have. Once the real
* grid mounts, its cards keep their own per-card content skeletons.
*/
export const DashboardGridSkeleton = ({
dashboardId,
}: {
dashboardId: DashboardId | null;
}) => {
const lastSeenTabDashcards = useSelector((state) =>
getLastSeenTabDashcards(state, dashboardId),
);
const isFixedWidth = useSelector((state) =>
getIsLastSeenDashboardFixedWidth(state, dashboardId),
);
const placements = getPlacements(lastSeenTabDashcards);
return (
<FixedWidthContainer
isFixedWidth={isFixedWidth}
aria-busy
data-testid="dashboard-grid-skeleton"
>
<span role="status" className={visuallyHidden.visuallyHidden}>
{t`Loading…`}
</span>
{placements.length > 0 && <CardLayoutSkeleton placements={placements} />}
</FixedWidthContainer>
);
};

View File

@@ -0,0 +1,127 @@
import {
createMockDashboardState,
createMockStoreDashboard,
} from "__support__/state";
import { renderWithProviders, screen, within } from "__support__/ui";
import type { StoreDashboardTab, StoreDashcard } from "metabase/redux/store";
import {
createMockDashboardCard,
createMockDashboardTab,
createMockHeadingDashboardCard,
createMockTextDashboardCard,
} from "metabase-types/api/mocks";
import { DashboardGridSkeleton } from "./DashboardGridSkeleton";
type SetupOpts = {
dashcards?: StoreDashcard[];
tabs?: StoreDashboardTab[];
isCached?: boolean;
};
function setup({ dashcards = [], tabs = [], isCached = true }: SetupOpts = {}) {
renderWithProviders(<DashboardGridSkeleton dashboardId={1} />, {
storeInitialState: {
dashboard: createMockDashboardState({
dashboards: isCached
? {
1: createMockStoreDashboard({
id: 1,
dashcards: dashcards.map((dc) => dc.id),
tabs,
}),
}
: {},
dashcards: Object.fromEntries(dashcards.map((dc) => [dc.id, dc])),
}),
},
});
}
const getSkeletonCards = () =>
screen.getByTestId("dashboard-grid-skeleton-cards").children;
describe("DashboardGridSkeleton", () => {
afterEach(() => {
window.history.replaceState({}, "", "/");
});
it("tells assistive technology the dashboard is loading, without a spinner", () => {
setup();
const skeleton = screen.getByTestId("dashboard-grid-skeleton");
expect(skeleton).toHaveAttribute("aria-busy", "true");
expect(within(skeleton).getByRole("status")).toHaveTextContent("Loading…");
expect(screen.queryByTestId("loading-indicator")).not.toBeInTheDocument();
});
it("draws no cards until the dashboard's layout is known", () => {
setup({ isCached: false });
expect(screen.getByTestId("dashboard-grid-skeleton")).toBeInTheDocument();
expect(
screen.queryByTestId("dashboard-grid-skeleton-cards"),
).not.toBeInTheDocument();
});
it("draws exactly the cached dashboard's cards", () => {
setup({
dashcards: [
createMockDashboardCard({ id: 1, col: 0, row: 0, size_x: 12 }),
createMockDashboardCard({ id: 2, col: 12, row: 0, size_x: 12 }),
],
});
expect(getSkeletonCards()).toHaveLength(2);
});
it("draws the cards of the tab the URL lands on", () => {
window.history.replaceState({}, "", "/dashboard/1?tab=200");
setup({
tabs: [
createMockDashboardTab({ id: 100 }),
createMockDashboardTab({ id: 200 }),
],
dashcards: [
createMockDashboardCard({ id: 1, dashboard_tab_id: 100 }),
createMockDashboardCard({ id: 2, dashboard_tab_id: 200 }),
createMockDashboardCard({ id: 3, dashboard_tab_id: 200 }),
],
});
expect(getSkeletonCards()).toHaveLength(2);
});
it("lands on the first tab that hasn't been removed", () => {
setup({
tabs: [
{ ...createMockDashboardTab({ id: 100 }), isRemoved: true },
createMockDashboardTab({ id: 200 }),
],
dashcards: [
createMockDashboardCard({ id: 1, dashboard_tab_id: 100 }),
createMockDashboardCard({ id: 2, dashboard_tab_id: 200 }),
createMockDashboardCard({ id: 3, dashboard_tab_id: 200 }),
],
});
expect(getSkeletonCards()).toHaveLength(2);
});
it("renders one line skeleton per line of a heading or text card", () => {
setup({
dashcards: [
createMockHeadingDashboardCard({ id: 1, text: "One heading line" }),
createMockTextDashboardCard({
id: 2,
text: "Line one\nLine two\nLine three",
}),
],
});
const textSkeletons = screen.getAllByTestId("dashboard-skeleton-text");
expect(textSkeletons).toHaveLength(2);
expect(textSkeletons[0].childElementCount).toBe(1);
expect(textSkeletons[1].childElementCount).toBe(3);
});
});

View File

@@ -0,0 +1 @@
export { DashboardGridSkeleton } from "./DashboardGridSkeleton";

View File

@@ -21,11 +21,11 @@ import { fetchPulseFormInput } from "metabase/notifications/pulse/actions";
import { useDispatch, useSelector } from "metabase/redux";
import { useMaybeLocation } from "metabase/router";
import { getSetting } from "metabase/settings";
import { Flex, Loader } from "metabase/ui";
import type { Dashboard } from "metabase-types/api";
import { SIDEBAR_NAME } from "../../constants";
import { DashboardHeaderSkeleton } from "./DashboardHeaderSkeleton";
import { DashboardHeaderView } from "./DashboardHeaderView";
import { CancelEditButton, SaveEditButton } from "./buttons";
@@ -61,13 +61,12 @@ export const DashboardHeaderInner = ({ dashboard }: DashboardHeaderProps) => {
getSetting(state, "custom-homepage-dashboard") === dashboard?.id,
);
const { data: collection, isLoading: isLoadingCollection } =
useGetCollectionQuery(
{ id: dashboard.collection_id || "root" },
{
skip: isGuestEmbed,
},
);
const { data: collection } = useGetCollectionQuery(
{ id: dashboard.collection_id || "root" },
{
skip: isGuestEmbed,
},
);
const onRequestCancel = () => {
if (isDirty && isEditing) {
@@ -116,17 +115,6 @@ export const DashboardHeaderInner = ({ dashboard }: DashboardHeaderProps) => {
];
};
// We don't fetch collection info for static embedding
if (!isGuestEmbed) {
if (isLoadingCollection || !collection) {
return (
<Flex justify="center" py="1.5rem">
<Loader size={29} />
</Flex>
);
}
}
const hasLastEditInfo = dashboard["last-edit-info"] != null;
const editingButtons = getEditingButtons();
@@ -164,10 +152,12 @@ export const DashboardHeaderInner = ({ dashboard }: DashboardHeaderProps) => {
};
export const DashboardHeader = () => {
const { dashboard } = useDashboardContext();
const { dashboard, dashboardId, titled } = useDashboardContext();
if (!dashboard) {
return null;
return (
<DashboardHeaderSkeleton dashboardId={dashboardId} titled={titled} />
);
}
return <DashboardHeaderInner dashboard={dashboard} />;

View File

@@ -0,0 +1,184 @@
import cx from "classnames";
import type { ReactNode } from "react";
import { EditableText } from "metabase/common/components/EditableText";
import CS from "metabase/css/core/index.css";
import {
getIsAdditionalInfoVisible,
getIsHeaderVisible,
getIsLastSeenDashboardFixedWidth,
getLastSeenDashboard,
} from "metabase/dashboard/selectors";
import { isEmbeddingSdk } from "metabase/embedding-sdk/config";
import { useSelector } from "metabase/redux";
import { FullWidthContainer } from "metabase/styled-components/layout/FullWidthContainer";
import { Box, Flex, Skeleton, Text, UnstyledButton } from "metabase/ui";
import type { DashboardId } from "metabase-types/api";
import { FixedWidthContainer } from "../Dashboard/DashboardComponents";
import S from "./DashboardHeaderView.module.css";
// Dashboards render a fixed row of action-icon buttons in their header.
const HEADER_ACTIONS = Array.from({ length: 6 });
/**
* A skeleton bar laid over an invisible copy of the element it stands in for,
* so it takes up exactly the height the real element will.
*/
const SkeletonOver = ({
width,
height,
children,
}: {
width: string;
height: string;
children: ReactNode;
}) => (
<Box pos="relative" w={width}>
{/* A flex container, like the rows the real elements sit in. */}
<Flex style={{ visibility: "hidden" }} aria-hidden>
{children}
</Flex>
<Flex pos="absolute" top={0} bottom={0} left={0} right={0} align="center">
<Skeleton height={height} radius="sm" />
</Flex>
</Box>
);
/**
* The dashboard header as a skeleton, shown while the dashboard loads. It's
* built from the same layout pieces as `DashboardHeaderView` and follows the
* same visibility rules, and sizes its title area from the real title and
* last-edit label, so the header doesn't change height when the dashboard
* arrives. Tabs come from the dashboard as last seen in the in-memory Redux
* cache, so the tab row only appears when the dashboard has more than one tab.
*/
export const DashboardHeaderSkeleton = ({
dashboardId,
titled,
}: {
dashboardId: DashboardId | null;
titled: boolean;
}) => {
const isHeaderVisible = useSelector(getIsHeaderVisible);
const isAdditionalInfoVisible = useSelector(getIsAdditionalInfoVisible);
const lastSeenDashboard = useSelector((state) =>
getLastSeenDashboard(state, dashboardId),
);
const isFixedWidth = useSelector((state) =>
getIsLastSeenDashboardFixedWidth(state, dashboardId),
);
const tabs = lastSeenDashboard?.tabs?.filter((tab) => !tab.isRemoved) ?? [];
// Without the dashboard in the cache, assume it has an edit history, as
// nearly every dashboard does.
const hasLastEditInfo =
lastSeenDashboard == null || lastSeenDashboard["last-edit-info"] != null;
const isLastEditInfoVisible = hasLastEditInfo && isAdditionalInfoVisible;
return (
<div
className={S.DashboardHeader}
aria-busy
data-testid="dashboard-header-skeleton"
>
<div className={S.HeaderContainer}>
{isHeaderVisible && (
<FullWidthContainer className={cx(CS.wrapper, S.HeaderRow)}>
<FixedWidthContainer
className={S.HeaderFixedWidthContainer}
isFixedWidth={isFixedWidth}
>
{titled && (
// The real header mounts with its sub-header shown.
<Box
className={cx(S.HeaderContent, S.showSubHeader)}
data-testid="dashboard-header-skeleton-title"
>
<Flex className={S.HeaderCaptionContainer}>
<SkeletonOver width="14rem" height="1.5rem">
<EditableText
className={S.HeaderCaption}
initialValue=""
isDisabled
/>
</SkeletonOver>
</Flex>
<Flex
className={S.HeaderBadges}
data-testid="dashboard-header-skeleton-badges"
>
{isLastEditInfoVisible && (
<SkeletonOver width="10rem" height="0.875rem">
<LastEditInfoLabelSizer />
</SkeletonOver>
)}
</Flex>
</Box>
)}
<Flex className={S.HeaderButtonsContainer}>
<Flex
className={S.HeaderButtonSection}
data-testid="dashboard-header-skeleton-actions"
>
{HEADER_ACTIONS.map((_, index) => (
<Skeleton
key={index}
height="2rem"
width="2rem"
radius="sm"
/>
))}
</Flex>
</Flex>
</FixedWidthContainer>
</FullWidthContainer>
)}
{tabs.length > 1 && (
<FullWidthContainer className={S.HeaderRow}>
<FixedWidthContainer
className={S.HeaderFixedWidthContainer}
isFixedWidth={isFixedWidth}
>
<Flex
align="center"
gap="lg"
h="2.5rem"
data-testid="dashboard-header-skeleton-tabs"
>
{tabs.map((tab) => (
<Skeleton
key={tab.id}
height="1rem"
width="4rem"
radius="sm"
/>
))}
</Flex>
</FixedWidthContainer>
</FullWidthContainer>
)}
</div>
</div>
);
};
/**
* An empty line of the dashboard's last-edit label, rendered as the same
* element `LastEditInfoLabel` uses in this context so it has the same height.
*/
const LastEditInfoLabelSizer = () =>
isEmbeddingSdk() ? (
<Text className={S.HeaderLastEditInfoLabel} size="sm" fw="bold">
{" "}
</Text>
) : (
<UnstyledButton
className={S.HeaderLastEditInfoLabel}
fz="sm"
fw="bold"
tabIndex={-1}
>
{" "}
</UnstyledButton>
);

View File

@@ -0,0 +1,102 @@
import {
createMockDashboardState,
createMockStoreDashboard,
} from "__support__/state";
import { renderWithProviders, screen } from "__support__/ui";
import type { StoreDashboardTab } from "metabase/redux/store";
import { createMockDashboardTab } from "metabase-types/api/mocks";
import { DashboardHeaderSkeleton } from "./DashboardHeaderSkeleton";
type SetupOpts = {
tabs?: StoreDashboardTab[];
titled?: boolean;
isCached?: boolean;
};
function setup({ tabs = [], titled = true, isCached = true }: SetupOpts = {}) {
renderWithProviders(
<DashboardHeaderSkeleton dashboardId={1} titled={titled} />,
{
storeInitialState: {
dashboard: createMockDashboardState({
dashboards: isCached
? { 1: createMockStoreDashboard({ id: 1, tabs }) }
: {},
}),
},
},
);
}
describe("DashboardHeaderSkeleton", () => {
it("draws the title and action buttons as a busy region", () => {
setup();
expect(screen.getByTestId("dashboard-header-skeleton")).toHaveAttribute(
"aria-busy",
"true",
);
expect(
screen.getByTestId("dashboard-header-skeleton-title"),
).toBeInTheDocument();
expect(
screen.getByTestId("dashboard-header-skeleton-actions").childElementCount,
).toBe(6);
});
it("reserves the last-edit label's row on a fresh page load", () => {
setup({ isCached: false });
expect(
screen.getByTestId("dashboard-header-skeleton-badges").childElementCount,
).toBe(1);
});
it("reserves the last-edit label's row for a cached dashboard with an edit history", () => {
setup();
expect(
screen.getByTestId("dashboard-header-skeleton-badges").childElementCount,
).toBe(1);
});
it("leaves out the title where the dashboard isn't titled", () => {
setup({ titled: false });
expect(
screen.queryByTestId("dashboard-header-skeleton-title"),
).not.toBeInTheDocument();
});
it("draws no tab row on a fresh page load", () => {
setup({ isCached: false });
expect(
screen.queryByTestId("dashboard-header-skeleton-tabs"),
).not.toBeInTheDocument();
});
it("draws no tab row for a dashboard with a single tab", () => {
setup({ tabs: [createMockDashboardTab({ id: 1 })] });
expect(
screen.queryByTestId("dashboard-header-skeleton-tabs"),
).not.toBeInTheDocument();
});
it("draws one placeholder per tab, skipping removed tabs", () => {
setup({
tabs: [
createMockDashboardTab({ id: 1 }),
createMockDashboardTab({ id: 2 }),
{ ...createMockDashboardTab({ id: 3 }), isRemoved: true },
createMockDashboardTab({ id: 4 }),
],
});
expect(
screen.getByTestId("dashboard-header-skeleton-tabs").childElementCount,
).toBe(3);
});
});

View File

@@ -16,11 +16,13 @@ import { FixedWidthContainer } from "../Dashboard/DashboardComponents";
import { ParametersList } from "../Dashboard/components";
import S from "./DashboardParameterPanel.module.css";
import { DashboardParameterPanelSkeleton } from "./DashboardParameterPanelSkeleton";
export function DashboardParameterPanel() {
const parameters = useSelector(getDashboardHeaderValuePopulatedParameters);
const { dashboard, hideParameters, isEditing } = useDashboardContext();
const { dashboard, dashboardId, hideParameters, isEditing } =
useDashboardContext();
const visibleParameters = getVisibleParameters(parameters, hideParameters);
const hasVisibleParameters = visibleParameters.length > 0;
@@ -34,6 +36,15 @@ export function DashboardParameterPanel() {
disabled: !allowSticky || !hasVisibleParameters,
});
if (!dashboard) {
return (
<DashboardParameterPanelSkeleton
dashboardId={dashboardId}
hideParameters={hideParameters}
/>
);
}
if (!hasVisibleParameters) {
return null;
}

View File

@@ -0,0 +1,70 @@
import cx from "classnames";
import {
getIsLastSeenDashboardFixedWidth,
getLastSeenDashboardHeaderParameters,
} from "metabase/dashboard/selectors";
import { isEmbeddingSdk } from "metabase/embedding-sdk/config";
import { getVisibleParameters } from "metabase/parameters/utils/ui";
import { useSelector } from "metabase/redux";
import { FullWidthContainer } from "metabase/styled-components/layout/FullWidthContainer";
import { Flex, Skeleton } from "metabase/ui";
import type { DashboardId } from "metabase-types/api";
import DashboardS from "../Dashboard/Dashboard.module.css";
import { FixedWidthContainer } from "../Dashboard/DashboardComponents";
import S from "./DashboardParameterPanel.module.css";
/**
* The dashboard's filter widgets as skeletons, shown while the dashboard
* loads. They come from the dashboard as last seen in the in-memory Redux
* cache, so nothing is drawn on a fresh page load.
*/
export const DashboardParameterPanelSkeleton = ({
dashboardId,
hideParameters,
}: {
dashboardId: DashboardId | null;
hideParameters?: string | null;
}) => {
const isFixedWidth = useSelector((state) =>
getIsLastSeenDashboardFixedWidth(state, dashboardId),
);
const headerParameters = useSelector((state) =>
getLastSeenDashboardHeaderParameters(state, dashboardId),
);
const visibleParameters = getVisibleParameters(
headerParameters,
hideParameters,
);
if (visibleParameters.length === 0) {
return null;
}
return (
<FullWidthContainer
className={cx(S.ParametersWidgetContainer, {
[S.isEmbeddingSdk]: isEmbeddingSdk(),
})}
aria-busy
data-testid="dashboard-parameters-skeleton"
>
<FixedWidthContainer
className={DashboardS.ParametersFixedWidthContainer}
isFixedWidth={isFixedWidth}
>
<Flex
align="center"
gap="sm"
data-testid="dashboard-parameters-skeleton-filters"
>
{visibleParameters.map((parameter) => (
<Skeleton key={parameter.id} height={24} width={160} radius="sm" />
))}
</Flex>
</FixedWidthContainer>
</FullWidthContainer>
);
};

View File

@@ -0,0 +1,73 @@
import {
createMockDashboardState,
createMockStoreDashboard,
} from "__support__/state";
import { renderWithProviders, screen } from "__support__/ui";
import {
createMockDashboardCard,
createMockParameter,
} from "metabase-types/api/mocks";
import { DashboardParameterPanelSkeleton } from "./DashboardParameterPanelSkeleton";
const PARAMETERS = [
createMockParameter({ id: "a", slug: "a" }),
createMockParameter({ id: "b", slug: "b" }),
createMockParameter({ id: "c", slug: "c" }),
];
// Parameter "c" sits inline on this card rather than in the header.
const DASHCARD = createMockDashboardCard({ id: 10, inline_parameters: ["c"] });
function setup({
isCached = true,
hideParameters,
}: { isCached?: boolean; hideParameters?: string } = {}) {
renderWithProviders(
<DashboardParameterPanelSkeleton
dashboardId={1}
hideParameters={hideParameters}
/>,
{
storeInitialState: {
dashboard: createMockDashboardState({
dashboards: isCached
? {
1: createMockStoreDashboard({
id: 1,
parameters: PARAMETERS,
dashcards: [DASHCARD.id],
}),
}
: {},
dashcards: { [DASHCARD.id]: DASHCARD },
}),
},
},
);
}
const getPlaceholders = () =>
screen.getByTestId("dashboard-parameters-skeleton-filters").children;
describe("DashboardParameterPanelSkeleton", () => {
it("draws one placeholder per header filter, leaving out inline ones", () => {
setup();
expect(getPlaceholders()).toHaveLength(2);
});
it("leaves out filters the embedding hides", () => {
setup({ hideParameters: "b" });
expect(getPlaceholders()).toHaveLength(1);
});
it("draws nothing on a fresh page load", () => {
setup({ isCached: false });
expect(
screen.queryByTestId("dashboard-parameters-skeleton"),
).not.toBeInTheDocument();
});
});

View File

@@ -9,7 +9,7 @@ import { createMockEntitiesState } from "__support__/store";
import {
renderWithProviders,
screen,
waitForLoaderToBeRemoved,
waitForDashboardToLoad,
within,
} from "__support__/ui";
import { Route } from "metabase/router";
@@ -51,7 +51,7 @@ const setup = async () => {
},
);
await waitForLoaderToBeRemoved();
await waitForDashboardToLoad();
return {
dashboardId,

View File

@@ -16,7 +16,7 @@ import {
act,
renderWithProviders,
waitFor,
waitForLoaderToBeRemoved,
waitForDashboardToLoad,
} from "__support__/ui";
import { DashboardApp } from "metabase/dashboard/containers/DashboardApp/DashboardApp";
import { Route } from "metabase/router";
@@ -80,7 +80,7 @@ async function setup({ hash = "" }: { hash?: string } = {}) {
},
);
await waitForLoaderToBeRemoved();
await waitForDashboardToLoad();
const getTargetDashcard = () =>
checkNotNull(

View File

@@ -22,11 +22,12 @@ import {
act,
renderWithProviders,
screen,
waitForLoaderToBeRemoved,
waitForDashboardToLoad,
} from "__support__/ui";
import { getBeforeUnloadUnsavedMessage } from "metabase/common/hooks/use-before-unload";
import { DashboardApp } from "metabase/dashboard/containers/DashboardApp/DashboardApp";
import { Route } from "metabase/router";
import { defer } from "metabase/utils/promise";
import { checkNotNull } from "metabase/utils/types";
import type { Dashboard } from "metabase-types/api";
import {
@@ -36,7 +37,9 @@ import {
createMockDashboard,
createMockDashboardQueryMetadata,
createMockDatabase,
createMockHeadingDashboardCard,
createMockTable,
createMockTextDashboardCard,
} from "metabase-types/api/mocks";
const TEST_COLLECTION = createMockCollection();
@@ -59,22 +62,33 @@ const TestHome = () => <div />;
interface Options {
dashboard?: Partial<Dashboard>;
slug?: string;
/** Holds the query metadata response until `resolveQueryMetadata` is called. */
deferQueryMetadata?: boolean;
}
async function setup({ dashboard, slug }: Options = {}) {
function renderDashboardApp({
dashboard,
slug,
deferQueryMetadata = false,
}: Options = {}) {
const mockDashboard = createMockDashboard(dashboard);
const dashboardId = mockDashboard.id;
const queryMetadata = createMockDashboardQueryMetadata({
databases: [TEST_DATABASE_WITH_ACTIONS],
});
const queryMetadataRequest = defer();
setupNotificationChannelsEndpoints({});
setupDatabasesEndpoints([TEST_DATABASE_WITH_ACTIONS]);
setupDashboardEndpoints(mockDashboard);
setupDashboardQueryMetadataEndpoint(
mockDashboard,
createMockDashboardQueryMetadata({
databases: [TEST_DATABASE_WITH_ACTIONS],
}),
);
if (deferQueryMetadata) {
fetchMock.get(`path:/api/dashboard/${dashboardId}/query_metadata`, () =>
queryMetadataRequest.promise.then(() => queryMetadata),
);
} else {
setupDashboardQueryMetadataEndpoint(mockDashboard, queryMetadata);
}
setupCollectionsEndpoints({ collections: [] });
setupCollectionItemsEndpoint({
collection: TEST_COLLECTION,
@@ -116,16 +130,23 @@ async function setup({ dashboard, slug }: Options = {}) {
},
);
await waitForLoaderToBeRemoved();
return {
dashboardId,
router: checkNotNull(router),
store,
mockEventListener,
resolveQueryMetadata: () => queryMetadataRequest.resolve(),
};
}
async function setup(options: Options = {}) {
const view = renderDashboardApp(options);
await waitForDashboardToLoad();
return view;
}
describe("DashboardApp", () => {
afterEach(() => {
jest.clearAllMocks();
@@ -169,7 +190,7 @@ describe("DashboardApp", () => {
router.navigate(`/dashboard/${dashboardId}`);
});
await waitForLoaderToBeRemoved();
await waitForDashboardToLoad();
await userEvent.click(await screen.findByLabelText("Edit dashboard"));
@@ -190,7 +211,7 @@ describe("DashboardApp", () => {
router.navigate(`/dashboard/${dashboardId}`);
});
await waitForLoaderToBeRemoved();
await waitForDashboardToLoad();
await userEvent.click(screen.getByLabelText("Edit dashboard"));
await userEvent.click(screen.getByTestId("dashboard-name-heading"));
@@ -285,6 +306,55 @@ describe("DashboardApp", () => {
expect(store.getState().app.errorPage).toMatchObject({ status: 404 });
});
describe("loading state (DSN-749)", () => {
it("renders the dashboard with skeletons, instead of a loading spinner, while it loads", async () => {
renderDashboardApp();
// The real dashboard page renders right away, with skeletons for its
// header and grid, and a loading spinner is never shown.
expect(screen.getByTestId("dashboard")).toBeInTheDocument();
expect(
screen.getByTestId("dashboard-header-skeleton"),
).toBeInTheDocument();
expect(screen.getByTestId("dashboard-grid-skeleton")).toBeInTheDocument();
expect(screen.queryByTestId("loading-indicator")).not.toBeInTheDocument();
// Once the dashboard arrives, its real header and grid replace them.
await waitForDashboardToLoad();
expect(screen.getByTestId("dashboard-header")).toBeInTheDocument();
});
it("switches to the dashboard's real card layout as soon as it is known, before the load finishes", async () => {
const { resolveQueryMetadata } = renderDashboardApp({
dashboard: {
dashcards: [
createMockHeadingDashboardCard({ id: 1, text: "Heading" }),
createMockTextDashboardCard({
id: 2,
text: "First line\nSecond line",
}),
],
},
deferQueryMetadata: true,
});
try {
// These can only come from the dashboard's own layout, drawn while
// its query metadata loads.
expect(
await screen.findAllByTestId("dashboard-skeleton-text"),
).toHaveLength(2);
} finally {
resolveQueryMetadata();
}
await waitForDashboardToLoad();
expect(
screen.queryByTestId("dashboard-skeleton-text"),
).not.toBeInTheDocument();
});
});
it("should not allow to enter a dashboard name longer than 254 characters", async () => {
await setup();

View File

@@ -54,6 +54,7 @@ import {
addManyCardsToDash,
cancelFetchCardData,
clearCardData,
dashboardLayoutFetched,
fetchCardDataAction,
fetchDashboard,
fetchDashboardCardDataAction,
@@ -320,6 +321,10 @@ export const dashboards = createReducer(
...state,
...payload.entities.dashboard,
}))
.addCase(dashboardLayoutFetched, (state, { payload }) => ({
...state,
...payload.dashboard,
}))
.addCase(
setDashboardAttributes,
(state, { payload: { id, attributes, isDirty = true } }) => {

View File

@@ -22,6 +22,7 @@ import {
UNDO_REMOVE_CARD_FROM_DASH,
addCardToDash,
addManyCardsToDash,
dashboardLayoutFetched,
fetchDashboardFulfilled,
markNewCardSeen,
onReplaceAllDashCardVisualizationSettings,
@@ -68,6 +69,10 @@ const dashcards = createReducer(
...state,
...action.payload.entities.dashcard,
}))
.addCase(dashboardLayoutFetched, (state, { payload }) => ({
...state,
...payload.dashcard,
}))
.addCase(
setDashCardAttributes,
(state, { payload: { id, attributes } }) => {

View File

@@ -26,6 +26,7 @@ import type {
EditParameterSidebarState,
State,
StoreDashboard,
StoreDashcard,
} from "metabase/redux/store";
import { getSetting } from "metabase/settings";
import * as Urls from "metabase/urls";
@@ -47,6 +48,7 @@ import type {
DashboardParameterMapping,
DashboardTabId,
EmbeddingParameterVisibility,
Parameter,
ParameterId,
VirtualCard,
} from "metabase-types/api";
@@ -181,6 +183,96 @@ export const getDashboardById = (state: State, dashboardId: DashboardId) => {
return dashboards[dashboardId];
};
const EMPTY_DASHCARDS: StoreDashcard[] = [];
const EMPTY_PARAMETERS: Parameter[] = [];
/**
* The dashboard as we last saw it, looked up by id in the in-memory Redux
* cache. Unlike `getDashboard`, it survives the reset of the active
* `dashboardId` while a dashboard (re)loads, so a dashboard seen earlier in
* this session can draw its real layout as a skeleton. Undefined on any fresh
* page load.
*/
export const getLastSeenDashboard = (
state: State,
dashboardId: DashboardId | null,
): StoreDashboard | undefined =>
dashboardId == null ? undefined : getDashboardById(state, dashboardId);
/**
* Whether the dashboard as last seen is fixed width. Without it in the cache,
* assume it is, since fixed is the default width for dashboards.
*/
export const getIsLastSeenDashboardFixedWidth = (
state: State,
dashboardId: DashboardId | null,
): boolean =>
(getLastSeenDashboard(state, dashboardId)?.width ?? "fixed") === "fixed";
/**
* The dashcards of the tab the user is landing on, in the dashboard as last
* seen. The tab is chosen the way `getSelectedTabId` chooses it on load.
*/
export const getLastSeenTabDashcards = createSelector(
[
getLastSeenDashboard,
getDashcards,
getIsWebApp,
(state: State) => getSetting(state, "site-url"),
(state: State & Partial<SdkSharedStoreState>) =>
state.sdk?.initialDashboardTabId,
],
(
dashboard,
dashcardMap,
isWebApp,
siteUrl,
sdkInitialDashboardTabId,
): StoreDashcard[] => {
if (!dashboard) {
return EMPTY_DASHCARDS;
}
const dashboardWithVisibleTabs = {
...dashboard,
tabs: dashboard.tabs?.filter((tab) => !tab.isRemoved),
};
const tabId = isEmbeddingSdk()
? getSdkInitialDashboardTabId(
dashboardWithVisibleTabs,
sdkInitialDashboardTabId,
)
: getInitialSelectedTabId(dashboardWithVisibleTabs, siteUrl, isWebApp);
const dashcards = dashboard.dashcards
.map((id) => dashcardMap[id])
.filter((dc): dc is StoreDashcard => isNotNull(dc) && !dc.isRemoved);
return tabId == null
? dashcards
: dashcards.filter((dc) => dc.dashboard_tab_id === tabId);
},
);
/**
* The filters shown in the header of the dashboard as last seen: its
* parameters except those placed inline on a dashcard.
*/
export const getLastSeenDashboardHeaderParameters = createSelector(
[getLastSeenDashboard, getDashcards],
(dashboard, dashcardMap): Parameter[] => {
if (!dashboard?.parameters) {
return EMPTY_PARAMETERS;
}
const dashcards = dashboard.dashcards
.map((id) => dashcardMap[id])
.filter(isNotNull);
return dashboard.parameters.filter(
(parameter) => !isDashcardInlineParameter(parameter.id, dashcards),
);
},
);
export const getLinkTargetEntities = (state: State) =>
state.dashboard.linkTargets;

View File

@@ -13,7 +13,11 @@ import {
getDashboardHeaderParameters,
getEditingParameterId,
getIsEditingParameter,
getIsLastSeenDashboardFixedWidth,
getIsSharing,
getLastSeenDashboard,
getLastSeenDashboardHeaderParameters,
getLastSeenTabDashcards,
getParameters,
getQuestionByCard,
getSelectedTabId,
@@ -566,4 +570,110 @@ describe("getQuestionByCard", () => {
expect(question).toBe(second[index]);
});
});
describe("getLastSeenTabDashcards", () => {
// The active pointer is null, mimicking the window during a (re)fetch when
// the layout can only be read from the in-memory Redux cache by id.
const CACHED_STATE = createMockState({
dashboard: createMockDashboardState({
dashboardId: null,
dashboards: {
5: createMockStoreDashboard({
id: 5,
dashcards: [10, 11, 12],
tabs: [
createMockDashboardTab({ id: 100 }),
createMockDashboardTab({ id: 200 }),
],
}),
},
dashcards: {
10: createMockDashboardCard({ id: 10, dashboard_tab_id: 100 }),
11: createMockDashboardCard({ id: 11, dashboard_tab_id: 100 }),
12: createMockDashboardCard({ id: 12, dashboard_tab_id: 200 }),
},
}),
});
it("returns the cached default-tab dashcards even when the active dashboard id is reset", () => {
const cards = getLastSeenTabDashcards(CACHED_STATE, 5);
expect(cards.map((dc) => dc.id)).toEqual([10, 11]);
});
it("returns an empty array for a dashboard that isn't cached", () => {
expect(getLastSeenTabDashcards(CACHED_STATE, 999)).toEqual([]);
expect(getLastSeenTabDashcards(CACHED_STATE, null)).toEqual([]);
});
});
describe("getLastSeenDashboard", () => {
const STATE = createMockState({
dashboard: createMockDashboardState({
dashboardId: null,
dashboards: { 5: createMockStoreDashboard({ id: 5 }) },
}),
});
it("returns the dashboard from the cache even when the active dashboard id is reset", () => {
expect(getLastSeenDashboard(STATE, 5)).toMatchObject({ id: 5 });
});
it("returns nothing for a dashboard that isn't cached", () => {
expect(getLastSeenDashboard(STATE, 999)).toBeUndefined();
expect(getLastSeenDashboard(STATE, null)).toBeUndefined();
});
});
describe("getIsLastSeenDashboardFixedWidth", () => {
const STATE = createMockState({
dashboard: createMockDashboardState({
dashboardId: null,
dashboards: {
5: createMockStoreDashboard({ id: 5, width: "fixed" }),
6: createMockStoreDashboard({ id: 6, width: "full" }),
},
}),
});
it("follows the cached dashboard's width", () => {
expect(getIsLastSeenDashboardFixedWidth(STATE, 5)).toBe(true);
expect(getIsLastSeenDashboardFixedWidth(STATE, 6)).toBe(false);
});
it("assumes the default fixed width for a dashboard that isn't cached", () => {
expect(getIsLastSeenDashboardFixedWidth(STATE, 999)).toBe(true);
expect(getIsLastSeenDashboardFixedWidth(STATE, null)).toBe(true);
});
});
describe("getLastSeenDashboardHeaderParameters", () => {
const STATE = createMockState({
dashboard: createMockDashboardState({
dashboardId: null,
dashboards: {
5: createMockStoreDashboard({
id: 5,
dashcards: [10],
parameters: [
createMockParameter({ id: "a" }),
createMockParameter({ id: "b" }),
],
}),
},
dashcards: {
10: createMockDashboardCard({ id: 10, inline_parameters: ["b"] }),
},
}),
});
it("returns the cached dashboard's parameters except those inline on a dashcard", () => {
expect(
getLastSeenDashboardHeaderParameters(STATE, 5).map(({ id }) => id),
).toEqual(["a"]);
});
it("returns no parameters for a dashboard that isn't cached", () => {
expect(getLastSeenDashboardHeaderParameters(STATE, 999)).toEqual([]);
});
});
});

View File

@@ -104,6 +104,12 @@ export function isHeadingDashCard(
return getVirtualCardType(dashcard) === "heading";
}
export function isTextDashCard(
dashcard: BaseDashboardCard,
): dashcard is VirtualDashboardCard {
return getVirtualCardType(dashcard) === "text";
}
export function isLinkDashCard(
dashcard: BaseDashboardCard,
): dashcard is VirtualDashboardCard {

View File

@@ -17,6 +17,28 @@ describe("PublicOrEmbeddedDashboardPage", () => {
setupLastDownloadFormatEndpoints();
});
it("shows the grid skeleton, not the empty state, while the dashboard loads", async () => {
const { releaseDashboardRequest } = await setupCommon({
holdDashboardRequest: true,
});
try {
expect(
await screen.findByTestId("dashboard-grid-skeleton"),
).toBeInTheDocument();
expect(
screen.queryByText("This dashboard is empty"),
).not.toBeInTheDocument();
} finally {
releaseDashboardRequest();
}
expect(await screen.findByTestId("dashboard-grid")).toBeInTheDocument();
expect(
screen.queryByTestId("dashboard-grid-skeleton"),
).not.toBeInTheDocument();
});
it("should display dashboard tabs", async () => {
await setupCommon({ numberOfTabs: 2 });

View File

@@ -1,3 +1,4 @@
import fetchMock from "fetch-mock";
import _ from "underscore";
import { setupEnterpriseOnlyPlugin } from "__support__/enterprise";
@@ -8,6 +9,7 @@ import { createMockState } from "__support__/state";
import { renderWithProviders, screen } from "__support__/ui";
import { Route } from "metabase/router";
import { registerStaticVisualizations } from "metabase/static-viz/register";
import { defer } from "metabase/utils/promise";
import type {
DashboardCard,
DashboardTab,
@@ -36,6 +38,8 @@ export type SetupOpts = {
tokenFeatures?: TokenFeatures;
dashboardTitle: string;
enterprisePlugins?: Parameters<typeof setupEnterpriseOnlyPlugin>[0][];
/** Holds the dashboard request until `releaseDashboardRequest` is called. */
holdDashboardRequest?: boolean;
};
export async function setup(
@@ -46,6 +50,7 @@ export async function setup(
tokenFeatures = createMockTokenFeatures(),
dashboardTitle,
enterprisePlugins,
holdDashboardRequest = false,
}: SetupOpts = { dashboardTitle: "" },
) {
mockSettings({
@@ -87,6 +92,14 @@ export async function setup(
tabs,
});
const dashboardRequest = defer();
if (holdDashboardRequest) {
// Registered first, so it answers the dashboard request instead of the
// route `setupEmbedDashboardEndpoints` adds.
fetchMock.get(`path:/api/embed/dashboard/${MOCK_TOKEN}`, () =>
dashboardRequest.promise.then(() => dashboard),
);
}
setupEmbedDashboardEndpoints(MOCK_TOKEN, dashboard, dashcards);
const pathname = `/embed/dashboard/${MOCK_TOKEN}`;
@@ -109,9 +122,12 @@ export async function setup(
},
);
if (numberOfTabs > 0) {
if (numberOfTabs > 0 && !holdDashboardRequest) {
expect(await screen.findByTestId("dashboard-grid")).toBeInTheDocument();
}
return view;
return {
...view,
releaseDashboardRequest: () => dashboardRequest.resolve(),
};
}

View File

@@ -8,6 +8,8 @@ import { DEFAULT_CARD_SIZE_JSON } from "cljs/metabase.dashboards.constants";
export const GRID_WIDTH = 24;
export const GRID_ASPECT_RATIO = 10 / 9;
// The dashboard grid skeleton's mobile container query repeats this value,
// because CSS can't read it. Keep the two in sync.
const MOBILE_BREAKPOINT = 752;
export const GRID_BREAKPOINTS = {
@@ -20,6 +22,15 @@ export const GRID_COLUMNS = {
mobile: 1,
};
/** The gaps between dashcards, as `[horizontal, vertical]` pixels. */
export const GRID_MARGINS: Record<
keyof typeof GRID_BREAKPOINTS,
[number, number]
> = {
desktop: [6, 6],
mobile: [6, 10],
};
/** @type {{ width: number, height: number }} */
export const DEFAULT_CARD_SIZE = DEFAULT_CARD_SIZE_JSON;

View File

@@ -579,6 +579,26 @@ export const waitForLoaderToBeRemoved = async () => {
});
};
/**
* Waits for a dashboard to finish loading. While it loads, a dashboard renders
* skeletons rather than a loader, so `waitForLoaderToBeRemoved` alone returns
* before the dashboard is there.
*/
export const waitForDashboardToLoad = async () => {
await waitFor(
() => {
expect(
screen.queryByTestId("dashboard-grid-skeleton"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("dashboard-header-skeleton"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("loading-indicator")).not.toBeInTheDocument();
},
{ timeout: 3000 },
);
};
/**
* jsdom doesn't have offsetHeight and offsetWidth, so we need to mock it
*/

View File

@@ -440,7 +440,7 @@
"chromatic": "bun run build:cljs && NODE_OPTIONS=--max-old-space-size=8192 chromatic",
"ci": "bun run ci-frontend && bun run ci-backend",
"ci-backend": "clojure -X:dev:ee:ee-dev:drivers:drivers-dev:eastwood && clojure -X:dev:test",
"ci-frontend": "bun run lint && bun run test",
"ci-frontend": "bun run lint && bun run type-check && bun run test",
"clean-cypress-artifacts": "rm -R -f cypress/",
"clean-dev:cljs": "rm -rf target/cljs_dev/*",
"clean-dev:js": "rm -rf resources/frontend_client/app/dist target/classes/frontend_client/app/dist resources/frontend_client/app/embedding-sdk target/classes/frontend_client/app/embedding-sdk",
@@ -473,11 +473,10 @@
"generate-cypress-html-report": "mochawesome-merge cypress/reports/mochareports/*.json > cypress/reports/cypress-test-report.json && marge cypress/reports/cypress-test-report.json -o cypress/reports --inline",
"generate-openapi": "clojure -M:run:ee generate-openapi-spec",
"lint-openapi": "redocly lint resources/openapi/openapi.json --skip-rule security-defined --skip-rule operation-operationId --skip-rule no-unused-components --skip-rule info-license --skip-rule no-path-trailing-slash --skip-rule no-identical-paths --skip-rule no-ambiguous-paths --skip-rule path-parameters-defined --skip-rule spec-components-invalid-map-name",
"lint": "bun run lint-oxlint && bun run lint-format && bun run lint-docs-links && bun run lint-yaml && bun run type-check",
"lint": "bun run lint-oxlint && bun run lint-format && bun run lint-docs-links && bun run lint-yaml",
"lint-docs-links": "bun install && ./bin/verify-doc-links",
"lint-css": "stylelint './frontend/**/*.css'",
"lint-oxlint": "bun run build:cljs && bun run lint-oxlint-pure",
"lint-oxlint-pure": "oxlint --disable-nested-config --max-warnings 0 --report-unused-disable-directives enterprise/frontend frontend e2e",
"lint-oxlint": "node frontend/lint/scripts/run-oxlint.mjs",
"test-oxlint": "node --test frontend/lint/tests/oxlint-*.test.mjs",
"lint-config-update": "node frontend/lint/update-recommended-rules.mjs && oxfmt frontend/lint/recommended-rules.json frontend/lint/oxlint/rule-defaults.json",
"module-boundaries": "eslint --config eslint.config.module-boundaries.mjs 'frontend/src/**/*.{ts,tsx}' 'enterprise/frontend/src/**/*.{ts,tsx}'",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -1,7 +0,0 @@
<svg width="40" height="32" viewBox="0 0 40 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.25" y="1.25" width="37.5" height="29.5" rx="2.75" stroke="current" stroke-width="2.5"/>
<path opacity="0.5" d="M6 11C6 10.4477 6.44772 10 7 10H33C33.5523 10 34 10.4477 34 11V18C34 18.5523 33.5523 19 33 19H7C6.44772 19 6 18.5523 6 18V11Z" stroke="#949AAB" stroke-width="2"/>
<rect x="5" y="5" width="30" height="2" fill="current"/>
<rect x="5" y="22" width="30" height="2" fill="current"/>
<rect x="5" y="26" width="30" height="2" fill="current"/>
</svg>

Before

Width:  |  Height:  |  Size: 581 B

View File

@@ -1,9 +0,0 @@
<svg width="40" height="32" viewBox="0 0 40 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.25" y="1.25" width="37.5" height="29.5" rx="2.75" stroke="#EDF2F5" stroke-width="2.5"/>
<path class="inner-box" d="M14 12C14 10.8954 14.8954 10 16 10H33C34.1046 10 35 10.8954 35 12V17H14V12Z" fill="#93A1AB"
fill-opacity="0.5"/>
<path class="inner-box" d="M14 19H23V28H16C14.8954 28 14 27.1046 14 26V19Z" fill="#93A1AB" fill-opacity="0.5"/>
<path class="inner-box" d="M25 19H35V26C35 27.1046 34.1046 28 33 28H25V19Z" fill="#93A1AB" fill-opacity="0.5"/>
<path d="M5 10H11V28H7C5.89543 28 5 27.1046 5 26V10Z" fill="#EDF2F5"/>
<path d="M5 6C5 4.89543 5.89543 4 7 4H33C34.1046 4 35 4.89543 35 6V8H5V6Z" fill="#EDF2F5"/>
</svg>

Before

Width:  |  Height:  |  Size: 763 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 56 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 789 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -1,35 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="260.000000pt" height="260.000000pt" viewBox="0 0 260.000000 260.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,260.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M473 1959 c-29 -18 -63 -80 -63 -114 0 -8 7 -33 16 -55 41 -103 190
-107 237 -7 21 44 21 80 0 124 -32 69 -124 94 -190 52z"/>
<path d="M1965 1949 c-66 -59 -66 -150 1 -208 83 -74 214 -10 214 104 0 114
-131 178 -215 104z"/>
<path d="M517 1592 c-38 -5 -94 -63 -103 -106 -27 -149 171 -227 244 -97 25
45 28 79 8 124 -24 59 -79 87 -149 79z"/>
<path d="M853 1570 c-115 -70 -65 -250 70 -250 44 0 53 4 88 39 36 35 39 43
39 93 0 41 -5 62 -21 82 -44 56 -118 72 -176 36z"/>
<path d="M1603 1571 c-113 -71 -65 -251 66 -251 125 0 183 147 92 234 -40 39
-112 46 -158 17z"/>
<path d="M1995 1580 c-56 -23 -92 -99 -75 -159 19 -67 63 -101 131 -101 99 0
156 97 115 195 -25 60 -108 92 -171 65z"/>
<path d="M494 1196 c-91 -41 -109 -166 -33 -230 82 -69 202 -19 215 90 12 99
-93 180 -182 140z"/>
<path d="M1223 1179 c-45 -29 -70 -96 -55 -147 36 -119 194 -138 245 -29 60
126 -74 250 -190 176z"/>
<path d="M1980 1183 c-53 -36 -76 -96 -60 -154 32 -116 200 -133 246 -24 28
68 11 134 -45 174 -36 26 -106 28 -141 4z"/>
<path d="M483 801 c-109 -68 -85 -225 38 -254 65 -16 141 38 155 108 9 49 -1
80 -39 121 -42 46 -105 56 -154 25z"/>
<path d="M2013 813 c-61 -12 -109 -99 -93 -168 27 -119 185 -141 242 -34 56
104 -33 225 -149 202z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 984 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -1,16 +0,0 @@
<svg width="1440" height="768" viewBox="0 0 1440 768" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="1439" height="767" fill="#FAFBFC"/>
<rect x="38" y="347.663" width="87.8109" height="361.337" fill="#EDF2F5" fill-opacity="0.56"/>
<rect x="144.3" y="263.631" width="87.8109" height="445.369" fill="#EDF2F5" fill-opacity="0.56"/>
<rect x="250.591" y="347.663" width="87.8109" height="361.337" fill="#EDF2F5" fill-opacity="0.56"/>
<rect x="356.889" y="169" width="87.8109" height="540" fill="#EDF2F5" fill-opacity="0.56"/>
<rect x="463.183" y="263.631" width="87.8109" height="445.369" fill="#EDF2F5" fill-opacity="0.56"/>
<rect x="569.487" y="288.841" width="87.8109" height="420.159" fill="#EDF2F5" fill-opacity="0.56"/>
<rect x="675.781" y="213.212" width="92.4325" height="495.788" fill="#EDF2F5" fill-opacity="0.56"/>
<rect x="782.078" y="263.631" width="92.4325" height="445.369" fill="#EDF2F5" fill-opacity="0.56"/>
<rect x="888.382" y="263.631" width="92.4325" height="445.369" fill="#EDF2F5" fill-opacity="0.56"/>
<rect x="999.302" y="213.212" width="87.8109" height="495.788" fill="#EDF2F5" fill-opacity="0.56"/>
<rect x="1105.6" y="263.631" width="87.8109" height="445.369" fill="#EDF2F5" fill-opacity="0.56"/>
<rect x="1211.89" y="263.631" width="87.8109" height="445.369" fill="#EDF2F5" fill-opacity="0.56"/>
<rect x="1318.19" y="213.212" width="87.8109" height="495.788" fill="#EDF2F5" fill-opacity="0.56"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -1 +0,0 @@
<svg width="148" height="109" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 104C1.4 76.4 24.167 51.167 36 42H7.5l-1 .5c-.4 2.8-2.833 3.833-4 4v57l2.5.5Z" fill="#E9F4FE"/><path d="M45.5 42h-35a8 8 0 0 0-8 8v50a8 8 0 0 0 8 8h50a8 8 0 0 0 8-8V71.5" stroke="#509EE3" stroke-width="1.5"/><path d="M68.75 105a3.25 3.25 0 1 1-6.5 0 3.25 3.25 0 0 1 6.5 0ZM7.25 105a3.25 3.25 0 1 1-6.5 0 3.25 3.25 0 0 1 6.5 0ZM8.25 45.5a3.25 3.25 0 1 1-6.5 0 3.25 3.25 0 0 1 6.5 0Z" fill="#fff" stroke="#509EE3" stroke-width="1.5"/><path d="M18.5 41.5v66M52 58H2M68 75H2M35 41.5v66M51 56v52m18-17H2" stroke="#509EE3"/><path d="M21.75 58a3.25 3.25 0 1 1-6.5 0 3.25 3.25 0 0 1 6.5 0Z" fill="#509EE3" stroke="#509EE3" stroke-width="1.5"/><path d="M38.25 91a3.25 3.25 0 1 1-6.5 0 3.25 3.25 0 0 1 6.5 0Z" fill="#fff" stroke="#509EE3" stroke-width="1.5"/><circle cx="51" cy="75" r="3.25" fill="#509EE3" stroke="#509EE3" stroke-width="1.5"/><path d="M53.5 49H85M66 65h19" stroke="#509EE3" stroke-width="1.3" stroke-linecap="round"/><path d="M66 33v32" stroke="#509EE3" stroke-width="1.3"/><path d="M71 64a4 4 0 0 0-7.924-.78l6.57 3.78A3.99 3.99 0 0 0 71 64Z" fill="#509EE3"/><path d="M69.646 67a4 4 0 1 0-6.52-4l-.126.5" stroke="#509EE3" stroke-width="1.5"/><path opacity=".16" d="M43 40c0 20.434 16.79 37 37.5 37 9.917 0 18.794-4.298 25.5-10.5-5.88 4-19.7 10.5-30.5 6.5-3.731-.167-12.555-2.1-20-10.5-3.833-3.667-11.6-14.6-10-29l3.125-13A36.465 36.465 0 0 0 43 40Z" fill="#509EE3"/><path d="m107.436 65.634 4.081-4.233 34.282 32.301a2.89 2.89 0 0 1 .099 4.121 2.992 2.992 0 0 1-4.193.1l-34.269-32.29Z" stroke="#509EE3" stroke-width="1.5" stroke-linejoin="round"/><path d="M119.859 37.37c0 20.217-16.582 36.62-37.054 36.62-20.473 0-37.055-16.403-37.055-36.62C45.75 17.154 62.332.75 82.805.75c20.472 0 37.054 16.404 37.054 36.62Z" stroke="#509EE3" stroke-width="1.5" stroke-linejoin="round"/><path d="M114.751 37.875c0 17.717-14.306 32.075-31.946 32.075S50.86 55.592 50.86 37.875C50.86 20.157 65.165 5.8 82.805 5.8s31.946 14.357 31.946 32.075Z" stroke="#509EE3" stroke-width="1.5" stroke-linejoin="round"/><path d="M108 29c1.307 4.72 1.856 10.608.305 17" stroke="#509EE3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M51 33h26a8 8 0 0 1 8 8v28.5" stroke="#509EE3" stroke-width="1.5"/><path d="M85.75 36a3.25 3.25 0 1 1-6.5 0 3.25 3.25 0 0 1 6.5 0Z" fill="#fff" stroke="#509EE3" stroke-width="1.5"/></svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -4,6 +4,7 @@
[metabase.models.interface :as mi]
[metabase.revisions.db :as revisions.db]
[metabase.util.i18n :refer [deferred-tru]]
[metabase.util.log :as log]
[metabase.util.match :as match]))
(defn- readable-name
@@ -150,19 +151,26 @@
(defn ^:private model-str->i18n-str
[model-str]
(case model-str
"Dashboard" (deferred-tru "Dashboard")
"Card" (deferred-tru "Card")
"Segment" (deferred-tru "Segment")
"Measure" (deferred-tru "Measure")
"Document" (deferred-tru "Document")
"Transform" (deferred-tru "Transform")))
"Dashboard" (deferred-tru "Dashboard")
"Card" (deferred-tru "Card")
"Segment" (deferred-tru "Segment")
"Measure" (deferred-tru "Measure")
"Document" (deferred-tru "Document")
"Exploration" (deferred-tru "Exploration")
"Transform" (deferred-tru "Transform")
"TransformTest" (deferred-tru "Transform test")))
(defn diff-strings*
"Create a seq of string describing how `o1` is different from `o2`.
The directionality of the statement should indicate that `o1` changed into `o2`."
[model o1 o2]
(when-let [[before after] (data/diff o1 o2)]
(let [model-name (model-str->i18n-str model)
(let [model-name (try
(model-str->i18n-str model)
;; A missing display name costs one untranslated word, not the whole history read.
(catch Exception e
(log/warnf e "No display name for model %s; the revision description uses the model string" model)
model))
;; ignore collection_id as part of diff if the dashboard_id has changed
;; so that the final diff string doesn't contain two messages about moving
ks (cond->> (keys (or after before))

View File

@@ -115,6 +115,36 @@
{:private true :collection_unknown_field nil}
{:private false :collection_unknown_field 1}))))))
(deftest ^:parallel every-revisioned-model-has-a-diff-description-test
(testing "Each revisioned model gets a description of a rename"
(let [model->display-name (cond-> {:model/Card "Card"
:model/Dashboard "Dashboard"
:model/Document "Document"
:model/Exploration "Exploration"
:model/Measure "Measure"
:model/Segment "Segment"
:model/Transform "Transform"}
config/ee-available? (assoc :model/TransformTest "Transform test"))]
;; `push-revision!` calls `serialize-instance`, which has no default: a model with revisions has a method.
(is (= (set (filter #(= "model" (namespace %)) (keys (methods revision/serialize-instance))))
(set (keys model->display-name))))
(doseq [[model display-name] model->display-name]
(testing model
(mt/with-log-messages-for-level [messages [metabase.revisions.models.revision.diff :warn]]
(is (= (str "renamed this " display-name " from \"A\" to \"B\".")
(u/build-sentence (revision/diff-strings model {:name "A"} {:name "B"}))))
;; A missing display name falls back to the model string, which equals the display name for most models.
(testing "with no warning of a missing display name"
(is (= [] (messages))))))))))
(deftest ^:parallel model-with-no-display-name-gets-a-description-test
(testing "A model with no display name gets a description with the model string, and a warning that names the model"
(mt/with-log-messages-for-level [messages [metabase.revisions.models.revision.diff :warn]]
(is (= "renamed this NoSuchModel from \"A\" to \"B\"."
(u/build-sentence (revision.diff/diff-strings* "NoSuchModel" {:name "A"} {:name "B"}))))
(is (=? [{:level :warn, :message #".*NoSuchModel.*"}]
(messages))))))
;;; # REVISIONS + PUSH-REVISION!
(deftest new-object-no-revisions-test