From 12a38bcd2b8c3e60d94b3ea9ff104f1615c3d091 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Fri, 15 Aug 2025 13:17:52 +0200 Subject: [PATCH] feat(api): add view configuration API routes (#13177) * feat: add view_configurations feature flag - Add feature flag provider and hooks to admin dashboard - Add backend API endpoint for feature flags - Create view_configurations feature flag (disabled by default) - Update order list table to use legacy version when flag is disabled - Can be enabled with MEDUSA_FF_VIEW_CONFIGURATIONS=true env var * fix: naming * fix: feature flags unauthenticated * fix: add test * feat: add settings module * fix: deps * fix: cleanup * fix: add more tetsts * fix: rm changelog * fix: deps * fix: add settings module to default modules list * feat(api): add view configuration API routes - Add CRUD endpoints for view configurations - Add active view configuration management endpoints - Add feature flag middleware for view config routes - Add comprehensive integration tests - Add HTTP types for view configuration payloads and responses - Support system defaults and user-specific configurations - Enable setting views as active during create/update operations * fix: test * fix: test * fix: test * fix: change view configuration path * fix: tests * fix: remove manual settings module config from integration tests * fix: container typing * fix: workflows --- .../helpers/create-admin-user.ts | 8 +- .../__tests__/view-configurations.spec.ts | 949 ++++++++++++++++++ packages/core/core-flows/src/index.ts | 1 + .../core/core-flows/src/settings/index.ts | 2 + .../steps/create-view-configuration.ts | 27 + .../core-flows/src/settings/steps/index.ts | 3 + .../steps/set-active-view-configuration.ts | 59 ++ .../steps/update-view-configuration.ts | 41 + .../workflows/create-view-configuration.ts | 42 + .../src/settings/workflows/index.ts | 2 + .../workflows/update-view-configuration.ts | 51 + .../core/framework/src/types/container.ts | 2 + packages/core/types/src/http/index.ts | 1 + .../http/view-configuration/admin/index.ts | 3 + .../http/view-configuration/admin/payloads.ts | 108 ++ .../http/view-configuration/admin/queries.ts | 37 + .../view-configuration/admin/responses.ts | 80 ++ .../src/http/view-configuration/index.ts | 1 + packages/core/types/src/settings/common.ts | 19 +- packages/core/types/src/settings/mutations.ts | 10 +- .../[entity]/configurations/[id]/route.ts | 92 ++ .../[entity]/configurations/active/route.ts | 79 ++ .../[entity]/configurations/middleware.ts | 27 + .../[entity]/configurations/middlewares.ts | 72 ++ .../[entity]/configurations/query-config.ts | 20 + .../views/[entity]/configurations/route.ts | 59 ++ .../[entity]/configurations/validators.ts | 71 ++ packages/medusa/src/api/middlewares.ts | 2 + 28 files changed, 1858 insertions(+), 10 deletions(-) create mode 100644 integration-tests/http/__tests__/view-configurations.spec.ts create mode 100644 packages/core/core-flows/src/settings/index.ts create mode 100644 packages/core/core-flows/src/settings/steps/create-view-configuration.ts create mode 100644 packages/core/core-flows/src/settings/steps/index.ts create mode 100644 packages/core/core-flows/src/settings/steps/set-active-view-configuration.ts create mode 100644 packages/core/core-flows/src/settings/steps/update-view-configuration.ts create mode 100644 packages/core/core-flows/src/settings/workflows/create-view-configuration.ts create mode 100644 packages/core/core-flows/src/settings/workflows/index.ts create mode 100644 packages/core/core-flows/src/settings/workflows/update-view-configuration.ts create mode 100644 packages/core/types/src/http/view-configuration/admin/index.ts create mode 100644 packages/core/types/src/http/view-configuration/admin/payloads.ts create mode 100644 packages/core/types/src/http/view-configuration/admin/queries.ts create mode 100644 packages/core/types/src/http/view-configuration/admin/responses.ts create mode 100644 packages/core/types/src/http/view-configuration/index.ts create mode 100644 packages/medusa/src/api/admin/views/[entity]/configurations/[id]/route.ts create mode 100644 packages/medusa/src/api/admin/views/[entity]/configurations/active/route.ts create mode 100644 packages/medusa/src/api/admin/views/[entity]/configurations/middleware.ts create mode 100644 packages/medusa/src/api/admin/views/[entity]/configurations/middlewares.ts create mode 100644 packages/medusa/src/api/admin/views/[entity]/configurations/query-config.ts create mode 100644 packages/medusa/src/api/admin/views/[entity]/configurations/route.ts create mode 100644 packages/medusa/src/api/admin/views/[entity]/configurations/validators.ts diff --git a/integration-tests/helpers/create-admin-user.ts b/integration-tests/helpers/create-admin-user.ts index 969aa98e44..4124e922d0 100644 --- a/integration-tests/helpers/create-admin-user.ts +++ b/integration-tests/helpers/create-admin-user.ts @@ -22,16 +22,18 @@ export const adminHeaders = { export const createAdminUser = async ( dbConnection, adminHeaders, - container? + container?, + options?: { email?: string } ) => { const appContainer = container ?? getContainer()! + const email = options?.email ?? "admin@medusa.js" const userModule: IUserModuleService = appContainer.resolve(Modules.USER) const authModule: IAuthModuleService = appContainer.resolve(Modules.AUTH) const user = await userModule.createUsers({ first_name: "Admin", last_name: "User", - email: "admin@medusa.js", + email, }) const hashConfig = { logN: 15, r: 8, p: 1 } @@ -41,7 +43,7 @@ export const createAdminUser = async ( provider_identities: [ { provider: "emailpass", - entity_id: "admin@medusa.js", + entity_id: email, provider_metadata: { password: passwordHash.toString("base64"), }, diff --git a/integration-tests/http/__tests__/view-configurations.spec.ts b/integration-tests/http/__tests__/view-configurations.spec.ts new file mode 100644 index 0000000000..0b2f2e2583 --- /dev/null +++ b/integration-tests/http/__tests__/view-configurations.spec.ts @@ -0,0 +1,949 @@ +import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { adminHeaders, createAdminUser } from "../../helpers/create-admin-user" +import { Modules, ContainerRegistrationKeys } from "@medusajs/framework/utils" + +jest.setTimeout(50000) + +const env = { MEDUSA_FF_MEDUSA_V2: true, MEDUSA_FF_VIEW_CONFIGURATIONS: true } + +medusaIntegrationTestRunner({ + env, + testSuite: ({ dbConnection, api, getContainer }) => { + describe("View Configurations API", () => { + let adminHeader + let secondAdminHeader + let secondAdminUserId + let adminUserId + + beforeEach(async () => { + const container = getContainer() + const { user: adminUser } = await createAdminUser( + dbConnection, + adminHeaders, + container + ) + adminHeader = adminHeaders.headers + adminUserId = adminUser.id + + // Create a second admin user + const secondAdminHeaders = { headers: {} } + const { user: secondAdminUser } = await createAdminUser( + dbConnection, + secondAdminHeaders, + container, + { email: "admin2@test.com" } + ) + secondAdminUserId = secondAdminUser.id + secondAdminHeader = secondAdminHeaders.headers + }) + + describe("POST /admin/views/{entity}/configurations", () => { + it("should create a personal view configuration", async () => { + const payload = { + name: "My Order View", + configuration: { + visible_columns: ["id", "display_id", "created_at"], + column_order: ["display_id", "id", "created_at"], + }, + } + + const response = await api.post( + "/admin/views/orders/configurations", + payload, + { + headers: secondAdminHeader, + } + ) + + expect(response.status).toBe(200) + expect(response.data.view_configuration).toMatchObject({ + entity: "orders", + name: "My Order View", + user_id: secondAdminUserId, + configuration: payload.configuration, + }) + expect(response.data.view_configuration.is_system_default).toBeFalsy() + }) + + it("should create a system default view as admin", async () => { + const payload = { + name: "Default Order View", + is_system_default: true, + configuration: { + visible_columns: ["id", "display_id", "created_at", "total"], + column_order: ["display_id", "created_at", "total", "id"], + }, + } + + const response = await api.post( + "/admin/views/orders/configurations", + payload, + { + headers: adminHeader, + } + ) + + expect(response.status).toBe(200) + expect(response.data.view_configuration).toMatchObject({ + entity: "orders", + name: "Default Order View", + user_id: null, + is_system_default: true, + configuration: payload.configuration, + }) + }) + }) + + describe("GET /admin/views/{entity}/configurations", () => { + let systemView + let personalView + let otherUserView + + beforeEach(async () => { + const container = getContainer() + const settingsService = container.resolve("settings") + + // Create system default view + systemView = await settingsService.createViewConfigurations({ + entity: "orders", + name: "System Default", + is_system_default: true, + user_id: null, + configuration: { + visible_columns: ["id", "display_id"], + column_order: ["display_id", "id"], + }, + }) + + // Create personal view for non-admin user + personalView = await settingsService.createViewConfigurations({ + entity: "orders", + name: "My Personal View", + is_system_default: false, + user_id: secondAdminUserId, + configuration: { + visible_columns: ["id", "total"], + column_order: ["total", "id"], + }, + }) + + // Create view for another user + otherUserView = await settingsService.createViewConfigurations({ + entity: "orders", + name: "Other User View", + is_system_default: false, + user_id: "other-user-id", + configuration: { + visible_columns: ["id"], + column_order: ["id"], + }, + }) + }) + + it("should list system defaults and personal views", async () => { + const response = await api.get("/admin/views/orders/configurations", { + headers: secondAdminHeader, + }) + + expect(response.status).toBe(200) + expect(response.data.view_configurations).toHaveLength(2) + expect(response.data.view_configurations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: systemView.id }), + expect.objectContaining({ id: personalView.id }), + ]) + ) + // Should not include other user's view + expect(response.data.view_configurations).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: otherUserView.id }), + ]) + ) + }) + + it("should filter by entity", async () => { + const response = await api.get( + "/admin/views/products/configurations", + { + headers: secondAdminHeader, + } + ) + + expect(response.status).toBe(200) + expect(response.data.view_configurations).toHaveLength(0) + }) + }) + + describe("GET /admin/views/{entity}/configurations/:id", () => { + let viewConfig + + beforeEach(async () => { + const container = getContainer() + const settingsService = container.resolve("settings") + + viewConfig = await settingsService.createViewConfigurations({ + entity: "orders", + name: "Test View", + is_system_default: false, + user_id: secondAdminUserId, + configuration: { + visible_columns: ["id"], + column_order: ["id"], + }, + }) + }) + + it("should retrieve own view configuration", async () => { + const response = await api.get( + `/admin/views/orders/configurations/${viewConfig.id}`, + { + headers: secondAdminHeader, + } + ) + + expect(response.status).toBe(200) + expect(response.data.view_configuration).toMatchObject({ + id: viewConfig.id, + entity: "orders", + name: "Test View", + }) + }) + + it("should prevent access to other user's view", async () => { + const container = getContainer() + const settingsService = container.resolve("settings") + + const otherView = await settingsService.createViewConfigurations({ + entity: "orders", + name: "Other View", + is_system_default: false, + user_id: "other-user-id", + configuration: { + visible_columns: ["id"], + column_order: ["id"], + }, + }) + + const response = await api + .get(`/admin/views/orders/configurations/${otherView.id}`, { + headers: secondAdminHeader, + }) + .catch((e) => e.response) + + expect(response.status).toBe(400) + }) + }) + + describe("POST /admin/views/{entity}/configurations/:id", () => { + let viewConfig + + beforeEach(async () => { + const container = getContainer() + const settingsService = container.resolve("settings") + + viewConfig = await settingsService.createViewConfigurations({ + entity: "orders", + name: "Test View", + is_system_default: false, + user_id: secondAdminUserId, + configuration: { + visible_columns: ["id"], + column_order: ["id"], + }, + }) + }) + + it("should update own view configuration", async () => { + const payload = { + name: "Updated View", + configuration: { + visible_columns: ["id", "total"], + column_order: ["total", "id"], + }, + } + + const response = await api.post( + `/admin/views/orders/configurations/${viewConfig.id}`, + payload, + { + headers: secondAdminHeader, + } + ) + + expect(response.status).toBe(200) + expect(response.data.view_configuration).toMatchObject({ + id: viewConfig.id, + name: "Updated View", + configuration: payload.configuration, + }) + }) + }) + + describe("DELETE /admin/views/{entity}/configurations/:id", () => { + let viewConfig + + beforeEach(async () => { + const container = getContainer() + const settingsService = container.resolve("settings") + + viewConfig = await settingsService.createViewConfigurations({ + entity: "orders", + name: "Test View", + is_system_default: false, + user_id: secondAdminUserId, + configuration: { + visible_columns: ["id"], + column_order: ["id"], + }, + }) + }) + + it("should delete own view configuration", async () => { + const response = await api.delete( + `/admin/views/orders/configurations/${viewConfig.id}`, + { + headers: secondAdminHeader, + } + ) + + expect(response.status).toBe(200) + expect(response.data).toMatchObject({ + id: viewConfig.id, + object: "view_configuration", + deleted: true, + }) + + // Verify it's deleted + const getResponse = await api + .get(`/admin/views/orders/configurations/${viewConfig.id}`, { + headers: secondAdminHeader, + }) + .catch((e) => e.response) + + expect(getResponse.status).toBe(404) + }) + }) + + describe("GET /admin/views/{entity}/configurations/active", () => { + beforeEach(async () => { + const container = getContainer() + const settingsService = container.resolve("settings") + + // Create and set active view + const viewConfig = await settingsService.createViewConfigurations({ + entity: "orders", + name: "Active View", + is_system_default: false, + user_id: secondAdminUserId, + configuration: { + visible_columns: ["id", "total"], + column_order: ["total", "id"], + }, + }) + + await settingsService.setActiveViewConfiguration( + "orders", + secondAdminUserId, + viewConfig.id + ) + }) + + it("should retrieve active view configuration", async () => { + const response = await api.get( + "/admin/views/orders/configurations/active", + { + headers: secondAdminHeader, + } + ) + + expect(response.status).toBe(200) + expect(response.data.view_configuration).toMatchObject({ + entity: "orders", + name: "Active View", + user_id: secondAdminUserId, + }) + }) + + it("should return null when no active view", async () => { + const response = await api.get( + "/admin/views/products/configurations/active", + { + headers: secondAdminHeader, + } + ) + + expect(response.status).toBe(200) + expect(response.data.view_configuration).toBeNull() + }) + }) + + describe("POST /admin/views/{entity}/configurations/active", () => { + let viewConfig + + beforeEach(async () => { + const container = getContainer() + const settingsService = container.resolve("settings") + + viewConfig = await settingsService.createViewConfigurations({ + entity: "orders", + name: "Test View", + is_system_default: false, + user_id: secondAdminUserId, + configuration: { + visible_columns: ["id"], + column_order: ["id"], + }, + }) + }) + + it("should set active view configuration", async () => { + const response = await api.post( + "/admin/views/orders/configurations/active", + { + view_configuration_id: viewConfig.id, + }, + { + headers: secondAdminHeader, + } + ) + + expect(response.status).toBe(200) + expect(response.data.success).toBe(true) + + // Verify it's active + const activeResponse = await api.get( + "/admin/views/orders/configurations/active", + { + headers: secondAdminHeader, + } + ) + + expect(activeResponse.data.view_configuration.id).toBe(viewConfig.id) + }) + + it("should clear active view and return to default when setting view_configuration_id to null", async () => { + // First set an active view + await api.post( + "/admin/views/orders/configurations/active", + { + view_configuration_id: viewConfig.id, + }, + { + headers: secondAdminHeader, + } + ) + + // Verify it's active + let activeResponse = await api.get( + "/admin/views/orders/configurations/active", + { + headers: secondAdminHeader, + } + ) + expect(activeResponse.data.view_configuration.id).toBe(viewConfig.id) + + // Now clear the active view + const clearResponse = await api.post( + "/admin/views/orders/configurations/active", + { + view_configuration_id: null, + }, + { + headers: secondAdminHeader, + } + ) + + expect(clearResponse.status).toBe(200) + expect(clearResponse.data.success).toBe(true) + + // Verify the active view is cleared + activeResponse = await api.get( + "/admin/views/orders/configurations/active", + { + headers: secondAdminHeader, + } + ) + + // Debug output + if (activeResponse.data.view_configuration) { + console.log("Active view after clearing:", { + id: activeResponse.data.view_configuration.id, + name: activeResponse.data.view_configuration.name, + is_system_default: + activeResponse.data.view_configuration.is_system_default, + }) + } + + // Should either return null or a system default if one exists + if (activeResponse.data.view_configuration) { + expect( + activeResponse.data.view_configuration.is_system_default + ).toBe(true) + } else { + expect(activeResponse.data.view_configuration).toBeNull() + } + expect(activeResponse.data.is_default_active).toBe(true) + }) + }) + + describe("System Default Views", () => { + it("should make system default views available to all users", async () => { + const container = getContainer() + + // Create a third admin user + const thirdAdminHeaders = { headers: {} } + const { user: thirdAdminUser } = await createAdminUser( + dbConnection, + thirdAdminHeaders, + container, + { email: "admin3@test.com" } + ) + + // Admin 1 creates a system default view + const systemDefaultView = await api.post( + "/admin/views/orders/configurations", + { + name: "System Default View", + configuration: { + visible_columns: ["id", "display_id", "created_at"], + column_order: ["display_id", "id", "created_at"], + }, + is_system_default: true, + }, + adminHeaders + ) + + expect(systemDefaultView.status).toEqual(200) + expect(systemDefaultView.data.view_configuration.user_id).toBeNull() + + // Admin 3 should be able to see this view + const viewsForAdmin3 = await api.get( + "/admin/views/orders/configurations", + thirdAdminHeaders + ) + + expect(viewsForAdmin3.status).toEqual(200) + const systemDefaults = viewsForAdmin3.data.view_configurations.filter( + (v: any) => v.is_system_default + ) + expect(systemDefaults).toHaveLength(1) + expect(systemDefaults[0].name).toEqual("System Default View") + + // Admin 3 should also be able to retrieve it directly + const directRetrieve = await api.get( + `/admin/views/orders/configurations/${systemDefaultView.data.view_configuration.id}`, + thirdAdminHeaders + ) + + expect(directRetrieve.status).toEqual(200) + expect(directRetrieve.data.view_configuration.name).toEqual( + "System Default View" + ) + }) + + it("should allow creating system default without name", async () => { + // Create a system default view without providing a name + const systemDefaultView = await api.post( + "/admin/views/customers/configurations", + { + is_system_default: true, + configuration: { + visible_columns: ["id", "email", "first_name", "last_name"], + column_order: ["email", "first_name", "last_name", "id"], + }, + // Note: no name field + }, + adminHeaders + ) + + expect(systemDefaultView.status).toEqual(200) + expect(systemDefaultView.data.view_configuration.user_id).toBeNull() + expect( + systemDefaultView.data.view_configuration.is_system_default + ).toBe(true) + // Name should be undefined/null when not provided + expect(systemDefaultView.data.view_configuration.name).toBeFalsy() + }) + + it("should set view as active when created with set_active flag", async () => { + // Create a view with set_active = true + const viewConfig = await api.post( + "/admin/views/orders/configurations", + { + name: "Auto-Active View", + configuration: { + visible_columns: ["id", "display_id", "status"], + column_order: ["display_id", "status", "id"], + }, + set_active: true, + }, + { headers: secondAdminHeader } + ) + + expect(viewConfig.status).toEqual(200) + + // Verify the view is now active + const activeView = await api.get( + "/admin/views/orders/configurations/active", + { headers: secondAdminHeader } + ) + + expect(activeView.status).toEqual(200) + expect(activeView.data.view_configuration).toBeTruthy() + expect(activeView.data.view_configuration.id).toEqual( + viewConfig.data.view_configuration.id + ) + expect(activeView.data.view_configuration.name).toEqual( + "Auto-Active View" + ) + }) + + it("should set view as active when updated with set_active flag", async () => { + const container = getContainer() + const settingsService = container.resolve("settings") + + // Create two views + const view1 = await settingsService.createViewConfigurations({ + entity: "orders", + name: "View 1", + is_system_default: false, + user_id: secondAdminUserId, + configuration: { + visible_columns: ["id"], + column_order: ["id"], + }, + }) + + const view2 = await settingsService.createViewConfigurations({ + entity: "orders", + name: "View 2", + is_system_default: false, + user_id: secondAdminUserId, + configuration: { + visible_columns: ["id", "total"], + column_order: ["total", "id"], + }, + }) + + // Set view1 as active initially + await settingsService.setActiveViewConfiguration( + "orders", + secondAdminUserId, + view1.id + ) + + // Update view2 with set_active flag + const updateResponse = await api.post( + `/admin/views/orders/configurations/${view2.id}`, + { + name: "Updated View 2", + set_active: true, + }, + { headers: secondAdminHeader } + ) + + expect(updateResponse.status).toEqual(200) + + // Verify view2 is now the active view + const activeView = await api.get( + "/admin/views/orders/configurations/active", + { headers: secondAdminHeader } + ) + + expect(activeView.status).toEqual(200) + expect(activeView.data.view_configuration.id).toEqual(view2.id) + expect(activeView.data.view_configuration.name).toEqual( + "Updated View 2" + ) + }) + + it("should allow resetting system default to code-level defaults", async () => { + // Create a system default view + const systemDefaultView = await api.post( + "/admin/views/orders/configurations", + { + name: "Custom System Default", + is_system_default: true, + configuration: { + visible_columns: ["id", "status", "total"], + column_order: ["status", "total", "id"], + }, + }, + adminHeaders + ) + + expect(systemDefaultView.status).toEqual(200) + const viewId = systemDefaultView.data.view_configuration.id + + // Verify it exists + let viewsList = await api.get( + "/admin/views/orders/configurations", + adminHeaders + ) + expect( + viewsList.data.view_configurations.some((v: any) => v.id === viewId) + ).toBe(true) + + // Delete the system default view (reset to code defaults) + const deleteResponse = await api.delete( + `/admin/views/orders/configurations/${viewId}`, + adminHeaders + ) + + expect(deleteResponse.status).toEqual(200) + expect(deleteResponse.data.deleted).toBe(true) + + // Verify it's gone + viewsList = await api.get( + "/admin/views/orders/configurations", + adminHeaders + ) + expect( + viewsList.data.view_configurations.some((v: any) => v.id === viewId) + ).toBe(false) + + // Getting active view should return null (falls back to code defaults) + const activeView = await api.get( + "/admin/views/orders/configurations/active", + adminHeaders + ) + expect(activeView.data.view_configuration).toBeNull() + }) + + it("should return system default view when created and no user view is active", async () => { + // Step 1: Create a system default view + const systemDefaultView = await api.post( + "/admin/views/orders/configurations", + { + name: "System Default Orders", + is_system_default: true, + configuration: { + visible_columns: [ + "id", + "display_id", + "created_at", + "customer", + "total", + ], + column_order: [ + "display_id", + "customer", + "total", + "created_at", + "id", + ], + filters: {}, + sorting: { id: "created_at", desc: true }, + search: "", + }, + }, + adminHeaders + ) + + expect(systemDefaultView.status).toEqual(200) + expect( + systemDefaultView.data.view_configuration.is_system_default + ).toBe(true) + + // Step 2: Retrieve active view - should return the system default + const activeView = await api.get( + "/admin/views/orders/configurations/active", + { headers: secondAdminHeader } + ) + + expect(activeView.status).toEqual(200) + expect(activeView.data.view_configuration).toBeTruthy() + expect(activeView.data.view_configuration.id).toEqual( + systemDefaultView.data.view_configuration.id + ) + expect(activeView.data.view_configuration.name).toEqual( + "System Default Orders" + ) + expect(activeView.data.view_configuration.is_system_default).toBe( + true + ) + expect(activeView.data.is_default_active).toBe(true) + expect(activeView.data.default_type).toEqual("system") + }) + }) + + describe("Filter, Sorting, and Search Persistence", () => { + it("should save and restore filters, sorting, and search configuration", async () => { + // Create a view with filters, sorting, and search + const viewConfig = await api.post( + "/admin/views/orders/configurations", + { + name: "Filtered View", + configuration: { + visible_columns: ["id", "status", "total", "created_at"], + column_order: ["status", "total", "created_at", "id"], + filters: { + status: ["pending", "completed"], + total: { gte: 100 }, + }, + sorting: { id: "created_at", desc: true }, + search: "test search", + }, + }, + { headers: secondAdminHeader } + ) + + expect(viewConfig.status).toEqual(200) + expect( + viewConfig.data.view_configuration.configuration.filters + ).toEqual({ + status: ["pending", "completed"], + total: { gte: 100 }, + }) + expect( + viewConfig.data.view_configuration.configuration.sorting + ).toEqual({ + id: "created_at", + desc: true, + }) + expect( + viewConfig.data.view_configuration.configuration.search + ).toEqual("test search") + + // Retrieve the view and verify filters are preserved + const getResponse = await api.get( + `/admin/views/orders/configurations/${viewConfig.data.view_configuration.id}`, + { headers: secondAdminHeader } + ) + + expect(getResponse.status).toEqual(200) + expect( + getResponse.data.view_configuration.configuration.filters + ).toEqual({ + status: ["pending", "completed"], + total: { gte: 100 }, + }) + }) + + it("should remove filters when updating a view without filters", async () => { + // Create a view with filters + const viewConfig = await api.post( + "/admin/views/orders/configurations", + { + name: "View with Filters", + configuration: { + visible_columns: ["id", "status", "total"], + column_order: ["status", "total", "id"], + filters: { + status: ["pending", "completed"], + total: { gte: 100 }, + }, + sorting: { id: "total", desc: true }, + search: "initial search", + }, + }, + { headers: secondAdminHeader } + ) + + expect(viewConfig.status).toEqual(200) + const viewId = viewConfig.data.view_configuration.id + + // Update the view to remove filters + const updateResponse = await api.post( + `/admin/views/orders/configurations/${viewId}`, + { + configuration: { + visible_columns: ["id", "status", "total"], + column_order: ["status", "total", "id"], + filters: {}, // Empty filters object + sorting: null, // Remove sorting + search: "", // Clear search + }, + }, + { headers: secondAdminHeader } + ) + + expect(updateResponse.status).toEqual(200) + + // Verify filters were removed + expect( + updateResponse.data.view_configuration.configuration.filters + ).toEqual({}) + expect( + updateResponse.data.view_configuration.configuration.sorting + ).toBeNull() + expect( + updateResponse.data.view_configuration.configuration.search + ).toEqual("") + + // Retrieve again to double-check persistence + const getResponse = await api.get( + `/admin/views/orders/configurations/${viewId}`, + { headers: secondAdminHeader } + ) + + expect(getResponse.status).toEqual(200) + expect( + getResponse.data.view_configuration.configuration.filters + ).toEqual({}) + expect( + getResponse.data.view_configuration.configuration.sorting + ).toBeNull() + expect( + getResponse.data.view_configuration.configuration.search + ).toEqual("") + }) + + it("should update only specific filters while keeping others", async () => { + // Create a view with multiple filters + const viewConfig = await api.post( + "/admin/views/orders/configurations", + { + name: "Multi-Filter View", + configuration: { + visible_columns: ["id", "status", "total", "created_at"], + column_order: ["status", "total", "created_at", "id"], + filters: { + status: ["pending", "completed"], + total: { gte: 100, lte: 1000 }, + created_at: { gte: "2024-01-01" }, + }, + sorting: { id: "created_at", desc: true }, + search: "customer", + }, + }, + { headers: secondAdminHeader } + ) + + expect(viewConfig.status).toEqual(200) + const viewId = viewConfig.data.view_configuration.id + + // Update to remove only the 'total' filter + const updateResponse = await api.post( + `/admin/views/orders/configurations/${viewId}`, + { + configuration: { + visible_columns: ["id", "status", "total", "created_at"], + column_order: ["status", "total", "created_at", "id"], + filters: { + status: ["pending", "completed"], + created_at: { gte: "2024-01-01" }, + // 'total' filter removed + }, + sorting: { id: "created_at", desc: true }, + search: "customer", + }, + }, + { headers: secondAdminHeader } + ) + + expect(updateResponse.status).toEqual(200) + expect( + updateResponse.data.view_configuration.configuration.filters + ).toEqual({ + status: ["pending", "completed"], + created_at: { gte: "2024-01-01" }, + }) + expect( + updateResponse.data.view_configuration.configuration.filters.total + ).toBeUndefined() + }) + }) + }) + }, +}) diff --git a/packages/core/core-flows/src/index.ts b/packages/core/core-flows/src/index.ts index c10412e9d7..c77a9ee9c8 100644 --- a/packages/core/core-flows/src/index.ts +++ b/packages/core/core-flows/src/index.ts @@ -25,6 +25,7 @@ export * from "./region" export * from "./reservation" export * from "./return-reason" export * from "./sales-channel" +export * from "./settings" export * from "./shipping-options" export * from "./shipping-profile" export * from "./stock-location" diff --git a/packages/core/core-flows/src/settings/index.ts b/packages/core/core-flows/src/settings/index.ts new file mode 100644 index 0000000000..c4c6682e60 --- /dev/null +++ b/packages/core/core-flows/src/settings/index.ts @@ -0,0 +1,2 @@ +export * from "./steps" +export * from "./workflows" \ No newline at end of file diff --git a/packages/core/core-flows/src/settings/steps/create-view-configuration.ts b/packages/core/core-flows/src/settings/steps/create-view-configuration.ts new file mode 100644 index 0000000000..4142bd41fe --- /dev/null +++ b/packages/core/core-flows/src/settings/steps/create-view-configuration.ts @@ -0,0 +1,27 @@ +import { + CreateViewConfigurationDTO, +} from "@medusajs/framework/types" +import { Modules } from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" + +export type CreateViewConfigurationStepInput = CreateViewConfigurationDTO + +export const createViewConfigurationStepId = "create-view-configuration" + +export const createViewConfigurationStep = createStep( + createViewConfigurationStepId, + async (data: CreateViewConfigurationStepInput, { container }) => { + const service = container.resolve(Modules.SETTINGS) + const created = await service.createViewConfigurations(data) + + return new StepResponse(created, { id: created.id }) + }, + async (compensateInput, { container }) => { + if (!compensateInput?.id) { + return + } + + const service = container.resolve(Modules.SETTINGS) + await service.deleteViewConfigurations([compensateInput.id]) + } +) diff --git a/packages/core/core-flows/src/settings/steps/index.ts b/packages/core/core-flows/src/settings/steps/index.ts new file mode 100644 index 0000000000..d2021f0eb0 --- /dev/null +++ b/packages/core/core-flows/src/settings/steps/index.ts @@ -0,0 +1,3 @@ +export * from "./create-view-configuration" +export * from "./update-view-configuration" +export * from "./set-active-view-configuration" \ No newline at end of file diff --git a/packages/core/core-flows/src/settings/steps/set-active-view-configuration.ts b/packages/core/core-flows/src/settings/steps/set-active-view-configuration.ts new file mode 100644 index 0000000000..64a439310a --- /dev/null +++ b/packages/core/core-flows/src/settings/steps/set-active-view-configuration.ts @@ -0,0 +1,59 @@ +import { ISettingsModuleService } from "@medusajs/framework/types" +import { Modules } from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" + +export type SetActiveViewConfigurationStepInput = { + id: string + entity: string + user_id: string +} + +export const setActiveViewConfigurationStepId = "set-active-view-configuration" + +export const setActiveViewConfigurationStep = createStep( + setActiveViewConfigurationStepId, + async (input: SetActiveViewConfigurationStepInput, { container }) => { + const service = container.resolve(Modules.SETTINGS) + + // Get the currently active view configuration for rollback + const currentActiveView = await service.getActiveViewConfiguration( + input.entity, + input.user_id + ) + + // Set the new view as active + await service.setActiveViewConfiguration( + input.entity, + input.user_id, + input.id + ) + + return new StepResponse(input.id, { + entity: input.entity, + user_id: input.user_id, + previousActiveViewId: currentActiveView?.id || null, + }) + }, + async (compensateInput, { container }) => { + if (!compensateInput) { + return + } + + const service = container.resolve(Modules.SETTINGS) + + if (compensateInput.previousActiveViewId) { + // Restore the previous active view + await service.setActiveViewConfiguration( + compensateInput.entity, + compensateInput.user_id, + compensateInput.previousActiveViewId + ) + } else { + // If there was no previous active view, clear the active view + await service.clearActiveViewConfiguration( + compensateInput.entity, + compensateInput.user_id + ) + } + } +) \ No newline at end of file diff --git a/packages/core/core-flows/src/settings/steps/update-view-configuration.ts b/packages/core/core-flows/src/settings/steps/update-view-configuration.ts new file mode 100644 index 0000000000..528a933f9b --- /dev/null +++ b/packages/core/core-flows/src/settings/steps/update-view-configuration.ts @@ -0,0 +1,41 @@ +import { + UpdateViewConfigurationDTO, + ISettingsModuleService, + ViewConfigurationDTO, +} from "@medusajs/framework/types" +import { Modules } from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" + +export type UpdateViewConfigurationStepInput = { + id: string + data: UpdateViewConfigurationDTO +} + +export const updateViewConfigurationStepId = "update-view-configuration" + +export const updateViewConfigurationStep = createStep( + updateViewConfigurationStepId, + async (input: UpdateViewConfigurationStepInput, { container }) => { + const service = container.resolve(Modules.SETTINGS) + + const currentState = await service.retrieveViewConfiguration(input.id) + + const updated = await service.updateViewConfigurations(input.id, input.data) + + return new StepResponse(updated, { + id: input.id, + previousState: currentState, + }) + }, + async (compensateInput, { container }) => { + if (!compensateInput?.id || !compensateInput?.previousState) { + return + } + + const service = container.resolve(Modules.SETTINGS) + + const { id, created_at, updated_at, ...restoreData } = + compensateInput.previousState as ViewConfigurationDTO + await service.updateViewConfigurations(compensateInput.id, restoreData) + } +) diff --git a/packages/core/core-flows/src/settings/workflows/create-view-configuration.ts b/packages/core/core-flows/src/settings/workflows/create-view-configuration.ts new file mode 100644 index 0000000000..954d2dc823 --- /dev/null +++ b/packages/core/core-flows/src/settings/workflows/create-view-configuration.ts @@ -0,0 +1,42 @@ +import { + CreateViewConfigurationDTO, + ViewConfigurationDTO, +} from "@medusajs/framework/types" +import { + WorkflowData, + WorkflowResponse, + createWorkflow, + when, +} from "@medusajs/framework/workflows-sdk" +import { + createViewConfigurationStep, + setActiveViewConfigurationStep, +} from "../steps" + +export type CreateViewConfigurationWorkflowInput = + CreateViewConfigurationDTO & { + set_active?: boolean + } + +export const createViewConfigurationWorkflowId = "create-view-configuration" + +export const createViewConfigurationWorkflow = createWorkflow( + createViewConfigurationWorkflowId, + ( + input: WorkflowData + ): WorkflowResponse => { + const viewConfig = createViewConfigurationStep(input) + + when({ input, viewConfig }, ({ input }) => { + return !!input.set_active && !!input.user_id + }).then(() => { + setActiveViewConfigurationStep({ + id: viewConfig.id, + entity: viewConfig.entity, + user_id: input.user_id as string, + }) + }) + + return new WorkflowResponse(viewConfig) + } +) diff --git a/packages/core/core-flows/src/settings/workflows/index.ts b/packages/core/core-flows/src/settings/workflows/index.ts new file mode 100644 index 0000000000..b675aa5bd1 --- /dev/null +++ b/packages/core/core-flows/src/settings/workflows/index.ts @@ -0,0 +1,2 @@ +export * from "./create-view-configuration" +export * from "./update-view-configuration" \ No newline at end of file diff --git a/packages/core/core-flows/src/settings/workflows/update-view-configuration.ts b/packages/core/core-flows/src/settings/workflows/update-view-configuration.ts new file mode 100644 index 0000000000..092003ebad --- /dev/null +++ b/packages/core/core-flows/src/settings/workflows/update-view-configuration.ts @@ -0,0 +1,51 @@ +import { + UpdateViewConfigurationDTO, + ViewConfigurationDTO, +} from "@medusajs/framework/types" +import { + WorkflowData, + WorkflowResponse, + createWorkflow, + when, + transform, +} from "@medusajs/framework/workflows-sdk" +import { + updateViewConfigurationStep, + setActiveViewConfigurationStep, +} from "../steps" + +export type UpdateViewConfigurationWorkflowInput = { + id: string + set_active?: boolean +} & UpdateViewConfigurationDTO + +export const updateViewConfigurationWorkflowId = "update-view-configuration" + +export const updateViewConfigurationWorkflow = createWorkflow( + updateViewConfigurationWorkflowId, + ( + input: WorkflowData + ): WorkflowResponse => { + const updateData = transform({ input }, ({ input }) => { + const { id, set_active, ...data } = input + return data + }) + + const viewConfig = updateViewConfigurationStep({ + id: input.id, + data: updateData, + }) + + when({ input, viewConfig }, ({ input, viewConfig }) => { + return !!input.set_active && !!viewConfig.user_id + }).then(() => { + setActiveViewConfigurationStep({ + id: viewConfig.id, + entity: viewConfig.entity, + user_id: viewConfig.user_id as string, + }) + }) + + return new WorkflowResponse(viewConfig) + } +) diff --git a/packages/core/framework/src/types/container.ts b/packages/core/framework/src/types/container.ts index 99ce147368..e63d9aad59 100644 --- a/packages/core/framework/src/types/container.ts +++ b/packages/core/framework/src/types/container.ts @@ -21,6 +21,7 @@ import { IPromotionModuleService, IRegionModuleService, ISalesChannelModuleService, + ISettingsModuleService, IStockLocationService, IStoreModuleService, ITaxModuleService, @@ -74,6 +75,7 @@ declare module "@medusajs/types" { [Modules.FILE]: IFileModuleService [Modules.NOTIFICATION]: INotificationModuleService [Modules.LOCKING]: ILockingModule + [Modules.SETTINGS]: ISettingsModuleService } } diff --git a/packages/core/types/src/http/index.ts b/packages/core/types/src/http/index.ts index 72de197bd7..71bb002ad7 100644 --- a/packages/core/types/src/http/index.ts +++ b/packages/core/types/src/http/index.ts @@ -43,4 +43,5 @@ export * from "./tax-provider" export * from "./tax-rate" export * from "./tax-region" export * from "./user" +export * from "./view-configuration" export * from "./workflow-execution" diff --git a/packages/core/types/src/http/view-configuration/admin/index.ts b/packages/core/types/src/http/view-configuration/admin/index.ts new file mode 100644 index 0000000000..e4a8d9ad66 --- /dev/null +++ b/packages/core/types/src/http/view-configuration/admin/index.ts @@ -0,0 +1,3 @@ +export * from "./responses" +export * from "./queries" +export * from "./payloads" \ No newline at end of file diff --git a/packages/core/types/src/http/view-configuration/admin/payloads.ts b/packages/core/types/src/http/view-configuration/admin/payloads.ts new file mode 100644 index 0000000000..53a2360539 --- /dev/null +++ b/packages/core/types/src/http/view-configuration/admin/payloads.ts @@ -0,0 +1,108 @@ +export interface AdminCreateViewConfiguration { + /** + * The entity this configuration is for (e.g., "order", "product"). + */ + entity: string + /** + * The name of the view configuration. + */ + name?: string + /** + * Whether this is a system default configuration. + */ + is_system_default?: boolean + /** + * Whether to set this view as the active view after creation. + */ + set_active?: boolean + /** + * The view configuration settings. + */ + configuration: { + /** + * The list of visible column IDs. + */ + visible_columns: string[] + /** + * The order of columns. + */ + column_order: string[] + /** + * Custom column widths. + */ + column_widths?: Record + /** + * Active filters for the view. + */ + filters?: Record + /** + * Sorting configuration. + */ + sorting?: { + id: string + desc: boolean + } | null + /** + * Search query for the view. + */ + search?: string + } +} + +export interface AdminUpdateViewConfiguration { + /** + * The name of the view configuration. + */ + name?: string + /** + * Whether this is a system default configuration. + */ + is_system_default?: boolean + /** + * Whether to set this view as the active view after update. + */ + set_active?: boolean + /** + * The view configuration settings. + */ + configuration?: { + /** + * The list of visible column IDs. + */ + visible_columns?: string[] + /** + * The order of columns. + */ + column_order?: string[] + /** + * Custom column widths. + */ + column_widths?: Record + /** + * Active filters for the view. + */ + filters?: Record + /** + * Sorting configuration. + */ + sorting?: { + id: string + desc: boolean + } | null + /** + * Search query for the view. + */ + search?: string + } +} + +export interface AdminSetActiveViewConfiguration { + /** + * The entity to set the active view for. + */ + entity: string + /** + * The ID of the view configuration to set as active, or null to clear the active view. + */ + view_configuration_id: string | null +} \ No newline at end of file diff --git a/packages/core/types/src/http/view-configuration/admin/queries.ts b/packages/core/types/src/http/view-configuration/admin/queries.ts new file mode 100644 index 0000000000..91f3128abe --- /dev/null +++ b/packages/core/types/src/http/view-configuration/admin/queries.ts @@ -0,0 +1,37 @@ +import { BaseFilterable, OperatorMap } from "../../../dal" +import { FindParams, SelectParams } from "../../common" + +export interface AdminGetViewConfigurationParams extends SelectParams {} + +export interface AdminGetViewConfigurationsParams + extends FindParams, + BaseFilterable { + /** + * IDs to filter view configurations by. + */ + id?: string | string[] + /** + * Entity to filter by. + */ + entity?: string | string[] + /** + * Name to filter by. + */ + name?: string | string[] + /** + * User ID to filter by. + */ + user_id?: string | string[] | null + /** + * Filter by system default status. + */ + is_system_default?: boolean + /** + * Date filters for when the view configuration was created. + */ + created_at?: OperatorMap + /** + * Date filters for when the view configuration was updated. + */ + updated_at?: OperatorMap +} \ No newline at end of file diff --git a/packages/core/types/src/http/view-configuration/admin/responses.ts b/packages/core/types/src/http/view-configuration/admin/responses.ts new file mode 100644 index 0000000000..1ad56d8451 --- /dev/null +++ b/packages/core/types/src/http/view-configuration/admin/responses.ts @@ -0,0 +1,80 @@ +import { DeleteResponse, PaginatedResponse } from "../../common" + +interface AdminViewConfiguration { + /** + * The view configuration's ID. + */ + id: string + /** + * The entity this configuration is for (e.g., "order", "product"). + */ + entity: string + /** + * The name of the view configuration. + */ + name: string | null + /** + * The ID of the user who owns this configuration, or null for system defaults. + */ + user_id: string | null + /** + * Whether this is a system default configuration. + */ + is_system_default: boolean + /** + * The view configuration settings. + */ + configuration: { + /** + * The list of visible column IDs. + */ + visible_columns: string[] + /** + * The order of columns. + */ + column_order: string[] + /** + * Custom column widths. + */ + column_widths?: Record + /** + * Active filters for the view. + */ + filters?: Record + /** + * Sorting configuration. + */ + sorting?: { + id: string + desc: boolean + } | null + /** + * Search query for the view. + */ + search?: string + } + /** + * The date the view configuration was created. + */ + created_at: Date + /** + * The date the view configuration was updated. + */ + updated_at: Date +} + +export interface AdminViewConfigurationResponse { + /** + * The view configuration's details. + */ + view_configuration: AdminViewConfiguration | null +} + +export type AdminViewConfigurationListResponse = PaginatedResponse<{ + /** + * The list of view configurations. + */ + view_configurations: AdminViewConfiguration[] +}> + +export type AdminViewConfigurationDeleteResponse = DeleteResponse<"view_configuration"> \ No newline at end of file diff --git a/packages/core/types/src/http/view-configuration/index.ts b/packages/core/types/src/http/view-configuration/index.ts new file mode 100644 index 0000000000..99595b00b3 --- /dev/null +++ b/packages/core/types/src/http/view-configuration/index.ts @@ -0,0 +1 @@ +export * from "./admin" \ No newline at end of file diff --git a/packages/core/types/src/settings/common.ts b/packages/core/types/src/settings/common.ts index c7fa2caefb..71c94c8569 100644 --- a/packages/core/types/src/settings/common.ts +++ b/packages/core/types/src/settings/common.ts @@ -111,9 +111,9 @@ export interface UserPreferenceDTO { } /** - * The filters to apply on the retrieved view configurations. + * Partial filters for view configuration fields. */ -export interface FilterableViewConfigurationProps extends BaseFilterable { +export interface ViewConfigurationFilterableFields { /** * The IDs to filter by. */ @@ -140,6 +140,21 @@ export interface FilterableViewConfigurationProps extends BaseFilterable, + res: MedusaResponse +) => { + const settingsService = req.scope.resolve(Modules.SETTINGS) + + const viewConfiguration = await settingsService.retrieveViewConfiguration( + req.params.id, + req.queryConfig + ) + + if ( + viewConfiguration.user_id && + viewConfiguration.user_id !== req.auth_context.actor_id && + !req.auth_context.app_metadata?.admin + ) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "You don't have access to this view configuration" + ) + } + + res.json({ view_configuration: viewConfiguration }) +} + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const settingsService = req.scope.resolve(Modules.SETTINGS) + + // Single retrieval for permission check + const existing = await settingsService.retrieveViewConfiguration( + req.params.id, + { select: ["id", "user_id", "is_system_default"] } + ) + + if (existing.user_id && existing.user_id !== req.auth_context.actor_id) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "You can only update your own view configurations" + ) + } + + const input = { + id: req.params.id, + ...req.validatedBody, + } + + const { result } = await updateViewConfigurationWorkflow(req.scope).run({ + input, + }) + + res.json({ view_configuration: result }) +} + +export const DELETE = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const settingsService = req.scope.resolve(Modules.SETTINGS) + + // Retrieve existing to check permissions + const existing = await settingsService.retrieveViewConfiguration( + req.params.id, + { select: ["id", "user_id", "is_system_default", "entity", "name"] } + ) + + if (existing.user_id && existing.user_id !== req.auth_context.actor_id) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "You can only delete your own view configurations" + ) + } + + await settingsService.deleteViewConfigurations(req.params.id) + + res.status(200).json({ + id: req.params.id, + object: "view_configuration", + deleted: true, + }) +} diff --git a/packages/medusa/src/api/admin/views/[entity]/configurations/active/route.ts b/packages/medusa/src/api/admin/views/[entity]/configurations/active/route.ts new file mode 100644 index 0000000000..5ece8fe4be --- /dev/null +++ b/packages/medusa/src/api/admin/views/[entity]/configurations/active/route.ts @@ -0,0 +1,79 @@ +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { + AdminSetActiveViewConfigurationType, + AdminGetActiveViewConfigurationParamsType, +} from "../validators" +import { HttpTypes } from "@medusajs/framework/types" +import { Modules } from "@medusajs/framework/utils" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse< + HttpTypes.AdminViewConfigurationResponse & { + is_default_active?: boolean + default_type?: "system" | "code" + } + > +) => { + const settingsService = req.scope.resolve(Modules.SETTINGS) + + const viewConfiguration = await settingsService.getActiveViewConfiguration( + req.params.entity, + req.auth_context.actor_id + ) + + if (!viewConfiguration) { + // No active view set or explicitly cleared - return null + res.json({ + view_configuration: null, + is_default_active: true, + default_type: "code", + }) + } else { + // Check if the user has an explicit preference + const activeViewPref = await settingsService.getUserPreference( + req.auth_context.actor_id, + `active_view.${req.params.entity}` + ) + + // If there's no preference and the view is a system default, it means we're falling back to system default + const isDefaultActive = + !activeViewPref && viewConfiguration.is_system_default + + res.json({ + view_configuration: viewConfiguration, + is_default_active: isDefaultActive, + default_type: + isDefaultActive && viewConfiguration.is_system_default + ? "system" + : undefined, + }) + } +} + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse<{ success: boolean }> +) => { + const settingsService = req.scope.resolve(Modules.SETTINGS) + + if (req.body.view_configuration_id === null) { + // Clear the active view configuration + await settingsService.clearActiveViewConfiguration( + req.params.entity, + req.auth_context.actor_id + ) + } else { + // Set a specific view as active + await settingsService.setActiveViewConfiguration( + req.params.entity, + req.auth_context.actor_id, + req.body.view_configuration_id + ) + } + + res.json({ success: true }) +} diff --git a/packages/medusa/src/api/admin/views/[entity]/configurations/middleware.ts b/packages/medusa/src/api/admin/views/[entity]/configurations/middleware.ts new file mode 100644 index 0000000000..8b805c146c --- /dev/null +++ b/packages/medusa/src/api/admin/views/[entity]/configurations/middleware.ts @@ -0,0 +1,27 @@ +import { + MedusaRequest, + MedusaResponse, + MedusaNextFunction +} from "@medusajs/framework/http" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" +import ViewConfigurationsFeatureFlag from "../../../../../loaders/feature-flags/view-configurations" + +export const ensureViewConfigurationsEnabled = async ( + req: MedusaRequest, + res: MedusaResponse, + next: MedusaNextFunction +) => { + const flagRouter = req.scope.resolve( + ContainerRegistrationKeys.FEATURE_FLAG_ROUTER + ) as any + + if (!flagRouter.isFeatureEnabled(ViewConfigurationsFeatureFlag.key)) { + res.status(404).json({ + type: "not_found", + message: "Route not found" + }) + return + } + + next() +} \ No newline at end of file diff --git a/packages/medusa/src/api/admin/views/[entity]/configurations/middlewares.ts b/packages/medusa/src/api/admin/views/[entity]/configurations/middlewares.ts new file mode 100644 index 0000000000..c4c27a04d4 --- /dev/null +++ b/packages/medusa/src/api/admin/views/[entity]/configurations/middlewares.ts @@ -0,0 +1,72 @@ +import { validateAndTransformBody, validateAndTransformQuery } from "@medusajs/framework" +import { MiddlewareRoute } from "@medusajs/framework/http" +import * as QueryConfig from "./query-config" +import { + AdminCreateViewConfiguration, + AdminUpdateViewConfiguration, + AdminSetActiveViewConfiguration, + AdminGetViewConfigurationParams, + AdminGetActiveViewConfigurationParams, + AdminGetViewConfigurationsParams, +} from "./validators" +import { ensureViewConfigurationsEnabled } from "./middleware" + +export const viewConfigurationRoutesMiddlewares: MiddlewareRoute[] = [ + // Apply feature flag check to all view configuration routes + { + method: ["GET", "POST", "DELETE"], + matcher: "/admin/views/*/configurations*", + middlewares: [ensureViewConfigurationsEnabled], + }, + { + method: ["GET"], + matcher: "/admin/views/:entity/configurations", + middlewares: [ + validateAndTransformQuery( + AdminGetViewConfigurationsParams, + QueryConfig.retrieveViewConfigurationList + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/views/:entity/configurations", + middlewares: [ + validateAndTransformBody(AdminCreateViewConfiguration), + ], + }, + { + method: ["GET"], + matcher: "/admin/views/:entity/configurations/:id", + middlewares: [ + validateAndTransformQuery( + AdminGetViewConfigurationParams, + QueryConfig.retrieveViewConfiguration + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/views/:entity/configurations/:id", + middlewares: [ + validateAndTransformBody(AdminUpdateViewConfiguration), + ], + }, + { + method: ["GET"], + matcher: "/admin/views/:entity/configurations/active", + middlewares: [ + validateAndTransformQuery( + AdminGetActiveViewConfigurationParams, + QueryConfig.retrieveViewConfiguration + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/views/:entity/configurations/active", + middlewares: [ + validateAndTransformBody(AdminSetActiveViewConfiguration), + ], + }, +] \ No newline at end of file diff --git a/packages/medusa/src/api/admin/views/[entity]/configurations/query-config.ts b/packages/medusa/src/api/admin/views/[entity]/configurations/query-config.ts new file mode 100644 index 0000000000..69a0628c77 --- /dev/null +++ b/packages/medusa/src/api/admin/views/[entity]/configurations/query-config.ts @@ -0,0 +1,20 @@ +export const defaultViewConfigurationFields = [ + "id", + "entity", + "name", + "user_id", + "is_system_default", + "configuration", + "created_at", + "updated_at", +] + +export const retrieveViewConfigurationList = { + defaults: defaultViewConfigurationFields, + isList: true, +} + +export const retrieveViewConfiguration = { + defaults: defaultViewConfigurationFields, + isList: false, +} \ No newline at end of file diff --git a/packages/medusa/src/api/admin/views/[entity]/configurations/route.ts b/packages/medusa/src/api/admin/views/[entity]/configurations/route.ts new file mode 100644 index 0000000000..a1e412f55f --- /dev/null +++ b/packages/medusa/src/api/admin/views/[entity]/configurations/route.ts @@ -0,0 +1,59 @@ +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { AdminCreateViewConfigurationType } from "./validators" +import { HttpTypes } from "@medusajs/framework/types" +import { MedusaError, Modules } from "@medusajs/framework/utils" +import { createViewConfigurationWorkflow } from "@medusajs/core-flows" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const settingsService = req.scope.resolve(Modules.SETTINGS) + + const filters = { + ...req.filterableFields, + entity: req.params.entity, + $or: [{ user_id: req.auth_context.actor_id }, { is_system_default: true }], + } + + const [viewConfigurations, count] = + await settingsService.listAndCountViewConfigurations( + filters, + req.queryConfig + ) + + res.json({ + view_configurations: viewConfigurations, + count, + offset: req.queryConfig.pagination?.skip || 0, + limit: req.queryConfig.pagination?.take || 20, + }) +} + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + // Validate: name is required unless creating a system default + if (!req.body.is_system_default && !req.body.name) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Name is required unless creating a system default view" + ) + } + + const input = { + ...req.body, + entity: req.params.entity, + user_id: req.body.is_system_default ? null : req.auth_context.actor_id, + } + + const { result } = await createViewConfigurationWorkflow(req.scope).run({ + input, + }) + + return res.json({ view_configuration: result }) +} diff --git a/packages/medusa/src/api/admin/views/[entity]/configurations/validators.ts b/packages/medusa/src/api/admin/views/[entity]/configurations/validators.ts new file mode 100644 index 0000000000..da66f90cd7 --- /dev/null +++ b/packages/medusa/src/api/admin/views/[entity]/configurations/validators.ts @@ -0,0 +1,71 @@ +import { z } from "zod" +import { + createFindParams, + createOperatorMap, + createSelectParams, +} from "../../../../utils/validators" +import { applyAndAndOrOperators } from "../../../../utils/common-validators" + +export const AdminGetViewConfigurationParams = createSelectParams() + +export type AdminGetActiveViewConfigurationParamsType = z.infer +export const AdminGetActiveViewConfigurationParams = createSelectParams() + +export const AdminGetViewConfigurationsParamsFields = z.object({ + id: z.union([z.string(), z.array(z.string())]).optional(), + entity: z.union([z.string(), z.array(z.string())]).optional(), + name: z.union([z.string(), z.array(z.string())]).optional(), + user_id: z.union([z.string(), z.array(z.string()), z.null()]).optional(), + is_system_default: z.boolean().optional(), + created_at: createOperatorMap().optional(), + updated_at: createOperatorMap().optional(), +}) + +export type AdminGetViewConfigurationsParamsType = z.infer +export const AdminGetViewConfigurationsParams = createFindParams({ + offset: 0, + limit: 20, +}) + .merge(AdminGetViewConfigurationsParamsFields) + .merge(applyAndAndOrOperators(AdminGetViewConfigurationsParamsFields)) + +export type AdminCreateViewConfigurationType = z.infer +export const AdminCreateViewConfiguration = z.object({ + name: z.string().optional(), + is_system_default: z.boolean().optional().default(false), + set_active: z.boolean().optional().default(false), + configuration: z.object({ + visible_columns: z.array(z.string()), + column_order: z.array(z.string()), + column_widths: z.record(z.string(), z.number()).optional(), + filters: z.record(z.string(), z.any()).optional(), + sorting: z.object({ + id: z.string(), + desc: z.boolean(), + }).nullable().optional(), + search: z.string().optional(), + }), +}) + +export type AdminUpdateViewConfigurationType = z.infer +export const AdminUpdateViewConfiguration = z.object({ + name: z.string().optional(), + is_system_default: z.boolean().optional(), + set_active: z.boolean().optional().default(false), + configuration: z.object({ + visible_columns: z.array(z.string()).optional(), + column_order: z.array(z.string()).optional(), + column_widths: z.record(z.string(), z.number()).optional(), + filters: z.record(z.string(), z.any()).optional(), + sorting: z.object({ + id: z.string(), + desc: z.boolean(), + }).nullable().optional(), + search: z.string().optional(), + }).optional(), +}) + +export type AdminSetActiveViewConfigurationType = z.infer +export const AdminSetActiveViewConfiguration = z.object({ + view_configuration_id: z.union([z.string(), z.null()]), +}) \ No newline at end of file diff --git a/packages/medusa/src/api/middlewares.ts b/packages/medusa/src/api/middlewares.ts index 3d6743c55c..0481b68c0e 100644 --- a/packages/medusa/src/api/middlewares.ts +++ b/packages/medusa/src/api/middlewares.ts @@ -41,6 +41,7 @@ import { adminTaxRegionRoutesMiddlewares } from "./admin/tax-regions/middlewares import { adminTaxProviderRoutesMiddlewares } from "./admin/tax-providers/middlewares" import { adminUploadRoutesMiddlewares } from "./admin/uploads/middlewares" import { adminUserRoutesMiddlewares } from "./admin/users/middlewares" +import { viewConfigurationRoutesMiddlewares } from "./admin/views/[entity]/configurations/middlewares" import { adminWorkflowsExecutionsMiddlewares } from "./admin/workflows-executions/middlewares" import { authRoutesMiddlewares } from "./auth/middlewares" @@ -126,4 +127,5 @@ export default defineMiddlewares([ ...adminTaxProviderRoutesMiddlewares, ...adminOrderEditRoutesMiddlewares, ...adminPaymentCollectionsMiddlewares, + ...viewConfigurationRoutesMiddlewares, ])