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.
This commit is contained in:
Sebastian Rindom
2025-08-15 17:35:01 +02:00
committed by GitHub
parent 83d2ce762c
commit d2cb9523e0
12 changed files with 1141 additions and 13 deletions

View File

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

View File

@@ -1,2 +1,2 @@
export * from "./steps"
export * from "./workflows"
export * from "./workflows"

View File

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

View File

@@ -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<ISettingsModuleService>(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<ISettingsModuleService>(Modules.SETTINGS)
const service = container.resolve(Modules.SETTINGS)
const { id, created_at, updated_at, ...restoreData } =
compensateInput.previousState as ViewConfigurationDTO

View File

@@ -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[]
}

View File

@@ -1,3 +1,4 @@
export * from "./responses"
export * from "./queries"
export * from "./payloads"
export * from "./payloads"
export * from "./columns"

View File

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

View File

@@ -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<string, number> = {
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<string, string>()
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<string, string>()
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
}

View File

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

View File

@@ -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<HttpTypes.AdminViewsEntityColumnsResponse>
) => {
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)
}

View File

@@ -0,0 +1,5 @@
import { z } from "zod"
import { createSelectParams } from "../../../../utils/validators"
export type AdminGetColumnsParamsType = z.infer<typeof AdminGetColumnsParams>
export const AdminGetColumnsParams = createSelectParams()

View File

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