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)
defaultValue String?
options String[] // Pour les champs de type SELECT
orderIndex Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

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

View File

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

View File

@@ -43,7 +43,10 @@ describe('ModelTypeMapper', () => {
description: '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({
pieces: [
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -209,12 +209,20 @@ export class CustomFieldsService {
if (existingField) {
targetCustomFieldId = existingField.id;
} 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({
data: {
name: normalizedName,
type: (customFieldType || 'text').trim() || 'text',
required: !!customFieldRequired,
type: normalizedType,
required: normalizedRequired,
options: normalizedOptions,
orderIndex: nextOrderIndex,
[customFieldTypeField]: typeId,
},
});

View File

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

View File

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

View File

@@ -8,7 +8,9 @@ import type { PieceModelStructure } from '../shared/types/inventory';
const PIECE_WITH_RELATIONS_INCLUDE = {
typePiece: {
include: {
pieceCustomFields: true,
pieceCustomFields: {
orderBy: { orderIndex: 'asc' },
},
},
},
constructeurs: true,
@@ -286,23 +288,35 @@ export class PiecesService {
const existing = await this.prisma.customField.findMany({
where: { typePieceId },
select: { id: true, name: true },
select: { id: true, name: true, orderIndex: true },
});
const existingByName = new Map(
existing.map((field) => [
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) {
continue;
}
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;
}
@@ -316,12 +330,13 @@ export class PiecesService {
type,
required,
options,
orderIndex: index,
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()
@IsArray()
options?: string[]; // Pour les champs de type SELECT
@IsOptional()
@IsInt()
orderIndex?: number;
}
export class UpdateCustomFieldDto {
@@ -54,6 +58,10 @@ export class UpdateCustomFieldDto {
@IsOptional()
@IsArray()
options?: string[];
@IsOptional()
@IsInt()
orderIndex?: number;
}
export class TypeMachineComponentRequirementDto {