feat: gérer l'ordre des champs personnalisés

This commit is contained in:
Matthieu
2025-10-28 18:08:08 +01:00
parent 635ea0e84e
commit 9f522a6dbb
15 changed files with 134 additions and 27 deletions

View File

@@ -0,0 +1,18 @@
-- Introduce an order index for custom fields so their ordering can be persisted.
ALTER TABLE "custom_fields"
ADD COLUMN "orderIndex" INTEGER NOT NULL DEFAULT 0;
WITH ranked AS (
SELECT
"id",
ROW_NUMBER() OVER (
PARTITION BY "typeMachineId", "typeComposantId", "typePieceId"
ORDER BY "createdAt", "id"
) - 1 AS rn
FROM "custom_fields"
)
UPDATE "custom_fields"
SET "orderIndex" = ranked.rn
FROM ranked
WHERE ranked."id" = "custom_fields"."id";

View File

@@ -245,6 +245,7 @@ model CustomField {
required Boolean @default(false) required Boolean @default(false)
defaultValue String? defaultValue String?
options String[] // Pour les champs de type SELECT options String[] // Pour les champs de type SELECT
orderIndex Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -6,12 +6,15 @@ const CUSTOM_FIELD_SELECT = {
type: true, type: true,
required: true, required: true,
options: true, options: true,
orderIndex: true,
} as const; } as const;
export const COMPONENT_WITH_RELATIONS_INCLUDE = { export const COMPONENT_WITH_RELATIONS_INCLUDE = {
typeComposant: { typeComposant: {
include: { include: {
customFields: true, customFields: {
orderBy: { orderIndex: 'asc' },
},
}, },
}, },
constructeurs: true, constructeurs: true,

View File

@@ -4,4 +4,5 @@ export const CUSTOM_FIELD_SELECT = {
type: true, type: true,
required: true, required: true,
options: true, options: true,
orderIndex: true,
} as const; } as const;

View File

@@ -43,7 +43,10 @@ describe('ModelTypeMapper', () => {
description: 'Desc', description: 'Desc',
notes: 'Desc', notes: 'Desc',
}); });
expect(input.customFields?.create?.[0]).toMatchObject({ name: 'Field' }); expect(input.customFields?.create?.[0]).toMatchObject({
name: 'Field',
orderIndex: 0,
});
expect((input as any).componentSkeleton).toEqual({ expect((input as any).componentSkeleton).toEqual({
pieces: [ pieces: [
{ {

View File

@@ -12,12 +12,18 @@ import type {
import { CUSTOM_FIELD_SELECT } from '../constants/custom-field.constant'; import { CUSTOM_FIELD_SELECT } from '../constants/custom-field.constant';
export const COMPONENT_TYPE_INCLUDE: Prisma.ModelTypeInclude = { export const COMPONENT_TYPE_INCLUDE: Prisma.ModelTypeInclude = {
customFields: { select: CUSTOM_FIELD_SELECT }, customFields: {
select: CUSTOM_FIELD_SELECT,
orderBy: { orderIndex: 'asc' },
},
composants: true, composants: true,
}; };
export const PIECE_TYPE_INCLUDE: Prisma.ModelTypeInclude = { export const PIECE_TYPE_INCLUDE: Prisma.ModelTypeInclude = {
pieceCustomFields: { select: CUSTOM_FIELD_SELECT }, pieceCustomFields: {
select: CUSTOM_FIELD_SELECT,
orderBy: { orderIndex: 'asc' },
},
pieceRequirements: true, pieceRequirements: true,
pieces: true, pieces: true,
}; };
@@ -42,11 +48,12 @@ export class ModelTypeMapper {
notes: description ?? null, notes: description ?? null,
customFields: customFields customFields: customFields
? { ? {
create: customFields.map((field) => ({ create: customFields.map((field, index) => ({
name: field.name, name: field.name,
type: field.type, type: field.type,
required: field.required ?? false, required: field.required ?? false,
options: field.options, options: field.options,
orderIndex: field.orderIndex ?? index,
})), })),
} }
: undefined, : undefined,
@@ -97,11 +104,12 @@ export class ModelTypeMapper {
notes: description ?? null, notes: description ?? null,
pieceCustomFields: customFields pieceCustomFields: customFields
? { ? {
create: customFields.map((field) => ({ create: customFields.map((field, index) => ({
name: field.name, name: field.name,
type: field.type, type: field.type,
required: field.required ?? false, required: field.required ?? false,
options: field.options, options: field.options,
orderIndex: field.orderIndex ?? index,
})), })),
} }
: undefined, : undefined,
@@ -165,11 +173,12 @@ export class ModelTypeMapper {
return []; return [];
} }
return fields.map((field) => ({ return fields.map((field, index) => ({
name: field.name, name: field.name,
type: field.type, type: field.type,
required: field.required ?? false, required: field.required ?? false,
options: field.options, options: field.options,
orderIndex: field.orderIndex ?? index,
})); }));
} }
@@ -180,11 +189,12 @@ export class ModelTypeMapper {
return []; return [];
} }
return fields.map((field) => ({ return fields.map((field, index) => ({
name: field.name, name: field.name,
type: field.type, type: field.type,
required: field.required ?? false, required: field.required ?? false,
options: field.options, options: field.options,
orderIndex: field.orderIndex ?? index,
})); }));
} }
} }

View File

@@ -33,6 +33,9 @@ describe('TypeMachineMapper', () => {
const input = TypeMachineMapper.toCreateInput(baseDto as any); const input = TypeMachineMapper.toCreateInput(baseDto as any);
expect(input.customFields?.create).toHaveLength(1); expect(input.customFields?.create).toHaveLength(1);
expect(input.customFields?.create?.[0]).toMatchObject({
orderIndex: 0,
});
expect(input.componentRequirements?.create?.[0]).toMatchObject({ expect(input.componentRequirements?.create?.[0]).toMatchObject({
label: 'Comp', label: 'Comp',
minCount: 2, minCount: 2,
@@ -61,6 +64,7 @@ describe('TypeMachineMapper', () => {
type: 'string', type: 'string',
required: true, required: true,
options: ['a'], options: ['a'],
orderIndex: 0,
}, },
]); ]);
}); });

View File

@@ -17,7 +17,10 @@ type RequirementDto = {
}; };
export const TYPE_MACHINE_DEFAULT_INCLUDE: Prisma.TypeMachineInclude = { export const TYPE_MACHINE_DEFAULT_INCLUDE: Prisma.TypeMachineInclude = {
customFields: { select: CUSTOM_FIELD_SELECT }, customFields: {
select: CUSTOM_FIELD_SELECT,
orderBy: { orderIndex: 'asc' },
},
componentRequirements: { componentRequirements: {
include: { typeComposant: true }, include: { typeComposant: true },
orderBy: { orderIndex: 'asc' }, orderBy: { orderIndex: 'asc' },
@@ -81,11 +84,12 @@ export class TypeMachineMapper {
} }
return { return {
create: fields.map((field) => ({ create: fields.map((field, index) => ({
name: field.name, name: field.name,
type: field.type, type: field.type,
required: field.required ?? false, required: field.required ?? false,
options: field.options, options: field.options,
orderIndex: field.orderIndex ?? index,
})), })),
}; };
} }
@@ -95,11 +99,12 @@ export class TypeMachineMapper {
return []; return [];
} }
return fields.map((field) => ({ return fields.map((field, index) => ({
name: field.name, name: field.name,
type: field.type, type: field.type,
required: field.required ?? false, required: field.required ?? false,
options: field.options, options: field.options,
orderIndex: field.orderIndex ?? index,
})); }));
} }

View File

@@ -45,6 +45,7 @@ describe('ModelTypesRepository', () => {
type: 'string', type: 'string',
required: true, required: true,
options: [], options: [],
orderIndex: 0,
typeComposantId: 'comp-id', typeComposantId: 'comp-id',
}, },
], ],
@@ -63,6 +64,7 @@ describe('ModelTypesRepository', () => {
type: 'string', type: 'string',
required: false, required: false,
options: [], options: [],
orderIndex: 0,
typePieceId: 'piece-id', typePieceId: 'piece-id',
}, },
], ],

View File

@@ -44,6 +44,7 @@ describe('TypeMachinesRepository', () => {
type: 'string', type: 'string',
required: true, required: true,
options: [], options: [],
orderIndex: 0,
typeMachineId: 'machine-id', typeMachineId: 'machine-id',
}, },
], ],

View File

@@ -209,12 +209,20 @@ export class CustomFieldsService {
if (existingField) { if (existingField) {
targetCustomFieldId = existingField.id; targetCustomFieldId = existingField.id;
} else { } else {
const normalizedType = (customFieldType || 'text').trim() || 'text';
const normalizedRequired = !!customFieldRequired;
const orderScope = { [customFieldTypeField]: typeId } as const;
const nextOrderIndex = await this.prisma.customField.count({
where: orderScope,
});
const createdField = await this.prisma.customField.create({ const createdField = await this.prisma.customField.create({
data: { data: {
name: normalizedName, name: normalizedName,
type: (customFieldType || 'text').trim() || 'text', type: normalizedType,
required: !!customFieldRequired, required: normalizedRequired,
options: normalizedOptions, options: normalizedOptions,
orderIndex: nextOrderIndex,
[customFieldTypeField]: typeId, [customFieldTypeField]: typeId,
}, },
}); });

View File

@@ -17,15 +17,21 @@ const CUSTOM_FIELD_SELECT = {
type: true, type: true,
required: true, required: true,
options: true, options: true,
orderIndex: true,
} as const; } as const;
const TYPE_MACHINE_CONFIGURATION_INCLUDE: Prisma.TypeMachineInclude = { const TYPE_MACHINE_CONFIGURATION_INCLUDE: Prisma.TypeMachineInclude = {
customFields: { select: CUSTOM_FIELD_SELECT }, customFields: {
select: CUSTOM_FIELD_SELECT,
orderBy: { orderIndex: 'asc' },
},
componentRequirements: { componentRequirements: {
include: { include: {
typeComposant: { typeComposant: {
include: { include: {
customFields: true, customFields: {
orderBy: { orderIndex: 'asc' },
},
}, },
}, },
}, },
@@ -34,7 +40,9 @@ const TYPE_MACHINE_CONFIGURATION_INCLUDE: Prisma.TypeMachineInclude = {
include: { include: {
typePiece: { typePiece: {
include: { include: {
customFields: true, customFields: {
orderBy: { orderIndex: 'asc' },
},
}, },
}, },
}, },
@@ -52,7 +60,9 @@ const MACHINE_PIECE_LINK_INCLUDE: Prisma.MachinePieceLinkInclude = {
constructeurs: true, constructeurs: true,
typePiece: { typePiece: {
include: { include: {
customFields: true, customFields: {
orderBy: { orderIndex: 'asc' },
},
}, },
}, },
documents: true, documents: true,
@@ -62,7 +72,9 @@ const MACHINE_PIECE_LINK_INCLUDE: Prisma.MachinePieceLinkInclude = {
include: { include: {
typePiece: { typePiece: {
include: { include: {
customFields: true, customFields: {
orderBy: { orderIndex: 'asc' },
},
}, },
}, },
}, },
@@ -78,7 +90,9 @@ const buildComponentLinkInclude = (
constructeurs: true, constructeurs: true,
typeComposant: { typeComposant: {
include: { include: {
customFields: true, customFields: {
orderBy: { orderIndex: 'asc' },
},
}, },
}, },
customFieldValues: { customFieldValues: {
@@ -93,7 +107,9 @@ const buildComponentLinkInclude = (
include: { include: {
typeComposant: { typeComposant: {
include: { include: {
customFields: true, customFields: {
orderBy: { orderIndex: 'asc' },
},
}, },
}, },
}, },
@@ -1663,12 +1679,23 @@ export class MachinesService {
let targetCustomFieldId = existingCustomFieldId; let targetCustomFieldId = existingCustomFieldId;
if (!targetCustomFieldId) { if (!targetCustomFieldId) {
const whereClause = typeMachineId
? { typeMachineId }
: { typeMachineId: null };
const nextOrderIndex =
typeof customField.orderIndex === 'number'
? customField.orderIndex
: await prisma.customField.count({
where: whereClause,
});
const createdCustomField = await prisma.customField.create({ const createdCustomField = await prisma.customField.create({
data: { data: {
name: customField.name, name: customField.name,
type: customField.type, type: customField.type,
required: customField.required || false, required: customField.required || false,
options: customField.options || [], options: customField.options || [],
orderIndex: nextOrderIndex,
typeMachineId: typeMachineId ?? null, typeMachineId: typeMachineId ?? null,
}, },
}); });

View File

@@ -19,6 +19,7 @@ describe('PiecesService', () => {
customField: { customField: {
findMany: jest.fn(), findMany: jest.fn(),
create: jest.fn(), create: jest.fn(),
update: jest.fn(),
}, },
customFieldValue: { customFieldValue: {
findMany: jest.fn(), findMany: jest.fn(),

View File

@@ -8,7 +8,9 @@ import type { PieceModelStructure } from '../shared/types/inventory';
const PIECE_WITH_RELATIONS_INCLUDE = { const PIECE_WITH_RELATIONS_INCLUDE = {
typePiece: { typePiece: {
include: { include: {
pieceCustomFields: true, pieceCustomFields: {
orderBy: { orderIndex: 'asc' },
},
}, },
}, },
constructeurs: true, constructeurs: true,
@@ -286,23 +288,35 @@ export class PiecesService {
const existing = await this.prisma.customField.findMany({ const existing = await this.prisma.customField.findMany({
where: { typePieceId }, where: { typePieceId },
select: { id: true, name: true }, select: { id: true, name: true, orderIndex: true },
}); });
const existingByName = new Map( const existingByName = new Map(
existing.map((field) => [ existing.map((field) => [
this.normalizeIdentifier(field.name) ?? field.name, this.normalizeIdentifier(field.name) ?? field.name,
field.id, field,
]), ]),
); );
for (const field of customFields) { for (let index = 0; index < customFields.length; index += 1) {
const field = customFields[index];
if (!field) { if (!field) {
continue; continue;
} }
const name = this.normalizeIdentifier(field.name); const name = this.normalizeIdentifier(field.name);
if (!name || existingByName.has(name)) { if (!name) {
continue;
}
const existingField = existingByName.get(name);
if (existingField) {
if (existingField.orderIndex !== index) {
await this.prisma.customField.update({
where: { id: existingField.id },
data: { orderIndex: index },
});
}
continue; continue;
} }
@@ -316,12 +330,13 @@ export class PiecesService {
type, type,
required, required,
options, options,
orderIndex: index,
typePieceId, typePieceId,
}, },
select: { id: true }, select: { id: true, name: true, orderIndex: true },
}); });
existingByName.set(name, created.id); existingByName.set(name, created);
} }
} }

View File

@@ -36,6 +36,10 @@ export class CreateCustomFieldDto {
@IsOptional() @IsOptional()
@IsArray() @IsArray()
options?: string[]; // Pour les champs de type SELECT options?: string[]; // Pour les champs de type SELECT
@IsOptional()
@IsInt()
orderIndex?: number;
} }
export class UpdateCustomFieldDto { export class UpdateCustomFieldDto {
@@ -54,6 +58,10 @@ export class UpdateCustomFieldDto {
@IsOptional() @IsOptional()
@IsArray() @IsArray()
options?: string[]; options?: string[];
@IsOptional()
@IsInt()
orderIndex?: number;
} }
export class TypeMachineComponentRequirementDto { export class TypeMachineComponentRequirementDto {