From 3705b8daedbf66f8319751efd066ecad6fe9eafe Mon Sep 17 00:00:00 2001
From: matthieu
Date: Thu, 29 Jan 2026 19:53:56 +0100
Subject: [PATCH] feat(model-types): allow adding custom fields in restricted
mode
When a category has linked items (pieces, components, products),
enable restricted mode instead of blocking all edits:
- Allow adding new custom fields
- Lock existing fields from modification or deletion
- Hide add buttons for products, pieces, and subcomponents
- Display informative message about restricted mode
Co-Authored-By: Claude Opus 4.5
---
.../ComponentModelStructureEditor.vue | 5 +
app/components/PieceModelStructureEditor.vue | 47 ++++++++-
app/components/StructureNodeEditor.vue | 97 +++++++++++++++++--
app/components/model-types/ModelTypeForm.vue | 27 +++++-
app/composables/useCategoryEditGuard.ts | 27 ++++--
app/pages/component-category/[id]/edit.vue | 4 +
app/pages/piece-category/[id]/edit.vue | 4 +
app/pages/product-category/[id]/edit.vue | 4 +
8 files changed, 194 insertions(+), 21 deletions(-)
diff --git a/app/components/ComponentModelStructureEditor.vue b/app/components/ComponentModelStructureEditor.vue
index f69c3a0..14869e2 100644
--- a/app/components/ComponentModelStructureEditor.vue
+++ b/app/components/ComponentModelStructureEditor.vue
@@ -10,6 +10,7 @@
:locked-type-label="displayedRootTypeLabel"
:allow-subcomponents="allowSubcomponents"
:max-subcomponent-depth="maxSubcomponentDepth"
+ :restricted-mode="restrictedMode"
is-root
/>
@@ -55,6 +56,10 @@ const props = defineProps({
type: Number,
default: Infinity,
},
+ restrictedMode: {
+ type: Boolean,
+ default: false,
+ },
})
const emit = defineEmits(['update:modelValue'])
diff --git a/app/components/PieceModelStructureEditor.vue b/app/components/PieceModelStructureEditor.vue
index 599f053..27e2a82 100644
--- a/app/components/PieceModelStructureEditor.vue
+++ b/app/components/PieceModelStructureEditor.vue
@@ -7,10 +7,10 @@
Produits inclus par défaut
- Ces produits s’afficheront lors de la création d’une pièce basée sur cette catégorie.
+ Ces produits s'afficheront lors de la création d'une pièce basée sur cette catégorie.
-
+
Ajouter
@@ -35,6 +35,7 @@
@@ -51,12 +52,22 @@
+
+
+
+
+
@@ -107,8 +118,9 @@
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
+ :disabled="isFieldLocked(field)"
>
-
+
Texte
@@ -128,7 +140,7 @@
-
+
Obligatoire
@@ -137,16 +149,27 @@
v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1
Option 2"
+ :disabled="isFieldLocked(field)"
/>
+
+
+
+
+
@@ -181,6 +204,7 @@ type EditorProduct = {
const props = defineProps<{
modelValue?: PieceModelStructure | null
+ restrictedMode?: boolean
}>()
const emit = defineEmits<{
@@ -330,6 +354,19 @@ const fields = ref(hydrateFields(props.modelValue))
const products = ref(hydrateProducts(props.modelValue))
const restState = ref>(extractRest(props.modelValue))
+const initialFieldUids = ref>(new Set(fields.value.map(f => f.uid)))
+const initialProductUids = ref>(new Set(products.value.map(p => p.uid)))
+
+const isFieldLocked = (field: EditorField): boolean => {
+ return props.restrictedMode === true && initialFieldUids.value.has(field.uid)
+}
+
+const isProductLocked = (product: EditorProduct): boolean => {
+ return props.restrictedMode === true && initialProductUids.value.has(product.uid)
+}
+
+const restrictedMode = computed(() => props.restrictedMode === true)
+
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
list.map((field, index) => ({
...field,
@@ -438,6 +475,8 @@ watch(
products.value = hydrateProducts(value)
products.value.forEach((product) => updateProductTypeMetadata(product))
lastEmitted = incomingSerialized
+ initialFieldUids.value = new Set(fields.value.map(f => f.uid))
+ initialProductUids.value = new Set(products.value.map(p => p.uid))
},
{ deep: true },
)
diff --git a/app/components/StructureNodeEditor.vue b/app/components/StructureNodeEditor.vue
index f520923..bce8a43 100644
--- a/app/components/StructureNodeEditor.vue
+++ b/app/components/StructureNodeEditor.vue
@@ -17,6 +17,7 @@
@@ -42,6 +43,7 @@
type="text"
class="input input-bordered input-xs"
placeholder="Alias du sous-composant"
+ :disabled="isLocked"
/>
@@ -52,13 +54,18 @@
+
+
+
+
+
@@ -107,8 +114,9 @@
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
+ :disabled="isCustomFieldLocked(index)"
/>
-
+
Texte
Nombre
Liste
@@ -117,7 +125,7 @@
-
+
Obligatoire
+
+
+
+
+
@@ -144,7 +163,7 @@
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
-
+
Ajouter
@@ -179,6 +198,7 @@
@@ -194,9 +214,18 @@
-
+
+
+
+
+
+
@@ -207,7 +236,7 @@
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
-
+
Ajouter
@@ -243,6 +272,7 @@
@@ -262,9 +292,14 @@
-
+
+
+
+
+
+
@@ -274,7 +309,7 @@
Sous-composants
@@ -359,6 +396,8 @@ const props = withDefaults(defineProps<{
lockedTypeLabel?: string
allowSubcomponents?: boolean
maxSubcomponentDepth?: number
+ restrictedMode?: boolean
+ isLocked?: boolean
}>(), {
depth: 0,
componentTypes: () => [],
@@ -369,10 +408,52 @@ const props = withDefaults(defineProps<{
lockedTypeLabel: '',
allowSubcomponents: true,
maxSubcomponentDepth: Infinity,
+ restrictedMode: false,
+ isLocked: false,
})
const emit = defineEmits(['remove'])
+const initialCustomFieldIndices = ref>(new Set())
+const initialPieceIndices = ref>(new Set())
+const initialProductIndices = ref>(new Set())
+const initialSubcomponentIndices = ref>(new Set())
+
+const initializeLockedIndices = () => {
+ if (props.restrictedMode) {
+ const customFieldsLength = Array.isArray(props.node.customFields) ? props.node.customFields.length : 0
+ const piecesLength = Array.isArray(props.node.pieces) ? props.node.pieces.length : 0
+ const productsLength = Array.isArray(props.node.products) ? props.node.products.length : 0
+ const subcomponentsLength = Array.isArray(props.node.subcomponents) ? props.node.subcomponents.length : 0
+
+ initialCustomFieldIndices.value = new Set(Array.from({ length: customFieldsLength }, (_, i) => i))
+ initialPieceIndices.value = new Set(Array.from({ length: piecesLength }, (_, i) => i))
+ initialProductIndices.value = new Set(Array.from({ length: productsLength }, (_, i) => i))
+ initialSubcomponentIndices.value = new Set(Array.from({ length: subcomponentsLength }, (_, i) => i))
+ }
+}
+
+initializeLockedIndices()
+
+const isCustomFieldLocked = (index: number): boolean => {
+ return props.restrictedMode === true && initialCustomFieldIndices.value.has(index)
+}
+
+const isPieceLocked = (index: number): boolean => {
+ return props.restrictedMode === true && initialPieceIndices.value.has(index)
+}
+
+const isProductLocked = (index: number): boolean => {
+ return props.restrictedMode === true && initialProductIndices.value.has(index)
+}
+
+const isSubcomponentLocked = (index: number): boolean => {
+ return props.restrictedMode === true && initialSubcomponentIndices.value.has(index)
+}
+
+const isLocked = computed(() => props.isLocked === true)
+const restrictedMode = computed(() => props.restrictedMode === true)
+
const componentTypes = computed(() => props.componentTypes ?? [])
const pieceTypes = computed(() => props.pieceTypes ?? [])
const productTypes = computed(() => props.productTypes ?? [])
diff --git a/app/components/model-types/ModelTypeForm.vue b/app/components/model-types/ModelTypeForm.vue
index 84cb4b4..b4ca544 100644
--- a/app/components/model-types/ModelTypeForm.vue
+++ b/app/components/model-types/ModelTypeForm.vue
@@ -15,6 +15,7 @@
minlength="2"
maxlength="120"
required
+ :disabled="restrictedMode"
/>
{{ errors.name }}
@@ -47,6 +48,7 @@
rows="4"
name="notes"
maxlength="2000"
+ :disabled="restrictedMode"
>
Saisissez des informations complémentaires (facultatif).
@@ -81,6 +83,7 @@
v-model="componentStructure"
:allow-subcomponents="allowComponentSubcomponents"
:max-subcomponent-depth="componentSubcomponentMaxDepth"
+ :restricted-mode="restrictedMode"
/>
@@ -92,7 +95,7 @@
Aperçu :
{{ pieceStructurePreview }}
-
+
{{ productStructurePreview }}
-
+
+
+
+
{{ restrictedModeMessage }}
+
+
(), {
initialData: null,
saving: false,
@@ -170,6 +185,8 @@ const props = withDefaults(defineProps<{
componentSubcomponentMaxDepth: 1,
disableSubmit: false,
disableSubmitMessage: '',
+ restrictedMode: false,
+ restrictedModeMessage: '',
})
const emit = defineEmits<{
@@ -192,6 +209,12 @@ const disableSubmitMessage = computed(() =>
? props.disableSubmitMessage
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
)
+const restrictedMode = computed(() => props.restrictedMode === true)
+const restrictedModeMessage = computed(() =>
+ (props.restrictedModeMessage && props.restrictedModeMessage.trim())
+ ? props.restrictedModeMessage
+ : '',
+)
const form = reactive
({
name: '',
diff --git a/app/composables/useCategoryEditGuard.ts b/app/composables/useCategoryEditGuard.ts
index 2cfbf1e..74cb23d 100644
--- a/app/composables/useCategoryEditGuard.ts
+++ b/app/composables/useCategoryEditGuard.ts
@@ -32,7 +32,7 @@ const extractTotal = (payload: any, fallbackLength: number) => {
export function useCategoryEditGuard (config: GuardConfig) {
const { get } = useApi()
- const { showError } = useToast()
+ const { showInfo } = useToast()
const linkedCount = ref(0)
const linkedLoading = ref(false)
@@ -64,11 +64,15 @@ export function useCategoryEditGuard (config: GuardConfig) {
}
}
- const isSubmitBlocked = computed(
- () => linkedLoading.value || linkedCount.value > 0,
+ const isRestrictedMode = computed(
+ () => !linkedLoading.value && linkedCount.value > 0,
)
- const submitBlockMessage = computed(() => {
+ const isSubmitBlocked = computed(
+ () => linkedLoading.value,
+ )
+
+ const restrictedModeMessage = computed(() => {
if (linkedLoading.value) {
return config.labels.verifying
}
@@ -76,23 +80,32 @@ export function useCategoryEditGuard (config: GuardConfig) {
return ''
}
if (linkedCount.value === 1) {
- return `Modification bloquée : 1 ${config.labels.singular} est déjà lié à cette catégorie.`
+ return `Mode restreint : 1 ${config.labels.singular} est déjà lié à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés, mais pas modifier ou supprimer les existants.`
}
- return `Modification bloquée : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie.`
+ return `Mode restreint : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés, mais pas modifier ou supprimer les existants.`
+ })
+
+ const submitBlockMessage = computed(() => {
+ if (linkedLoading.value) {
+ return config.labels.verifying
+ }
+ return ''
})
const guardSubmitOrNotify = () => {
if (!isSubmitBlocked.value) {
return false
}
- showError(submitBlockMessage.value || 'Modification bloquée pour cette catégorie.')
+ showInfo(submitBlockMessage.value || 'Veuillez patienter...')
return true
}
return {
linkedCount,
linkedLoading,
+ isRestrictedMode,
isSubmitBlocked,
+ restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
diff --git a/app/pages/component-category/[id]/edit.vue b/app/pages/component-category/[id]/edit.vue
index 567390b..4e0f5af 100644
--- a/app/pages/component-category/[id]/edit.vue
+++ b/app/pages/component-category/[id]/edit.vue
@@ -28,6 +28,8 @@
:saving="saving"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
+ :restricted-mode="isRestrictedMode"
+ :restricted-mode-message="restrictedModeMessage"
@submit="handleSubmit"
@cancel="handleCancel"
/>
@@ -52,7 +54,9 @@ const saving = ref(false)
const initialData = ref | null>(null)
const {
+ isRestrictedMode,
isSubmitBlocked,
+ restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
diff --git a/app/pages/piece-category/[id]/edit.vue b/app/pages/piece-category/[id]/edit.vue
index f816c8d..7b17bbc 100644
--- a/app/pages/piece-category/[id]/edit.vue
+++ b/app/pages/piece-category/[id]/edit.vue
@@ -28,6 +28,8 @@
:saving="saving"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
+ :restricted-mode="isRestrictedMode"
+ :restricted-mode-message="restrictedModeMessage"
@submit="handleSubmit"
@cancel="handleCancel"
/>
@@ -52,7 +54,9 @@ const saving = ref(false)
const initialData = ref | null>(null)
const {
+ isRestrictedMode,
isSubmitBlocked,
+ restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
diff --git a/app/pages/product-category/[id]/edit.vue b/app/pages/product-category/[id]/edit.vue
index 3707006..687d16d 100644
--- a/app/pages/product-category/[id]/edit.vue
+++ b/app/pages/product-category/[id]/edit.vue
@@ -28,6 +28,8 @@
:saving="saving"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
+ :restricted-mode="isRestrictedMode"
+ :restricted-mode-message="restrictedModeMessage"
@submit="handleSubmit"
@cancel="handleCancel"
/>
@@ -52,7 +54,9 @@ const saving = ref(false)
const initialData = ref | null>(null)
const {
+ isRestrictedMode,
isSubmitBlocked,
+ restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,