diff --git a/frontend/src/metabase-types/api/dashboard.ts b/frontend/src/metabase-types/api/dashboard.ts index ccdd984e8807244b3c50632f553f58ea75713ba3..55e17e7d780db0ecedfeb3bb7404fcd3987fe6c0 100644 --- a/frontend/src/metabase-types/api/dashboard.ts +++ b/frontend/src/metabase-types/api/dashboard.ts @@ -39,7 +39,9 @@ export interface Dashboard { model?: string; dashcards: DashboardCard[]; tabs?: DashboardTab[]; + show_in_getting_started?: boolean | null; parameters?: Parameter[] | null; + point_of_interest?: string | null; collection_authority_level?: CollectionAuthorityLevel; can_write: boolean; cache_ttl: number | null; @@ -201,3 +203,52 @@ export interface GetCompatibleCardsPayload { query?: string; exclude_ids: number[]; } + +export type ListDashboardsRequest = { + f?: "all" | "mine" | "archived"; +}; + +export type GetDashboardRequest = { + id: DashboardId; + ignore_error?: boolean; +}; + +export type CreateDashboardRequest = { + name: string; + description?: string | null; + parameters?: Parameter[] | null; + cache_ttl?: number; + collection_id?: CollectionId | null; + collection_position?: number | null; +}; + +export type UpdateDashboardRequest = { + id: DashboardId; + parameters?: Parameter[] | null; + point_of_interest?: string | null; + description?: string | null; + archived?: boolean | null; + dashcards?: DashboardCard[] | null; + collection_position?: number | null; + tabs?: DashboardTab[]; + show_in_getting_started?: boolean | null; + enable_embedding?: boolean | null; + collection_id?: CollectionId | null; + name?: string | null; + width?: DashboardWidth | null; + caveats?: string | null; + embedding_params?: EmbeddingParameters | null; + cache_ttl?: number; + position?: number | null; +}; + +export type SaveDashboardRequest = Omit<UpdateDashboardRequest, "id">; + +export type CopyDashboardRequest = { + id: DashboardId; + name?: string | null; + description?: string | null; + collection_id?: CollectionId | null; + collection_position?: number | null; + is_deep_copy?: boolean | null; +}; diff --git a/frontend/src/metabase/api/dashboard.ts b/frontend/src/metabase/api/dashboard.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d2bf1f34f21c93e3ce8ca745868ec7732eba22f --- /dev/null +++ b/frontend/src/metabase/api/dashboard.ts @@ -0,0 +1,96 @@ +import type { + CopyDashboardRequest, + CreateDashboardRequest, + Dashboard, + DashboardId, + GetDashboardRequest, + ListDashboardsRequest, + SaveDashboardRequest, + UpdateDashboardRequest, +} from "metabase-types/api"; + +import { Api } from "./api"; +import { + idTag, + invalidateTags, + listTag, + provideDashboardListTags, + provideDashboardTags, +} from "./tags"; + +export const dashboardApi = Api.injectEndpoints({ + endpoints: builder => ({ + listDashboards: builder.query<Dashboard[], ListDashboardsRequest | void>({ + query: body => ({ + method: "GET", + url: "/api/dashboard", + body, + }), + providesTags: dashboards => + dashboards ? provideDashboardListTags(dashboards) : [], + }), + getDashboard: builder.query<Dashboard, GetDashboardRequest>({ + query: ({ id, ignore_error }) => ({ + method: "GET", + url: `/api/dashboard/${id}`, + noEvent: ignore_error, + }), + providesTags: dashboard => + dashboard ? provideDashboardTags(dashboard) : [], + }), + createDashboard: builder.mutation<Dashboard, CreateDashboardRequest>({ + query: body => ({ + method: "POST", + url: "/api/dashboard", + body, + }), + invalidatesTags: (_, error) => + invalidateTags(error, [listTag("dashboard")]), + }), + updateDashboard: builder.mutation<Dashboard, UpdateDashboardRequest>({ + query: ({ id, ...body }) => ({ + method: "PUT", + url: `/api/dashboard/${id}`, + body, + }), + invalidatesTags: (_, error, { id }) => + invalidateTags(error, [listTag("dashboard"), idTag("dashboard", id)]), + }), + deleteDashboard: builder.mutation<void, DashboardId>({ + query: id => ({ + method: "DELETE", + url: `/api/dashboard/${id}`, + }), + invalidatesTags: (_, error, id) => + invalidateTags(error, [listTag("dashboard"), idTag("dashboard", id)]), + }), + saveDashboard: builder.mutation<Dashboard, SaveDashboardRequest>({ + query: body => ({ + method: "POST", + url: `/api/dashboard/save`, + body, + }), + invalidatesTags: (_, error) => + invalidateTags(error, [listTag("dashboard")]), + }), + copyDashboard: builder.mutation<Dashboard, CopyDashboardRequest>({ + query: ({ id, ...body }) => ({ + method: "POST", + url: `/api/dashboard/${id}/copy`, + body, + }), + invalidatesTags: (_, error) => + invalidateTags(error, [listTag("dashboard")]), + }), + }), +}); + +export const { + useCopyDashboardMutation, + useCreateDashboardMutation, + useDeleteDashboardMutation, + useGetDashboardQuery, + useListDashboardsQuery, + useSaveDashboardMutation, + useUpdateDashboardMutation, +} = dashboardApi; diff --git a/frontend/src/metabase/api/index.ts b/frontend/src/metabase/api/index.ts index 57b5a083a165ebb5034fd56920eda93a6bb1f998..3afdb03479716b584ca23da737b3472a28c7bfad 100644 --- a/frontend/src/metabase/api/index.ts +++ b/frontend/src/metabase/api/index.ts @@ -6,6 +6,7 @@ export * from "./automagic-dashboards"; export * from "./card"; export * from "./collection"; export * from "./database"; +export * from "./dashboard"; export * from "./bookmark"; export * from "./dataset"; export * from "./field"; diff --git a/frontend/src/metabase/api/tags/utils.ts b/frontend/src/metabase/api/tags/utils.ts index d00a19569670715a975122f8bd2cf14cbbb8547c..4dfd787865980d0d9cf923f523e0e1f42cb99d8c 100644 --- a/frontend/src/metabase/api/tags/utils.ts +++ b/frontend/src/metabase/api/tags/utils.ts @@ -1,5 +1,6 @@ import type { TagDescription } from "@reduxjs/toolkit/query"; +import { isVirtualDashCard } from "metabase/dashboard/utils"; import type { Alert, ApiKey, @@ -8,6 +9,7 @@ import type { Collection, CollectionItem, CollectionItemModel, + Dashboard, Database, DatabaseCandidate, Field, @@ -56,6 +58,10 @@ export function invalidateTags( return !error ? tags : []; } +// ----------------------------------------------------------------------- // +// Keep the below list of entity-specific functions alphabetically sorted. // +// ----------------------------------------------------------------------- // + export function provideActivityItemListTags( items: RecentItem[] | PopularItem[], ): TagDescription<TagType>[] { @@ -94,36 +100,6 @@ export function provideApiKeyTags(apiKey: ApiKey): TagDescription<TagType>[] { return [idTag("api-key", apiKey.id)]; } -export function provideDatabaseCandidateListTags( - candidates: DatabaseCandidate[], -): TagDescription<TagType>[] { - return [ - listTag("schema"), - ...candidates.flatMap(provideDatabaseCandidateTags), - ]; -} - -export function provideDatabaseCandidateTags( - candidate: DatabaseCandidate, -): TagDescription<TagType>[] { - return [idTag("schema", candidate.schema)]; -} - -export function provideDatabaseListTags( - databases: Database[], -): TagDescription<TagType>[] { - return [listTag("database"), ...databases.flatMap(provideDatabaseTags)]; -} - -export function provideDatabaseTags( - database: Database, -): TagDescription<TagType>[] { - return [ - idTag("database", database.id), - ...(database.tables ? provideTableListTags(database.tables) : []), - ]; -} - export function provideBookmarkListTags( bookmarks: Bookmark[], ): TagDescription<TagType>[] { @@ -172,6 +148,58 @@ export function provideCollectionTags( return [idTag("collection", collection.id)]; } +export function provideDatabaseCandidateListTags( + candidates: DatabaseCandidate[], +): TagDescription<TagType>[] { + return [ + listTag("schema"), + ...candidates.flatMap(provideDatabaseCandidateTags), + ]; +} + +export function provideDatabaseCandidateTags( + candidate: DatabaseCandidate, +): TagDescription<TagType>[] { + return [idTag("schema", candidate.schema)]; +} + +export function provideDatabaseListTags( + databases: Database[], +): TagDescription<TagType>[] { + return [listTag("database"), ...databases.flatMap(provideDatabaseTags)]; +} + +export function provideDatabaseTags( + database: Database, +): TagDescription<TagType>[] { + return [ + idTag("database", database.id), + ...(database.tables ? provideTableListTags(database.tables) : []), + ]; +} + +export function provideDashboardListTags( + dashboards: Dashboard[], +): TagDescription<TagType>[] { + return [listTag("dashboard"), ...dashboards.flatMap(provideDashboardTags)]; +} + +export function provideDashboardTags( + dashboard: Dashboard, +): TagDescription<TagType>[] { + const cards = dashboard.dashcards + .flatMap(dashcard => (isVirtualDashCard(dashcard) ? [] : [dashcard])) + .map(dashcard => dashcard.card); + + return [ + idTag("dashboard", dashboard.id), + ...provideCardListTags(cards), + ...(dashboard.collection + ? provideCollectionTags(dashboard.collection) + : []), + ]; +} + export function provideFieldListTags( fields: Field[], ): TagDescription<TagType>[] { diff --git a/frontend/src/metabase/common/hooks/entity-framework/use-dashboard-query/use-dashboard-query.unit.spec.tsx b/frontend/src/metabase/common/hooks/entity-framework/use-dashboard-query/use-dashboard-query.unit.spec.tsx index 969bd31e01bfbb8e2fb909bd837481c4537a4077..50fb95945c5d417af285a97f2a22fa2f1b2f9e93 100644 --- a/frontend/src/metabase/common/hooks/entity-framework/use-dashboard-query/use-dashboard-query.unit.spec.tsx +++ b/frontend/src/metabase/common/hooks/entity-framework/use-dashboard-query/use-dashboard-query.unit.spec.tsx @@ -31,7 +31,7 @@ const setup = () => { renderWithProviders(<TestComponent />); }; -describe("useDatabaseQuery", () => { +describe("useDashboardQuery", () => { it("should be initially loading", () => { setup(); expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); diff --git a/frontend/src/metabase/dashboard/containers/ArchiveDashboardModal.jsx b/frontend/src/metabase/dashboard/containers/ArchiveDashboardModal.jsx index e2e188a58463c11ac6c8aaa77473560a154177fc..9f7cfab7e49c6addb5dfefdb37332cc64ccf9c9d 100644 --- a/frontend/src/metabase/dashboard/containers/ArchiveDashboardModal.jsx +++ b/frontend/src/metabase/dashboard/containers/ArchiveDashboardModal.jsx @@ -9,11 +9,11 @@ import _ from "underscore"; import ArchiveModal from "metabase/components/ArchiveModal"; import Collection from "metabase/entities/collections"; -import Dashboard from "metabase/entities/dashboards"; +import Dashboards from "metabase/entities/dashboards"; import * as Urls from "metabase/lib/urls"; const mapDispatchToProps = { - setDashboardArchived: Dashboard.actions.setArchived, + setDashboardArchived: Dashboards.actions.setArchived, push, }; @@ -56,7 +56,7 @@ class ArchiveDashboardModal extends Component { export const ArchiveDashboardModalConnected = _.compose( connect(null, mapDispatchToProps), - Dashboard.load({ + Dashboards.load({ id: (state, props) => Urls.extractCollectionId(props.params.slug), }), Collection.load({ diff --git a/frontend/src/metabase/entities/dashboards.js b/frontend/src/metabase/entities/dashboards.js index 3fd372e5e014bcc915d2284bd269f55d170f64fc..54acc8623845d8ce9c4c2f3962398fa739672f2e 100644 --- a/frontend/src/metabase/entities/dashboards.js +++ b/frontend/src/metabase/entities/dashboards.js @@ -1,14 +1,17 @@ -import { assocIn } from "icepick"; import { t } from "ttag"; +import { dashboardApi } from "metabase/api"; import { canonicalCollectionId } from "metabase/collections/utils"; import { getCollectionType, normalizedCollection, } from "metabase/entities/collections"; -import { POST, DELETE } from "metabase/lib/api"; import { color } from "metabase/lib/colors"; -import { createEntity, undo } from "metabase/lib/entities"; +import { + createEntity, + entityCompatibleQuery, + undo, +} from "metabase/lib/entities"; import { compose, withAction, @@ -20,8 +23,6 @@ import { addUndo } from "metabase/redux/undo"; import forms from "./dashboards/forms"; -const FAVORITE_ACTION = `metabase/entities/dashboards/FAVORITE`; -const UNFAVORITE_ACTION = `metabase/entities/dashboards/UNFAVORITE`; const COPY_ACTION = `metabase/entities/dashboards/COPY`; /** @@ -36,10 +37,48 @@ const Dashboards = createEntity({ displayNameMany: t`dashboards`, api: { - favorite: POST("/api/dashboard/:id/favorite"), - unfavorite: DELETE("/api/dashboard/:id/favorite"), - save: POST("/api/dashboard/save"), - copy: POST("/api/dashboard/:id/copy"), + list: (entityQuery, dispatch) => + entityCompatibleQuery( + entityQuery, + dispatch, + dashboardApi.endpoints.listDashboards, + ), + get: (entityQuery, options, dispatch) => + entityCompatibleQuery( + { ...entityQuery, ignore_error: options?.noEvent }, + dispatch, + dashboardApi.endpoints.getDashboard, + ), + create: (entityQuery, dispatch) => + entityCompatibleQuery( + entityQuery, + dispatch, + dashboardApi.endpoints.createDashboard, + ), + update: (entityQuery, dispatch) => + entityCompatibleQuery( + entityQuery, + dispatch, + dashboardApi.endpoints.updateDashboard, + ), + delete: ({ id }, dispatch) => + entityCompatibleQuery( + id, + dispatch, + dashboardApi.endpoints.deleteDashboard, + ), + save: (entityQuery, dispatch) => + entityCompatibleQuery( + entityQuery, + dispatch, + dashboardApi.endpoints.saveDashboard, + ), + copy: (entityQuery, dispatch) => + entityCompatibleQuery( + entityQuery, + dispatch, + dashboardApi.endpoints.copyDashboard, + ), }, objectActions: { @@ -67,16 +106,6 @@ const Dashboards = createEntity({ opts, ), - setFavorited: async ({ id }, favorite) => { - if (favorite) { - await Dashboards.api.favorite({ id }); - return { type: FAVORITE_ACTION, payload: id }; - } else { - await Dashboards.api.unfavorite({ id }); - return { type: UNFAVORITE_ACTION, payload: id }; - } - }, - // TODO move into more common area as copy is implemented for more entities copy: compose( withAction(COPY_ACTION), @@ -92,11 +121,15 @@ const Dashboards = createEntity({ (entityObject, overrides, { notify } = {}) => async (dispatch, getState) => { const result = Dashboards.normalize( - await Dashboards.api.copy({ - id: entityObject.id, - ...overrides, - is_deep_copy: !overrides.is_shallow_copy, - }), + await entityCompatibleQuery( + { + id: entityObject.id, + ...overrides, + is_deep_copy: !overrides.is_shallow_copy, + }, + dispatch, + dashboardApi.endpoints.copyDashboard, + ), ); if (notify) { dispatch(addUndo(notify)); @@ -109,7 +142,11 @@ const Dashboards = createEntity({ actions: { save: dashboard => async dispatch => { - const savedDashboard = await Dashboards.api.save(dashboard); + const savedDashboard = await entityCompatibleQuery( + dashboard, + dispatch, + dashboardApi.endpoints.saveDashboard, + ); dispatch({ type: Dashboards.actionTypes.INVALIDATE_LISTS_ACTION }); return { type: "metabase/entities/dashboards/SAVE_DASHBOARD", @@ -119,18 +156,13 @@ const Dashboards = createEntity({ }, reducer: (state = {}, { type, payload, error }) => { - if (type === FAVORITE_ACTION && !error) { - return assocIn(state, [payload, "favorite"], true); - } else if (type === UNFAVORITE_ACTION && !error) { - return assocIn(state, [payload, "favorite"], false); - } else if (type === COPY_ACTION && !error && state[""]) { + if (type === COPY_ACTION && !error && state[""]) { return { ...state, "": state[""].concat([payload.result]) }; } return state; }, objectSelectors: { - getFavorited: dashboard => dashboard && dashboard.favorite, getName: dashboard => dashboard && dashboard.name, getUrl: dashboard => dashboard && Urls.dashboard(dashboard), getCollection: dashboard => diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js index c799f8328c9afdda7f0c7e84fbbcc7c5b885a6b9..ac6dcd29546b12fdd4db6ac207b81db817f3f530 100644 --- a/frontend/src/metabase/services.js +++ b/frontend/src/metabase/services.js @@ -182,8 +182,6 @@ export const DashboardApi = { get: GET("/api/dashboard/:dashId"), update: PUT("/api/dashboard/:id"), delete: DELETE("/api/dashboard/:dashId"), - favorite: POST("/api/dashboard/:dashId/favorite"), - unfavorite: DELETE("/api/dashboard/:dashId/favorite"), parameterValues: GET("/api/dashboard/:dashId/params/:paramId/values"), parameterSearch: GET("/api/dashboard/:dashId/params/:paramId/search/:query"), validFilterFields: GET("/api/dashboard/params/valid-filter-fields"),