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
This commit is contained in:
Sebastian Rindom
2025-08-15 13:17:52 +02:00
committed by GitHub
parent f7fc05307f
commit 12a38bcd2b
28 changed files with 1858 additions and 10 deletions

View File

@@ -0,0 +1,92 @@
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import { AdminUpdateViewConfigurationType } from "../validators"
import { HttpTypes } from "@medusajs/framework/types"
import { MedusaError, Modules } from "@medusajs/framework/utils"
import { updateViewConfigurationWorkflow } from "@medusajs/core-flows"
export const GET = async (
req: AuthenticatedMedusaRequest<HttpTypes.AdminGetViewConfigurationParams>,
res: MedusaResponse<HttpTypes.AdminViewConfigurationResponse>
) => {
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<AdminUpdateViewConfigurationType>,
res: MedusaResponse<HttpTypes.AdminViewConfigurationResponse>
) => {
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<HttpTypes.AdminViewConfigurationDeleteResponse>
) => {
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,
})
}

View File

@@ -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<AdminGetActiveViewConfigurationParamsType>,
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<AdminSetActiveViewConfigurationType>,
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 })
}

View File

@@ -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()
}

View File

@@ -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),
],
},
]

View File

@@ -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,
}

View File

@@ -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<HttpTypes.AdminGetViewConfigurationsParams>,
res: MedusaResponse<HttpTypes.AdminViewConfigurationListResponse>
) => {
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<AdminCreateViewConfigurationType>,
res: MedusaResponse<HttpTypes.AdminViewConfigurationResponse>
) => {
// 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 })
}

View File

@@ -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<typeof AdminGetActiveViewConfigurationParams>
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<typeof AdminGetViewConfigurationsParams>
export const AdminGetViewConfigurationsParams = createFindParams({
offset: 0,
limit: 20,
})
.merge(AdminGetViewConfigurationsParamsFields)
.merge(applyAndAndOrOperators(AdminGetViewConfigurationsParamsFields))
export type AdminCreateViewConfigurationType = z.infer<typeof AdminCreateViewConfiguration>
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<typeof AdminUpdateViewConfiguration>
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<typeof AdminSetActiveViewConfiguration>
export const AdminSetActiveViewConfiguration = z.object({
view_configuration_id: z.union([z.string(), z.null()]),
})

View File

@@ -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,
])