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