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:
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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()]),
|
||||
})
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user