From fa10c78ed3de084d1d3544f994d9c2a8559a843f Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Mon, 1 Sep 2025 11:20:37 +0200 Subject: [PATCH] feat(admin): add view configuration client infrastructure (#13186) This is part of stacked PRs to add a view configuration feature which will allow users to customize the columns seen in tables in the Medusa Admin dashboard. **What** - Adds client providers, sdk methods and hooks for interacting with the views api. --- .../admin/dashboard/src/hooks/api/index.ts | 1 + .../admin/dashboard/src/hooks/api/views.tsx | 209 ++++++++++++++++++ .../src/hooks/use-view-configurations.tsx | 105 +++++++++ .../dashboard/src/providers/providers.tsx | 1 - packages/core/js-sdk/src/admin/index.ts | 6 + packages/core/js-sdk/src/admin/views.ts | 125 +++++++++++ 6 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 packages/admin/dashboard/src/hooks/api/views.tsx create mode 100644 packages/admin/dashboard/src/hooks/use-view-configurations.tsx create mode 100644 packages/core/js-sdk/src/admin/views.ts diff --git a/packages/admin/dashboard/src/hooks/api/index.ts b/packages/admin/dashboard/src/hooks/api/index.ts index 7b332ff6cc..aed6edadeb 100644 --- a/packages/admin/dashboard/src/hooks/api/index.ts +++ b/packages/admin/dashboard/src/hooks/api/index.ts @@ -34,4 +34,5 @@ export * from "./tags" export * from "./tax-rates" export * from "./tax-regions" export * from "./users" +export * from "./views" export * from "./workflow-executions" diff --git a/packages/admin/dashboard/src/hooks/api/views.tsx b/packages/admin/dashboard/src/hooks/api/views.tsx new file mode 100644 index 0000000000..9a82c7585e --- /dev/null +++ b/packages/admin/dashboard/src/hooks/api/views.tsx @@ -0,0 +1,209 @@ +import { FetchError } from "@medusajs/js-sdk" +import { HttpTypes } from "@medusajs/types" +import { + QueryKey, + useMutation, + UseMutationOptions, + useQuery, + UseQueryOptions +} from "@tanstack/react-query" + +import { sdk } from "../../lib/client" +import { queryClient } from "../../lib/query-client" +import { queryKeysFactory } from "../../lib/query-key-factory" + +const VIEWS_QUERY_KEY = "views" as const +export const viewsQueryKeys = queryKeysFactory(VIEWS_QUERY_KEY) + +// Generic hook to get columns for any entity +export const useEntityColumns = (entity: string, options?: Omit< + UseQueryOptions< + HttpTypes.AdminViewsEntityColumnsResponse, + FetchError, + HttpTypes.AdminViewsEntityColumnsResponse, + QueryKey + >, + "queryFn" | "queryKey" +>) => { + const { data, ...rest } = useQuery({ + queryFn: () => sdk.admin.views.columns(entity), + queryKey: viewsQueryKeys.list(entity), + ...options, + }) + + return { ...data, ...rest } +} + +// View Configuration hooks + +// List view configurations for an entity +export const useViewConfigurations = ( + entity: string, + query?: HttpTypes.AdminGetViewConfigurationsParams, + options?: Omit< + UseQueryOptions< + HttpTypes.AdminViewConfigurationListResponse, + FetchError, + HttpTypes.AdminViewConfigurationListResponse, + QueryKey + >, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: () => sdk.admin.views.listConfigurations(entity, query), + queryKey: viewsQueryKeys.list(entity, query), + ...options, + }) + + return { ...data, ...rest } +} + +// Get active view configuration for an entity +export const useActiveViewConfiguration = ( + entity: string, + options?: Omit< + UseQueryOptions< + HttpTypes.AdminViewConfigurationResponse & { + active_view_configuration_id?: string | null + is_default_active?: boolean + default_type?: "system" | "code" + }, + FetchError, + HttpTypes.AdminViewConfigurationResponse & { + active_view_configuration_id?: string | null + is_default_active?: boolean + default_type?: "system" | "code" + }, + QueryKey + >, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: () => sdk.admin.views.retrieveActiveConfiguration(entity), + queryKey: [viewsQueryKeys.detail(entity), "active"], + ...options, + }) + + return { ...data, ...rest } +} + +// Get a specific view configuration +export const useViewConfiguration = ( + entity: string, + id: string, + query?: Record, + options?: Omit< + UseQueryOptions< + HttpTypes.AdminViewConfigurationResponse, + FetchError, + HttpTypes.AdminViewConfigurationResponse, + QueryKey + >, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: () => sdk.admin.views.retrieveConfiguration(entity, id, query), + queryKey: viewsQueryKeys.detail(id, query), + ...options, + }) + + return { ...data, ...rest } +} + +export const useCreateViewConfiguration = ( + entity: string, + options?: UseMutationOptions< + HttpTypes.AdminViewConfigurationResponse, + FetchError, + HttpTypes.AdminCreateViewConfiguration + > +) => { + return useMutation({ + mutationFn: (payload: HttpTypes.AdminCreateViewConfiguration) => + sdk.admin.views.createConfiguration(entity, payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: viewsQueryKeys.list(entity) }) + // If set_active was true, also invalidate the active configuration + if ((variables as any).set_active) { + queryClient.invalidateQueries({ + queryKey: [...viewsQueryKeys.detail(entity, "active")] + }) + } + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useUpdateViewConfiguration = ( + entity: string, + id: string, + options?: UseMutationOptions< + HttpTypes.AdminViewConfigurationResponse, + FetchError, + HttpTypes.AdminUpdateViewConfiguration + > +) => { + return useMutation({ + mutationFn: (payload: HttpTypes.AdminUpdateViewConfiguration) => + sdk.admin.views.updateConfiguration(entity, id, payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: viewsQueryKeys.list(entity) }) + queryClient.invalidateQueries({ queryKey: viewsQueryKeys.detail(id) }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +// Delete view configuration +export const useDeleteViewConfiguration = ( + entity: string, + id: string, + options?: UseMutationOptions< + HttpTypes.AdminViewConfigurationDeleteResponse, + FetchError, + void + > +) => { + return useMutation({ + mutationFn: () => sdk.admin.views.deleteConfiguration(entity, id), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: viewsQueryKeys.list(entity) }) + queryClient.invalidateQueries({ queryKey: viewsQueryKeys.detail(id) }) + // Also invalidate active configuration as it might have changed + queryClient.invalidateQueries({ + queryKey: [...viewsQueryKeys.detail(entity, "active")] + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useSetActiveViewConfiguration = ( + entity: string, + options?: UseMutationOptions< + { success: boolean }, + FetchError, + string | null + > +) => { + return useMutation({ + mutationFn: (viewConfigurationId: string | null) => + sdk.admin.views.setActiveConfiguration(entity, { + view_configuration_id: viewConfigurationId + }), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: [...viewsQueryKeys.detail(entity, "active")] + }) + queryClient.invalidateQueries({ queryKey: viewsQueryKeys.list(entity) }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/packages/admin/dashboard/src/hooks/use-view-configurations.tsx b/packages/admin/dashboard/src/hooks/use-view-configurations.tsx new file mode 100644 index 0000000000..c56e339f32 --- /dev/null +++ b/packages/admin/dashboard/src/hooks/use-view-configurations.tsx @@ -0,0 +1,105 @@ +import { useMemo } from "react" +import { HttpTypes } from "@medusajs/types" +import { toast } from "@medusajs/ui" +import { FetchError } from "@medusajs/js-sdk" +import { useFeatureFlag } from "../providers/feature-flag-provider" +import { + useViewConfigurations as useViewConfigurationsBase, + useActiveViewConfiguration as useActiveViewConfigurationBase, + useCreateViewConfiguration as useCreateViewConfigurationBase, + useUpdateViewConfiguration as useUpdateViewConfigurationBase, + useDeleteViewConfiguration as useDeleteViewConfigurationBase, + useSetActiveViewConfiguration as useSetActiveViewConfigurationBase, +} from "./api/views" + +// Re-export the type for convenience +export type ViewConfiguration = HttpTypes.AdminViewConfigurationResponse + +// Common error handler +const handleError = (error: Error, message?: string) => { + console.error("View configuration error:", error) + + let errorMessage = message + if (!errorMessage) { + if (error instanceof FetchError) { + errorMessage = error.message + } else if (error.message) { + errorMessage = error.message + } else { + errorMessage = "An error occurred" + } + } + + toast.error(errorMessage) +} + +export const useViewConfigurations = (entity: string) => { + const isViewConfigEnabled = useFeatureFlag("view_configurations") + + const listViews = useViewConfigurationsBase(entity, { limit: 100 }, { + enabled: isViewConfigEnabled && !!entity, + + }) + + const activeView = useActiveViewConfigurationBase(entity, { + enabled: isViewConfigEnabled && !!entity, + }) + + // Create view mutation + const createView = useCreateViewConfigurationBase(entity, { + onSuccess: () => { + toast.success(`View created`) + }, + onError: (error) => { + handleError(error, "Failed to create view") + }, + }) + + // Set active view mutation + const setActiveView = useSetActiveViewConfigurationBase(entity, { + onSuccess: () => { }, + onError: (error) => { + handleError(error, "Failed to update active view") + }, + }) + + return useMemo(() => ({ + isViewConfigEnabled, + listViews, + activeView, + createView, + setActiveView, + isDefaultViewActive: activeView.data?.is_default_active ?? true, + }), [ + isViewConfigEnabled, + listViews, + activeView, + createView, + setActiveView, + ]) +} + +export const useViewConfiguration = (entity: string, viewId: string) => { + const updateView = useUpdateViewConfigurationBase(entity, viewId, { + onSuccess: () => { + toast.success(`View updated`) + }, + onError: (error) => { + handleError(error, "Failed to update view") + }, + }) + + const deleteView = useDeleteViewConfigurationBase(entity, viewId, { + onSuccess: () => { + toast.success("View deleted") + }, + onError: (error) => { + handleError(error, "Failed to delete view") + }, + }) + + return { + updateView, + deleteView, + } +} diff --git a/packages/admin/dashboard/src/providers/providers.tsx b/packages/admin/dashboard/src/providers/providers.tsx index 027eb97182..2037d2e147 100644 --- a/packages/admin/dashboard/src/providers/providers.tsx +++ b/packages/admin/dashboard/src/providers/providers.tsx @@ -9,7 +9,6 @@ import { ExtensionProvider } from "./extension-provider" import { I18nProvider } from "./i18n-provider" import { ThemeProvider } from "./theme-provider" import { FeatureFlagProvider } from "./feature-flag-provider" - type ProvidersProps = PropsWithChildren<{ api: DashboardApp["api"] }> diff --git a/packages/core/js-sdk/src/admin/index.ts b/packages/core/js-sdk/src/admin/index.ts index b08811ddda..578e23c9cd 100644 --- a/packages/core/js-sdk/src/admin/index.ts +++ b/packages/core/js-sdk/src/admin/index.ts @@ -42,6 +42,7 @@ import { TaxRate } from "./tax-rate" import { TaxRegion } from "./tax-region" import { Upload } from "./upload" import { User } from "./user" +import { Views } from "./views" import { WorkflowExecution } from "./workflow-execution" import { ShippingOptionType } from "./shipping-option-type" @@ -226,6 +227,10 @@ export class Admin { * @tags plugin */ public plugin: Plugin + /** + * @tags views + */ + public views: Views constructor(client: Client) { this.invite = new Invite(client) @@ -273,5 +278,6 @@ export class Admin { this.campaign = new Campaign(client) this.plugin = new Plugin(client) this.taxProvider = new TaxProvider(client) + this.views = new Views(client) } } diff --git a/packages/core/js-sdk/src/admin/views.ts b/packages/core/js-sdk/src/admin/views.ts new file mode 100644 index 0000000000..cec1093d03 --- /dev/null +++ b/packages/core/js-sdk/src/admin/views.ts @@ -0,0 +1,125 @@ +import { HttpTypes, SelectParams } from "@medusajs/types" + +import { Client } from "../client" +import { ClientHeaders } from "../types" + +export class Views { + constructor(private client: Client) {} + + // Generic method to get columns for any entity + async columns( + entity: string, + query?: SelectParams, + headers?: ClientHeaders + ): Promise { + return await this.client.fetch(`/admin/views/${entity}/columns`, { + method: "GET", + headers, + query, + }) + } + + + // View configurations + async listConfigurations( + entity: string, + query?: HttpTypes.AdminGetViewConfigurationsParams, + headers?: ClientHeaders + ): Promise { + return await this.client.fetch(`/admin/views/${entity}/configurations`, { + method: "GET", + headers, + query, + }) + } + + async createConfiguration( + entity: string, + body: HttpTypes.AdminCreateViewConfiguration, + headers?: ClientHeaders + ): Promise { + return await this.client.fetch(`/admin/views/${entity}/configurations`, { + method: "POST", + headers, + body, + }) + } + + async retrieveConfiguration( + entity: string, + id: string, + query?: SelectParams, + headers?: ClientHeaders + ): Promise { + return await this.client.fetch( + `/admin/views/${entity}/configurations/${id}`, + { + method: "GET", + headers, + query, + } + ) + } + + async updateConfiguration( + entity: string, + id: string, + body: HttpTypes.AdminUpdateViewConfiguration, + headers?: ClientHeaders + ): Promise { + return await this.client.fetch( + `/admin/views/${entity}/configurations/${id}`, + { + method: "POST", + headers, + body, + } + ) + } + + async deleteConfiguration( + entity: string, + id: string, + headers?: ClientHeaders + ): Promise { + return await this.client.fetch( + `/admin/views/${entity}/configurations/${id}`, + { + method: "DELETE", + headers, + } + ) + } + + async retrieveActiveConfiguration( + entity: string, + headers?: ClientHeaders + ): Promise< + HttpTypes.AdminViewConfigurationResponse & { + active_view_configuration_id?: string | null + } + > { + return await this.client.fetch( + `/admin/views/${entity}/configurations/active`, + { + method: "GET", + headers, + } + ) + } + + async setActiveConfiguration( + entity: string, + body: { view_configuration_id: string | null }, + headers?: ClientHeaders + ): Promise<{ success: boolean }> { + return await this.client.fetch( + `/admin/views/${entity}/configurations/active`, + { + method: "POST", + headers, + body, + } + ) + } +} \ No newline at end of file