-
Notifications
You must be signed in to change notification settings - Fork 714
Description
Contact Details
What happened?
I’m working with two entities - Product
and ProductCategory
- which have a standard many-to-many relationship.
Products:
@Entity({ name: 'products' })
export class ProductEntity extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id: UUID;
// ...
@ManyToMany(() => ProductCategory, { eager: true })
@JoinTable({
name: 'product_category_assignments',
joinColumn: { name: 'product_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'category_id', referencedColumnName: 'id' },
})
categories: ProductCategory[];
}
Categories:
@Entity({ name: 'product_categories' })
export class ProductCategory extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id: UUID;
// ...
@ManyToMany(() => ProductEntity, (p) => p.categories)
products: ProductEntity[];
}
This is a typical setup. I'm using AdminJS to expose this relationship in the UI.
On the list
view, AdminJS correctly displays "Categories Length: 1" (or similar), which is great.
However, in the edit
view for a product, the dropdown for categories fails to populate with the currently assigned categories. Inspecting the logs and network requests reveals that the frontend is sending [object Object]
instead of category ID(s).
Looking into it, I found that the root cause is in the flat.get()
function in /src/utils/flat/get.ts
.
When AdminJS tries to extract the category reference using:
flat.get(record.params, 'categories.0')
It ends up returning the entire object:
{
"id": "39dd081d-d609-400b-a6d2-49d6b13afb28",
"categoryId": "9075acbf-9b55-11ee-bbe7-3cecef014aad",
"parentId": "71bb1f59-3ff5-44c6-bcf8-fb335fcc16d7",
"name": "name"
}
Instead of just returning the id
as expected (string | undefined
). The problematic line is:
return (flat.unflatten(nestedProperties) as Record<string, unknown>)[TEMP_HOLDING_KEY]
This leads to the dropdown breaking - the frontend serializes the object as [object Object]
, and the backend gets a malformed request that fails to resolve related data.
AdminJS resource options:
// product
{
resource: ProductEntity,
options: {
titleProperty: 'id',
properties: {
id: {
isId: true,
},
categories: {
reference: ProductCategory.name,
isArray: true,
isVisible: {
edit: true,
filter: true,
list: true,
show: false,
},
},
}
}
}
// category
{
resource: ProductCategory,
options: {
titleProperty: 'name',
properties: {
id: {
isId: true,
},
// ...
}
// ...
}
}
Sample of record.params
(flattened):
// ...
'categories.0.id': '39dd081d-d609-400b-a6d2-49d6b13afb28',
'categories.0.categoryId': '9075acbf-9b55-11ee-bbe7-3cecef014aad',
'categories.0.parentId': '71bb1f59-3ff5-44c6-bcf8-fb335fcc16d7',
'categories.0.name': 'name',
'categories.0.parent': undefined,
'categories.0.children': undefined,
'categories.0.products': undefined,
// ...
Expected:
The call to:
flat.get(record.params, 'categories.0')
Should return:
'39dd081d-d609-400b-a6d2-49d6b13afb28'
Instead of:
{
id: '...',
name: '...',
...
}
Bug prevalence
I encounter this bug consistently every time I try to use a many-to-many relationship (with isArray: true and reference) in the edit view. It's reproducible in a clean setup using AdminJS with TypeORM and a typical many-to-many relation.
AdminJS dependencies version
"@adminjs/design-system": "^4.1.1",
"@adminjs/express": "^6.1.1",
"@adminjs/nestjs": "^6.1.0",
"@adminjs/passwords": "^4.0.0",
"@adminjs/typeorm": "^5.0.1",
"adminjs": "^7.8.15",
What browsers do you see the problem on?
Chrome
Relevant log output
query: SELECT `ProductCategory`.`id` AS `ProductCategory_id`, `ProductCategory`.`category_id` AS `ProductCategory_category_id`, `ProductCategory`.`parent_id` AS `ProductCategory_parent_id`, `ProductCategory`.`name` AS `ProductCategory_name` FROM `product_categories` `ProductCategory` ORDER BY `ProductCategory`.`name` ASC LIMIT 50
query: SELECT `ProductCategory`.`id` AS `ProductCategory_id`, `ProductCategory`.`category_id` AS `ProductCategory_category_id`, `ProductCategory`.`parent_id` AS `ProductCategory_parent_id`, `ProductCategory`.`name` AS `ProductCategory_name` FROM `product_categories` `ProductCategory` WHERE ((`ProductCategory`.`id` = ?)) LIMIT 1 -- PARAMETERS: ["[object Object]"]
Relevant code that's giving you issues
// /utils/flat/get.ts
const get = (params: FlattenParams = {}, propertyPath?: string, options?: GetOptions): any => {
if (!propertyPath) {
return flat.unflatten(params)
}
// when object has this key - simply return it
// we cannot rely on typeof params[propertyPath !== 'undefined' because params can actually be
// undefined and in such case if would pass and function would return [undefined]
if (Object.keys(params).find((key) => (key === propertyPath))) {
return params[propertyPath]
}
const regex = propertyKeyRegex(propertyPath, options)
const selectedParams = selectParams(params, propertyPath, options)
const nestedProperties = Object.keys(selectedParams).reduce((memo, key, index) => {
let newKey = key.replace(regex, `${TEMP_HOLDING_KEY}${DELIMITER}`)
// when user wants to take allSiblings we have to fix the indexes so nested items from
// different siblings don't overlap
//
// Example for key `nested.1.el`:
// 'nested.0.el.0.value': 'val0.0',
// 'nested.0.el.1.value': 'val0.1',
// 'nested.1.el.0.value': 'val1',
// 'nested.1.el.1.value': 'val2',
//
// has to be changed to:
// 'TEMP_HOLDING_KEY.0.value': 'val0.0',
// 'TEMP_HOLDING_KEY.1.value': 'val0.1',
// 'TEMP_HOLDING_KEY.2.value': 'val1',
// 'TEMP_HOLDING_KEY.3.value': 'val2',
if (options?.includeAllSiblings) {
newKey = newKey.replace(
new RegExp(`${TEMP_HOLDING_KEY}\\${DELIMITER}(\\d*)`),
`${TEMP_HOLDING_KEY}${DELIMITER}${index}`,
)
}
memo[newKey] = selectedParams[key]
return memo
}, {} as FlattenParams)
if (Object.keys(nestedProperties).length) {
return (flat.unflatten(nestedProperties) as Record<string, unknown>)[TEMP_HOLDING_KEY]
}
return undefined
}