Compare commits
4 Commits
worktree-r
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afa79587c2 | ||
|
|
2b271c6987 | ||
|
|
37d5c5b042 | ||
|
|
30d6158ea1 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
6
.github/workflows/frontend.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)))))))))))
|
||||
|
||||
@@ -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",
|
||||
|
||||
35
frontend/lint/scripts/run-oxlint.mjs
Normal 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);
|
||||
}
|
||||
64
frontend/lint/tests/oxlint-run.test.mjs
Normal 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"],
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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…`} />;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { DashboardGridSkeleton } from "./DashboardGridSkeleton";
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 } }) => {
|
||||
|
||||
@@ -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 } }) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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}'",
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 60 KiB |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 789 B |
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 651 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 44 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 984 B |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 36 KiB |
@@ -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 |
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||