From d2cb9523e03edb76bf5fe4928329e02da6b993c1 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Fri, 15 Aug 2025 17:35:01 +0200 Subject: [PATCH] feat: add column introspection api (#13183) This is part of a set of stacked PRs to add support for view configurations, which will allow users to customize the columns shown in admin tables. The functionality in this PR is behind the view_configuration feature flag. **What** - Adds an API to introspect the remote query schema and extract columns that users can add to their table views. **Notes** - This is a brute forced approach to get the functionality in place and I expect it to evolve to something more elegant over time. Some ideas for things we might want to consider: - Compute the entity columns during build time and store as static data the API can serve quickly. - Offer developers more control over how their data can be exposed in this API with additional decorators in the DML. --- .../__tests__/views/admin/columns.spec.ts | 255 ++++++++ .../core/core-flows/src/settings/index.ts | 2 +- .../steps/set-active-view-configuration.ts | 15 +- .../steps/update-view-configuration.ts | 5 +- .../http/view-configuration/admin/columns.ts | 85 +++ .../http/view-configuration/admin/index.ts | 3 +- .../views/[entity]/columns/entity-mappings.ts | 151 +++++ .../admin/views/[entity]/columns/helpers.ts | 571 ++++++++++++++++++ .../views/[entity]/columns/middlewares.ts | 18 + .../api/admin/views/[entity]/columns/route.ts | 42 ++ .../views/[entity]/columns/validators.ts | 5 + packages/medusa/src/api/middlewares.ts | 2 + 12 files changed, 1141 insertions(+), 13 deletions(-) create mode 100644 integration-tests/http/__tests__/views/admin/columns.spec.ts create mode 100644 packages/core/types/src/http/view-configuration/admin/columns.ts create mode 100644 packages/medusa/src/api/admin/views/[entity]/columns/entity-mappings.ts create mode 100644 packages/medusa/src/api/admin/views/[entity]/columns/helpers.ts create mode 100644 packages/medusa/src/api/admin/views/[entity]/columns/middlewares.ts create mode 100644 packages/medusa/src/api/admin/views/[entity]/columns/route.ts create mode 100644 packages/medusa/src/api/admin/views/[entity]/columns/validators.ts diff --git a/integration-tests/http/__tests__/views/admin/columns.spec.ts b/integration-tests/http/__tests__/views/admin/columns.spec.ts new file mode 100644 index 0000000000..5956d13bff --- /dev/null +++ b/integration-tests/http/__tests__/views/admin/columns.spec.ts @@ -0,0 +1,255 @@ +import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { ModuleRegistrationName } from "@medusajs/utils" +import { + adminHeaders, + createAdminUser, +} from "../../../../helpers/create-admin-user" +import { setupTaxStructure } from "../../../../modules/__tests__/fixtures" +import { createOrderSeeder } from "../../fixtures/order" + +jest.setTimeout(300000) + +const env = { MEDUSA_FF_VIEW_CONFIGURATIONS: true } + +medusaIntegrationTestRunner({ + env, + testSuite: ({ dbConnection, getContainer, api }) => { + beforeEach(async () => { + const container = getContainer() + await setupTaxStructure(container.resolve(ModuleRegistrationName.TAX)) + await createAdminUser(dbConnection, adminHeaders, container) + }) + + describe("GET /admin/views/:entity/columns", () => { + describe("orders entity", () => { + let order, seeder + + beforeEach(async () => { + // Create an order with all relationships + seeder = await createOrderSeeder({ + api, + container: getContainer(), + }) + order = seeder.order + }) + + it("should return all order columns including relationships", async () => { + const response = await api.get( + `/admin/views/orders/columns`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.columns).toBeDefined() + expect(Array.isArray(response.data.columns)).toBe(true) + + // Check for direct fields + const displayIdColumn = response.data.columns.find( + (c) => c.id === "display_id" + ) + expect(displayIdColumn).toBeDefined() + expect(displayIdColumn).toMatchObject({ + id: "display_id", + name: "Display Id", + field: "display_id", + data_type: "string", + semantic_type: "identifier", + context: "order", + }) + + // Check for missing relationships + const salesChannelColumns = response.data.columns.filter((c) => + c.id.startsWith("sales_channel") + ) + + const orderExtraColumns = response.data.columns.filter((c) => + c.id.startsWith("order_extra") + ) + + // Check for shipping address columns + const shippingAddressColumns = response.data.columns.filter((c) => + c.id.startsWith("shipping_address") + ) + + // Check that we DON'T have the sales_channel relationship object + const salesChannelField = response.data.columns.find( + (c) => c.id === "sales_channel" + ) + expect(salesChannelField).toBeUndefined() + + // Check that sales_channel.name is marked as default visible + const salesChannelNameField = response.data.columns.find( + (c) => c.id === "sales_channel.name" + ) + expect(salesChannelNameField).toBeDefined() + expect(salesChannelNameField).toMatchObject({ + id: "sales_channel.name", + field: "sales_channel.name", + default_visible: true, + }) + + // Check that other sales_channel fields are NOT default visible + const salesChannelIdField = response.data.columns.find( + (c) => c.id === "sales_channel.id" + ) + expect(salesChannelIdField).toBeDefined() + expect(salesChannelIdField.default_visible).toBe(false) + + const salesChannelDescriptionField = response.data.columns.find( + (c) => c.id === "sales_channel.description" + ) + if (salesChannelDescriptionField) { + expect(salesChannelDescriptionField.default_visible).toBe(false) + } + + // Check that we have the customer_display computed column + const customerDisplayField = response.data.columns.find( + (c) => c.id === "customer_display" + ) + expect(customerDisplayField).toBeDefined() + expect(customerDisplayField).toMatchObject({ + id: "customer_display", + name: "Customer", + field: "customer_display", + data_type: "string", + semantic_type: "computed", + context: "display", + default_visible: true, + sortable: false, + computed: { + type: "customer_name", + required_fields: [ + "customer.first_name", + "customer.last_name", + "customer.email", + ], + optional_fields: ["customer.phone"], + }, + default_order: 300, + category: "relationship", + }) + + // Check that we have the country computed column + const countryField = response.data.columns.find( + (c) => c.id === "country" + ) + expect(countryField).toBeDefined() + expect(countryField).toMatchObject({ + id: "country", + name: "Country", + field: "country", + data_type: "string", + semantic_type: "computed", + context: "display", + default_visible: true, + sortable: false, + computed: { + type: "country_code", + required_fields: ["shipping_address.country_code"], + optional_fields: [], + }, + default_order: 800, + category: "metadata", + }) + + // Check that we DON'T have customer or shipping_address objects + const customerField = response.data.columns.find( + (c) => c.id === "customer" + ) + expect(customerField).toBeUndefined() + + const shippingAddressField = response.data.columns.find( + (c) => c.id === "shipping_address" + ) + expect(shippingAddressField).toBeUndefined() + + // Group columns by type + const directFields = response.data.columns.filter( + (c) => !c.id.includes(".") + ) + const relationshipFields = response.data.columns.filter((c) => + c.id.includes(".") + ) + + // Check that important fields have proper ordering + const displayIdField = response.data.columns.find( + (c) => c.id === "display_id" + ) + expect(displayIdField?.default_order).toBe(100) + expect(displayIdField?.category).toBe("identifier") + + const totalField = response.data.columns.find((c) => c.id === "total") + expect(totalField?.default_order).toBe(700) + expect(totalField?.category).toBe("metric") + + const createdAtField = response.data.columns.find( + (c) => c.id === "created_at" + ) + expect(createdAtField?.default_order).toBe(200) + expect(createdAtField?.category).toBe("timestamp") + }) + + it("should check filtering behavior", async () => { + const response = await api.get( + `/admin/views/orders/columns`, + adminHeaders + ) + + // Check which fields might be getting filtered + const allFieldIds = response.data.columns.map((c) => c.id) + + // Check for fields that might be filtered by suffixes + const linkFields = allFieldIds.filter((id) => id.includes("_link")) + expect(linkFields).toHaveLength(0) + }) + }) + + describe("products entity", () => { + it("should return product columns", async () => { + const response = await api.get( + `/admin/views/products/columns`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.columns).toBeDefined() + expect(Array.isArray(response.data.columns)).toBe(true) + + // Check for product-specific fields + const titleColumn = response.data.columns.find( + (c) => c.id === "title" + ) + expect(titleColumn).toBeDefined() + expect(titleColumn).toMatchObject({ + id: "title", + name: "Title", + field: "title", + default_visible: true, + }) + + const handleColumn = response.data.columns.find( + (c) => c.id === "handle" + ) + expect(handleColumn).toBeDefined() + expect(handleColumn).toMatchObject({ + id: "handle", + name: "Handle", + field: "handle", + default_visible: true, + }) + }) + }) + + describe("unsupported entity", () => { + it("should return 400 for unsupported entity", async () => { + const response = await api + .get(`/admin/views/unsupported-entity/columns`, adminHeaders) + .catch((e) => e.response) + + expect(response.status).toEqual(400) + expect(response.data.message).toContain("Unsupported entity") + }) + }) + }) + }, +}) diff --git a/packages/core/core-flows/src/settings/index.ts b/packages/core/core-flows/src/settings/index.ts index c4c6682e60..68de82c9f9 100644 --- a/packages/core/core-flows/src/settings/index.ts +++ b/packages/core/core-flows/src/settings/index.ts @@ -1,2 +1,2 @@ export * from "./steps" -export * from "./workflows" \ No newline at end of file +export * from "./workflows" 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 index 64a439310a..fc5de392bd 100644 --- 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 @@ -1,4 +1,3 @@ -import { ISettingsModuleService } from "@medusajs/framework/types" import { Modules } from "@medusajs/framework/utils" import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" @@ -13,21 +12,21 @@ export const setActiveViewConfigurationStepId = "set-active-view-configuration" export const setActiveViewConfigurationStep = createStep( setActiveViewConfigurationStepId, async (input: SetActiveViewConfigurationStepInput, { container }) => { - const service = container.resolve(Modules.SETTINGS) - + 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, @@ -39,8 +38,8 @@ export const setActiveViewConfigurationStep = createStep( return } - const service = container.resolve(Modules.SETTINGS) - + const service = container.resolve(Modules.SETTINGS) + if (compensateInput.previousActiveViewId) { // Restore the previous active view await service.setActiveViewConfiguration( @@ -56,4 +55,4 @@ export const setActiveViewConfigurationStep = createStep( ) } } -) \ 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 index 528a933f9b..d4ed09a4ae 100644 --- a/packages/core/core-flows/src/settings/steps/update-view-configuration.ts +++ b/packages/core/core-flows/src/settings/steps/update-view-configuration.ts @@ -1,6 +1,5 @@ import { UpdateViewConfigurationDTO, - ISettingsModuleService, ViewConfigurationDTO, } from "@medusajs/framework/types" import { Modules } from "@medusajs/framework/utils" @@ -16,7 +15,7 @@ export const updateViewConfigurationStepId = "update-view-configuration" export const updateViewConfigurationStep = createStep( updateViewConfigurationStepId, async (input: UpdateViewConfigurationStepInput, { container }) => { - const service = container.resolve(Modules.SETTINGS) + const service = container.resolve(Modules.SETTINGS) const currentState = await service.retrieveViewConfiguration(input.id) @@ -32,7 +31,7 @@ export const updateViewConfigurationStep = createStep( return } - const service = container.resolve(Modules.SETTINGS) + const service = container.resolve(Modules.SETTINGS) const { id, created_at, updated_at, ...restoreData } = compensateInput.previousState as ViewConfigurationDTO diff --git a/packages/core/types/src/http/view-configuration/admin/columns.ts b/packages/core/types/src/http/view-configuration/admin/columns.ts new file mode 100644 index 0000000000..d1f3a3882f --- /dev/null +++ b/packages/core/types/src/http/view-configuration/admin/columns.ts @@ -0,0 +1,85 @@ +export interface AdminColumn { + /** + * The column's unique identifier (e.g., "display_id", "customer.email"). + */ + id: string + /** + * The display name for the column. + */ + name: string + /** + * Description of the column. + */ + description?: string + /** + * The field path to access the data. + */ + field: string + /** + * Whether the column can be sorted. + */ + sortable: boolean + /** + * Whether the column can be hidden. + */ + hideable: boolean + /** + * Whether the column is visible by default. + */ + default_visible: boolean + /** + * The data type of the column. + */ + data_type: + | "string" + | "number" + | "boolean" + | "date" + | "currency" + | "enum" + | "object" + /** + * The semantic type provides additional context about the data. + */ + semantic_type?: string + /** + * Additional context about the column. + */ + context?: string + /** + * Information about computed columns. + */ + computed?: { + type: string + required_fields: string[] + optional_fields: string[] + } + /** + * Information about relationship columns. + */ + relationship?: { + entity: string + field: string + } + /** + * Default order for sorting columns. + */ + default_order?: number + /** + * Category for grouping columns. + */ + category?: + | "identifier" + | "timestamp" + | "status" + | "metric" + | "relationship" + | "metadata" +} + +export interface AdminViewsEntityColumnsResponse { + /** + * The list of available columns for the entity. + */ + columns: AdminColumn[] +} diff --git a/packages/core/types/src/http/view-configuration/admin/index.ts b/packages/core/types/src/http/view-configuration/admin/index.ts index e4a8d9ad66..4c5b34cefb 100644 --- a/packages/core/types/src/http/view-configuration/admin/index.ts +++ b/packages/core/types/src/http/view-configuration/admin/index.ts @@ -1,3 +1,4 @@ export * from "./responses" export * from "./queries" -export * from "./payloads" \ No newline at end of file +export * from "./payloads" +export * from "./columns" diff --git a/packages/medusa/src/api/admin/views/[entity]/columns/entity-mappings.ts b/packages/medusa/src/api/admin/views/[entity]/columns/entity-mappings.ts new file mode 100644 index 0000000000..4af2b82770 --- /dev/null +++ b/packages/medusa/src/api/admin/views/[entity]/columns/entity-mappings.ts @@ -0,0 +1,151 @@ +export const ENTITY_MAPPINGS = { + orders: { + serviceName: "order", + graphqlType: "Order", + defaultVisibleFields: [ + "display_id", + "created_at", + "payment_status", + "fulfillment_status", + "total", + "customer_display", + "country", + "sales_channel.name", + ], + fieldFilters: { + // Fields that end with these suffixes will be excluded + excludeSuffixes: ["_link"], + // Fields that start with these prefixes will be excluded + excludePrefixes: ["raw_"], + // Specific field names to exclude + excludeFields: ["order_change"], + }, + computedColumns: { + customer_display: { + name: "Customer", + computation_type: "customer_name", + required_fields: [ + "customer.first_name", + "customer.last_name", + "customer.email", + ], + optional_fields: ["customer.phone"], + default_visible: true, + }, + shipping_address_display: { + name: "Shipping Address", + computation_type: "address_summary", + required_fields: [ + "shipping_address.city", + "shipping_address.country_code", + ], + optional_fields: [ + "shipping_address.address_1", + "shipping_address.province", + "shipping_address.postal_code", + ], + default_visible: false, + }, + billing_address_display: { + name: "Billing Address", + computation_type: "address_summary", + required_fields: [ + "billing_address.city", + "billing_address.country_code", + ], + optional_fields: [ + "billing_address.address_1", + "billing_address.province", + "billing_address.postal_code", + ], + default_visible: false, + }, + country: { + name: "Country", + computation_type: "country_code", + required_fields: ["shipping_address.country_code"], + optional_fields: [], + default_visible: true, + }, + }, + }, + products: { + serviceName: "product", + graphqlType: "Product", + defaultVisibleFields: [ + "title", + "handle", + "status", + "created_at", + "updated_at", + ], + fieldFilters: { + excludeSuffixes: ["_link"], + excludePrefixes: ["raw_"], + excludeFields: [], + }, + computedColumns: {}, + }, + customers: { + serviceName: "customer", + graphqlType: "Customer", + defaultVisibleFields: [ + "email", + "first_name", + "last_name", + "created_at", + "updated_at", + ], + fieldFilters: { + excludeSuffixes: ["_link"], + excludePrefixes: ["raw_"], + excludeFields: [], + }, + computedColumns: {}, + }, + users: { + serviceName: "user", + graphqlType: "User", + defaultVisibleFields: [ + "email", + "first_name", + "last_name", + "created_at", + "updated_at", + ], + fieldFilters: { + excludeSuffixes: ["_link"], + excludePrefixes: ["raw_"], + excludeFields: [], + }, + computedColumns: {}, + }, + regions: { + serviceName: "region", + graphqlType: "Region", + defaultVisibleFields: ["name", "currency_code", "created_at", "updated_at"], + fieldFilters: { + excludeSuffixes: ["_link"], + excludePrefixes: ["raw_"], + excludeFields: [], + }, + computedColumns: {}, + }, + "sales-channels": { + serviceName: "salesChannel", + graphqlType: "SalesChannel", + defaultVisibleFields: [ + "name", + "description", + "is_disabled", + "created_at", + "updated_at", + ], + fieldFilters: { + excludeSuffixes: ["_link"], + excludePrefixes: ["raw_"], + excludeFields: [], + }, + computedColumns: {}, + }, +} diff --git a/packages/medusa/src/api/admin/views/[entity]/columns/helpers.ts b/packages/medusa/src/api/admin/views/[entity]/columns/helpers.ts new file mode 100644 index 0000000000..941f08e622 --- /dev/null +++ b/packages/medusa/src/api/admin/views/[entity]/columns/helpers.ts @@ -0,0 +1,571 @@ +import { + GraphQLObjectType, + isEnumType, + isListType, + isNonNullType, + isScalarType, + makeExecutableSchema, + mergeTypeDefs, + graphqlSchemaToFields, + extractRelationsFromGQL, + cleanGraphQLSchema, + print, +} from "@medusajs/framework/utils" +import { HttpTypes } from "@medusajs/types" +import { MedusaModule } from "@medusajs/framework/modules-sdk" +import { ENTITY_MAPPINGS } from "./entity-mappings" + +// Determine column category based on field characteristics +export const getColumnCategory = ( + fieldName: string, + dataType: string, + semanticType?: string +): HttpTypes.AdminColumn["category"] => { + // Check semantic type first + if (semanticType === "timestamp") return "timestamp" + if (semanticType === "status") return "status" + + // Check field name patterns + if ( + fieldName.includes("_id") || + fieldName === "id" || + fieldName.includes("display_id") || + fieldName === "code" + ) { + return "identifier" + } + + if (fieldName.includes("status") || fieldName === "state") { + return "status" + } + + if (fieldName.includes("_at") || fieldName.includes("date")) { + return "timestamp" + } + + if ( + fieldName.includes("total") || + fieldName.includes("amount") || + fieldName.includes("price") || + semanticType === "currency" + ) { + return "metric" + } + + if (dataType === "object" || fieldName.includes("_display")) { + return "relationship" + } + + return "metadata" +} + +// Helper function to format field name for display +export const formatFieldName = (field: string): string => { + return field + .split(/[._]/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") +} + +// Helper function to get the underlying type from wrapped types (NonNull, List) +export const getUnderlyingType = (type: any): any => { + if (type.ofType) { + return getUnderlyingType(type.ofType) + } + return type +} + +// Helper function to check if a field type is an array/list +export const isArrayField = (type: any): boolean => { + if (isListType(type)) { + return true + } + if (isNonNullType(type)) { + return isArrayField(type.ofType) + } + return false +} + +// Helper function to check if a field is a single relationship (many-to-one, one-to-one) +export const isSingleRelationship = (type: any): boolean => { + // If it's a list, it's a one-to-many or many-to-many relationship + if (isArrayField(type)) { + return false + } + + // Get the underlying type (removing NonNull wrappers) + const underlyingType = getUnderlyingType(type) + + // Check if it's a GraphQL object type (relationship) + return underlyingType instanceof GraphQLObjectType +} + +// Helper function to check if a field should be excluded based on filtering rules +export const shouldExcludeField = ( + fieldName: string, + fieldFilters: any +): boolean => { + // Check if field matches any exclude suffixes + if ( + fieldFilters.excludeSuffixes?.some((suffix: string) => + fieldName.endsWith(suffix) + ) + ) { + return true + } + + // Check if field matches any exclude prefixes + if ( + fieldFilters.excludePrefixes?.some((prefix: string) => + fieldName.startsWith(prefix) + ) + ) { + return true + } + + // Check if field is in the exclude fields list + if (fieldFilters.excludeFields?.includes(fieldName)) { + return true + } + + return false +} + +// Helper function to determine data type and semantic type from GraphQL type +export const getTypeInfoFromGraphQLType = ( + type: any, + fieldName: string +): { + data_type: HttpTypes.AdminColumn["data_type"] + semantic_type: string + context?: string +} => { + const underlyingType = type ? getUnderlyingType(type) : null + + // Check field name patterns first for more specific types + if (fieldName.includes("_at") || fieldName.includes("date")) { + return { + data_type: "date", + semantic_type: "timestamp", + context: fieldName.includes("created") + ? "creation" + : fieldName.includes("updated") + ? "update" + : "generic", + } + } else if ( + fieldName.includes("total") || + fieldName.includes("amount") || + fieldName.includes("price") + ) { + return { + data_type: "currency", + semantic_type: "currency", + context: fieldName.includes("total") ? "total" : "amount", + } + } else if (fieldName.includes("count") || fieldName.includes("quantity")) { + return { + data_type: "number", + semantic_type: "count", + context: fieldName.includes("quantity") ? "quantity" : "count", + } + } else if (fieldName.includes("status")) { + return { + data_type: "enum", + semantic_type: "status", + context: fieldName.includes("payment") + ? "payment" + : fieldName.includes("fulfillment") + ? "fulfillment" + : "generic", + } + } else if (fieldName.includes("type") || fieldName.includes("is_")) { + return { + data_type: "enum", + semantic_type: "enum", + context: "generic", + } + } else if (fieldName === "metadata" || fieldName.includes("json")) { + return { + data_type: "object", + semantic_type: "object", + context: "metadata", + } + } else if (fieldName === "display_id") { + return { + data_type: "string", + semantic_type: "identifier", + context: "order", + } + } else if (fieldName === "email") { + return { + data_type: "string", + semantic_type: "email", + context: "contact", + } + } + + // Then check GraphQL type + if (underlyingType && isScalarType(underlyingType)) { + switch (underlyingType.name) { + case "Int": + case "Float": + return { + data_type: "number", + semantic_type: "number", + context: "generic", + } + case "Boolean": + return { + data_type: "boolean", + semantic_type: "boolean", + context: "generic", + } + case "DateTime": + return { + data_type: "date", + semantic_type: "timestamp", + context: "generic", + } + case "JSON": + return { + data_type: "object", + semantic_type: "object", + context: "json", + } + default: + return { + data_type: "string", + semantic_type: "string", + context: "generic", + } + } + } else if (underlyingType && isEnumType(underlyingType)) { + return { + data_type: "enum", + semantic_type: "enum", + context: "generic", + } + } else { + return { + data_type: "object", + semantic_type: "object", + context: "relationship", + } + } +} + +export const DEFAULT_COLUMN_ORDERS: Record = { + display_id: 100, + created_at: 200, + customer_display: 300, + "sales_channel.name": 400, + fulfillment_status: 500, + payment_status: 600, + total: 700, + country: 800, +} + +/** + * Generates columns for a given entity by introspecting the GraphQL schema + * @param entity - The entity name to generate columns for + * @param entityMapping - The entity mapping configuration + * @returns Array of columns or null if generation fails + */ +export const generateEntityColumns = ( + entity: string, + entityMapping: typeof ENTITY_MAPPINGS[keyof typeof ENTITY_MAPPINGS] +): HttpTypes.AdminColumn[] | null => { + const joinerConfigs = MedusaModule.getAllJoinerConfigs() + + const schemaFragments: string[] = [] + let hasEntityType = false + + for (const config of joinerConfigs) { + if (config.schema) { + schemaFragments.push(config.schema) + + if (config.schema.includes(`type ${entityMapping.graphqlType} {`)) { + hasEntityType = true + } + } + } + + if (!hasEntityType || schemaFragments.length === 0) { + return null + } + + const scalarDefinitions = ` + scalar DateTime + scalar JSON + ` + + const allSchemas = [scalarDefinitions, ...schemaFragments] + const mergedSchemaAST = mergeTypeDefs(allSchemas) + const mergedSchemaString = print(mergedSchemaAST) + + const { schema: cleanedSchemaString } = + cleanGraphQLSchema(mergedSchemaString) + + const schema = makeExecutableSchema({ + typeDefs: cleanedSchemaString, + resolvers: {}, // Empty resolvers since we only need the schema for introspection + }) + + const schemaTypeMap = schema.getTypeMap() + + const entityType = schemaTypeMap[ + entityMapping.graphqlType + ] as GraphQLObjectType + + const allDirectFields = graphqlSchemaToFields( + schemaTypeMap, + entityMapping.graphqlType, + [] + ) + + // Filter out problematic fields + const directFields = allDirectFields.filter((fieldName) => { + const field = entityType?.getFields()[fieldName] + if (!field) return true + + const isArray = isArrayField(field.type) + if (isArray) { + return false + } + + if (shouldExcludeField(fieldName, entityMapping.fieldFilters)) { + return false + } + + return true + }) + + if (entity === "orders" && !directFields.includes("display_id")) { + directFields.unshift("display_id") + } + + const relationMap = extractRelationsFromGQL( + new Map(Object.entries(schemaTypeMap)) + ) + const allEntityRelations = relationMap.get(entityMapping.graphqlType) + + const filteredUtilityRelations = new Map() + if (allEntityRelations && entityType) { + const fields = entityType.getFields() + for (const [fieldName, relatedTypeName] of allEntityRelations) { + const field = fields[fieldName] + + if (shouldExcludeField(fieldName, entityMapping.fieldFilters)) { + continue + } + + if (field && isSingleRelationship(field.type)) { + filteredUtilityRelations.set(fieldName, relatedTypeName) + } + } + } + + const manualRelations = new Map() + if (entityType) { + const fields = entityType.getFields() + Object.entries(fields).forEach(([fieldName, field]) => { + if (shouldExcludeField(fieldName, entityMapping.fieldFilters)) { + return + } + + if (isSingleRelationship(field.type)) { + const fieldType = getUnderlyingType(field.type) + manualRelations.set(fieldName, fieldType.name) + } + }) + } + + const finalRelations = + filteredUtilityRelations.size > 0 + ? filteredUtilityRelations + : manualRelations + + if (directFields.length === 0) { + return null + } + + const directColumns = directFields.map((fieldName) => { + const displayName = formatFieldName(fieldName) + + const type = schemaTypeMap[ + entityMapping.graphqlType + ] as GraphQLObjectType + const fieldDef = type?.getFields()?.[fieldName] + const typeInfo = fieldDef + ? getTypeInfoFromGraphQLType(fieldDef.type, fieldName) + : getTypeInfoFromGraphQLType(null, fieldName) + + const sortable = + !fieldName.includes("metadata") && typeInfo.data_type !== "object" + + const isDefaultField = + entityMapping.defaultVisibleFields.includes(fieldName) + const defaultOrder = + DEFAULT_COLUMN_ORDERS[fieldName] || (isDefaultField ? 500 : 850) + const category = getColumnCategory( + fieldName, + typeInfo.data_type, + typeInfo.semantic_type + ) + + return { + id: fieldName, + name: displayName, + description: `${displayName} field`, + field: fieldName, + sortable, + hideable: true, + default_visible: + entityMapping.defaultVisibleFields.includes(fieldName), + data_type: typeInfo.data_type, + semantic_type: typeInfo.semantic_type, + context: typeInfo.context, + default_order: defaultOrder, + category, + } + }) + + const relationshipColumns: HttpTypes.AdminColumn[] = [] + + if (finalRelations.size > 0) { + for (const [relationName, relatedTypeName] of finalRelations) { + const allRelatedFields = graphqlSchemaToFields( + schemaTypeMap, + relatedTypeName, + [] + ) + + // Filter out problematic fields from related type + const relatedType = schemaTypeMap[ + relatedTypeName + ] as GraphQLObjectType + const relatedFields = allRelatedFields.filter((fieldName) => { + const field = relatedType?.getFields()[fieldName] + if (!field) return true + + const isArray = isArrayField(field.type) + if (isArray) { + return false + } + + // Apply entity-specific field filters to related fields as well + if (shouldExcludeField(fieldName, entityMapping.fieldFilters)) { + return false + } + + return true + }) + + const limitedFields = relatedFields.slice(0, 10) + + limitedFields.forEach((fieldName) => { + const fieldPath = `${relationName}.${fieldName}` + const displayName = `${formatFieldName( + relationName + )} ${formatFieldName(fieldName)}` + + const relatedType = schemaTypeMap[ + relatedTypeName + ] as GraphQLObjectType + const fieldDef = relatedType?.getFields()?.[fieldName] + const typeInfo = fieldDef + ? getTypeInfoFromGraphQLType(fieldDef.type, fieldName) + : { + data_type: "string" as const, + semantic_type: "string", + context: "generic", + } + + const sortable = fieldPath.includes(".") + ? false + : ["name", "title", "email", "handle"].includes(fieldName) + + const isDefaultVisible = + entityMapping.defaultVisibleFields.includes(fieldPath) + + // Get default order and category + // If field is not in default visible fields, place it after country (850) + const isDefaultField = + entityMapping.defaultVisibleFields.includes(fieldPath) + const defaultOrder = + DEFAULT_COLUMN_ORDERS[fieldPath] || (isDefaultField ? 700 : 850) + const category = getColumnCategory( + fieldPath, + typeInfo.data_type, + typeInfo.semantic_type + ) + + relationshipColumns.push({ + id: fieldPath, + name: displayName, + description: `${displayName} from related ${relatedTypeName}`, + field: fieldPath, + sortable, + hideable: true, + default_visible: isDefaultVisible, + data_type: typeInfo.data_type, + semantic_type: typeInfo.semantic_type, + context: typeInfo.context, + relationship: { + entity: relatedTypeName, + field: fieldName, + }, + default_order: defaultOrder, + category, + }) + }) + } + } + + // Generate computed columns + const computedColumns: HttpTypes.AdminColumn[] = [] + + if (entityMapping.computedColumns) { + for (const [columnId, columnConfig] of Object.entries( + entityMapping.computedColumns + )) { + // Get default order and category for computed columns + // If field is not in default visible fields, place it after country (850) + const isDefaultField = + entityMapping.defaultVisibleFields.includes(columnId) + const defaultOrder = + DEFAULT_COLUMN_ORDERS[columnId] || (isDefaultField ? 600 : 850) + const category = getColumnCategory(columnId, "string", "computed") + + computedColumns.push({ + id: columnId, + name: columnConfig.name, + description: `${columnConfig.name} (computed)`, + field: columnId, + sortable: false, // Computed columns can't be sorted server-side + hideable: true, + default_visible: + entityMapping.defaultVisibleFields.includes(columnId), + data_type: "string", // Computed columns typically output strings + semantic_type: "computed", + context: "display", + computed: { + type: columnConfig.computation_type, + required_fields: columnConfig.required_fields, + optional_fields: columnConfig.optional_fields || [], + }, + default_order: defaultOrder, + category, + }) + } + } + + const allColumns = [ + ...directColumns, + ...relationshipColumns, + ...computedColumns, + ] + + return allColumns +} diff --git a/packages/medusa/src/api/admin/views/[entity]/columns/middlewares.ts b/packages/medusa/src/api/admin/views/[entity]/columns/middlewares.ts new file mode 100644 index 0000000000..7cc104ed20 --- /dev/null +++ b/packages/medusa/src/api/admin/views/[entity]/columns/middlewares.ts @@ -0,0 +1,18 @@ +import { validateAndTransformQuery } from "@medusajs/framework" +import { MiddlewareRoute } from "@medusajs/framework/http" +import { AdminGetColumnsParams } from "./validators" +import { ensureViewConfigurationsEnabled } from "../configurations/middleware" + +export const columnRoutesMiddlewares: MiddlewareRoute[] = [ + // Apply feature flag check to all column routes + { + method: ["GET", "POST"], + matcher: "/admin/views/*/columns", + middlewares: [ensureViewConfigurationsEnabled], + }, + { + method: ["GET"], + matcher: "/admin/views/:entity/columns", + middlewares: [validateAndTransformQuery(AdminGetColumnsParams, {})], + }, +] diff --git a/packages/medusa/src/api/admin/views/[entity]/columns/route.ts b/packages/medusa/src/api/admin/views/[entity]/columns/route.ts new file mode 100644 index 0000000000..35de004b46 --- /dev/null +++ b/packages/medusa/src/api/admin/views/[entity]/columns/route.ts @@ -0,0 +1,42 @@ +import { HttpTypes } from "@medusajs/framework/types" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { + MedusaError, +} from "@medusajs/framework/utils" +import { generateEntityColumns } from "./helpers" +import { ENTITY_MAPPINGS } from "./entity-mappings" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const entity = req.params.entity + + const entityMapping = ENTITY_MAPPINGS[entity as keyof typeof ENTITY_MAPPINGS] + if (!entityMapping) { + return res.status(400).json({ + message: `Unsupported entity: ${entity}`, + type: "invalid_data", + } as any) + } + + try { + const columns = generateEntityColumns(entity, entityMapping) + + if (columns) { + return res.json({ + columns, + }) + } + } catch (schemaError) { + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + `Schema introspection failed for entity: ${entity}. Please check if the entity exists in the schema.` + ) + } + + return res.sendStatus(500) +} diff --git a/packages/medusa/src/api/admin/views/[entity]/columns/validators.ts b/packages/medusa/src/api/admin/views/[entity]/columns/validators.ts new file mode 100644 index 0000000000..ffee7f66f6 --- /dev/null +++ b/packages/medusa/src/api/admin/views/[entity]/columns/validators.ts @@ -0,0 +1,5 @@ +import { z } from "zod" +import { createSelectParams } from "../../../../utils/validators" + +export type AdminGetColumnsParamsType = z.infer +export const AdminGetColumnsParams = createSelectParams() diff --git a/packages/medusa/src/api/middlewares.ts b/packages/medusa/src/api/middlewares.ts index 0481b68c0e..5d8c59b0c4 100644 --- a/packages/medusa/src/api/middlewares.ts +++ b/packages/medusa/src/api/middlewares.ts @@ -42,6 +42,7 @@ import { adminTaxProviderRoutesMiddlewares } from "./admin/tax-providers/middlew import { adminUploadRoutesMiddlewares } from "./admin/uploads/middlewares" import { adminUserRoutesMiddlewares } from "./admin/users/middlewares" import { viewConfigurationRoutesMiddlewares } from "./admin/views/[entity]/configurations/middlewares" +import { columnRoutesMiddlewares } from "./admin/views/[entity]/columns/middlewares" import { adminWorkflowsExecutionsMiddlewares } from "./admin/workflows-executions/middlewares" import { authRoutesMiddlewares } from "./auth/middlewares" @@ -128,4 +129,5 @@ export default defineMiddlewares([ ...adminOrderEditRoutesMiddlewares, ...adminPaymentCollectionsMiddlewares, ...viewConfigurationRoutesMiddlewares, + ...columnRoutesMiddlewares, ])