diff --git a/package.json b/package.json index 4f74f5cc3..9285647c2 100644 --- a/package.json +++ b/package.json @@ -48,4 +48,4 @@ "vite": "^8.1.5", "vite-plugin-environment": "^1.1.3" } -} \ No newline at end of file +} diff --git a/src/components/specific/files/all-files-table/AllFilesTable.vue b/src/components/specific/files/all-files-table/AllFilesTable.vue index cbeca91a1..5064933d4 100644 --- a/src/components/specific/files/all-files-table/AllFilesTable.vue +++ b/src/components/specific/files/all-files-table/AllFilesTable.vue @@ -218,6 +218,7 @@ @download="$emit('download', file)" @file-clicked="$emit('file-clicked', file)" @manage-access="$emit('manage-access', file)" + @manage-naming-rule="$emit('manage-naming-rule', file)" @open-tag-manager="$emit('open-tag-manager', file)" @open-versioning-manager="$emit('open-versioning-manager', file)" @open-visa-manager="$emit('open-visa-manager', file)" @@ -288,6 +289,7 @@ export default { "file-clicked", "go-folders-view", "manage-access", + "manage-naming-rule", "open-tag-manager", "open-versioning-manager", "open-visa-manager", diff --git a/src/components/specific/files/files-manager/FilesManager.vue b/src/components/specific/files/files-manager/FilesManager.vue index 243a31375..8a288d8e0 100644 --- a/src/components/specific/files/files-manager/FilesManager.vue +++ b/src/components/specific/files/files-manager/FilesManager.vue @@ -11,6 +11,7 @@ :initialSearchText="searchText" @update:searchText="searchText = $event" @upload-files="uploadFiles" + @manage-naming-conflicts="openNamingConflictsModal" />
@@ -76,6 +77,7 @@ :filesToUpload="filesToUpload" :folder="currentFolder" :foldersToUpload="foldersToUpload" + :hasNamingConflict="hasNamingConflict" @back-parent-folder="backToParent" @create-model="createModelFromFile" @create-photosphere="createModelFromFile($event, MODEL_TYPE.PHOTOSPHERE)" @@ -88,6 +90,7 @@ @file-clicked="onFileSelected" @file-uploaded="$emit('file-uploaded')" @manage-access="openAccessManager" + @manage-naming-rule="openFolderNamingConstraintManager" @open-tag-manager="openTagManager" @open-versioning-manager="openVersioningManager" @open-visa-manager="openVisaManager" @@ -111,6 +114,7 @@ @file-clicked="onFileSelected" @go-folders-view="goFoldersView" @manage-access="openAccessManager" + @manage-naming-rule="openFolderNamingConstraintManager" @open-tag-manager="openTagManager" @open-versioning-manager="openVersioningManager" @open-visa-manager="openVisaManager" @@ -205,12 +209,14 @@ import FileService from "../../../../services/FileService.js"; import TagService from "../../../../services/TagService"; import { useFiles } from "../../../../state/files.js"; import { useModels } from "../../../../state/models.js"; +import { useNamingConstraints } from "../../../../state/naming-constraints.js"; import { useProjects } from "../../../../state/projects.js"; import { useSpaces } from "../../../../state/spaces.js"; import { useVisa } from "../../../../state/visa.js"; import { collectDescendants } from "../../../../utils/file-tree.js"; import { isFolder } from "../../../../utils/file-structure.js"; import { getFilesFromEvent } from "../../../../utils/files.js"; +import { matchName } from "../../../../utils/naming-constraint.js"; import { isFullTotal } from "../../../../utils/spaces.js"; import { fileUploadInput } from "../../../../utils/upload.js"; @@ -225,6 +231,8 @@ import FilesManagerOnboarding from "./files-manager-onboarding/FilesManagerOnboa import FileTree from "../file-tree/FileTree.vue"; import FileTreePreviewModal from "../file-tree-preview-modal/FileTreePreviewModal.vue"; import FolderAccessManager from "../folder-access-manager/FolderAccessManager.vue"; +import FolderNamingConstraintManager from "../naming-constraint/FolderNamingConstraintManager.vue"; +import NamingConflictModal from "../naming-constraint/NamingConflictModal.vue"; import FoldersTable from "../folder-table/FoldersTable.vue"; import SubscriptionModal from "../../subscriptions/subscription-modal/SubscriptionModal.vue"; import TagsMain from "../../tags/tags-main/TagsMain.vue"; @@ -264,6 +272,10 @@ export default { type: Object, required: true, }, + refreshFiles: { + type: Function, + required: true, + }, }, emits: ["file-uploaded", "file-updated", "model-created"], setup(props, { emit }) { @@ -280,7 +292,7 @@ export default { const { createModel, createPhotosphere, deleteModels } = useModels(); const { fetchToValidateVisas, fetchCreatedVisas } = useVisa(); - + const { getEffectiveFolderRule } = useNamingConstraints(); const currentFolder = ref(null); const currentFiles = ref([]); const toValidateVisas = ref([]); @@ -337,20 +349,55 @@ export default { const filesToUpload = ref([]); const foldersToUpload = ref([]); - const uploadFiles = async (event, folder = currentFolder.value) => { - const { files, folders } = await getFilesFromEvent(event); + const proceedUpload = async ({ files, folders }, folder) => { files.forEach((file) => (file.folder = folder)); - filesToUpload.value = files; foldersToUpload.value = await Promise.all( folders.map((f) => FileService.createFolderStructure(props.project, folder, f)), ); - setTimeout(() => { filesToUpload.value = []; foldersToUpload.value = []; }, 10); }; + let folders = []; + const uploadFiles = async (event, folder = currentFolder.value) => { + const { files, folders } = await getFilesFromEvent(event); + + const rule = await getEffectiveFolderRule(props.project, folder); + const invalidFiles = rule?.rule + ? files.filter((file) => !matchName(file.name, rule.rule)) + : []; + + if (invalidFiles.length > 0) { + openModal({ + component: NamingConflictModal, + props: { + project: props.project, + documents: invalidFiles.map((file, i) => ({ id: `upload-${i}`, name: file.name })), + rule, + persistChanges: false, + onClose: closeModal, + onConfirm: ({ renamed, deleted }) => { + const deletedIds = new Set(deleted.map((d) => d.id)); + const renamedById = new Map(renamed.map((r) => [r.id, r.name])); + const finalFiles = invalidFiles + .map((file, i) => ({ file, id: `upload-${i}` })) + .filter(({ id }) => !deletedIds.has(id)) + .map(({ file, id }) => { + const name = renamedById.get(id); + return name ? new File([file], name, { type: file.type }) : file; + }); + const validFiles = files.filter((file) => matchName(file.name, rule.rule)); + proceedUpload({ files: [...validFiles, ...finalFiles], folders }, folder); + }, + }, + }); + return; + } + + proceedUpload({ files, folders }, folder); + }; const loadingFileIds = ref([]); const isCreatingModels = ref(false); @@ -544,6 +591,49 @@ export default { }, 100); }; + const openFolderNamingConstraintManager = (folder) => { + openSidePanel("right", { + component: FolderNamingConstraintManager, + props: { + project: props.project, + folder, + refreshFiles: props.refreshFiles, + }, + }); + }; + + const openNamingConflictsModal = async () => { + const conflicting = allFiles.value.filter((file) => file.naming_constraint_conflict); + if (conflicting.length === 0) { + pushNotification({ + type: "success", + title: t("NamingConstraint.noConflictsTitle"), + message: t("NamingConstraint.noConflictsMessage"), + }); + return; + } + const documents = await Promise.all( + conflicting.map(async (file) => { + const folder = allFolders.value.find((f) => f.id === file.parent_id); + const effective = folder ? await getEffectiveFolderRule(props.project, folder) : null; + return { ...file, namingRule: effective?.rule ?? null }; + }), + ); + openModal({ + component: NamingConflictModal, + props: { + project: props.project, + documents, + allFolders: allFolders.value, + rule: null, + onClose: closeModal, + onConfirm: async () => { + await props.refreshFiles(); + }, + }, + }); + }; + const visasLoading = ref(false); const openVisaManager = (file) => { onTabChange(filesTabs[2]); @@ -683,6 +773,19 @@ export default { const allFiles = computed(() => getFilesInFolder(props.fileStructure)); const allFolders = computed(() => getFoldersInFolder(props.fileStructure)); + const hasNamingConflict = (folder) => { + if (!folder?.children?.length) { + return false; + } + + return folder.children.some((child) => { + if (isFolder(child)) { + return hasNamingConflict(child); + } + return child.naming_constraint_conflict; + }); + }; + const filesTabs = [ { id: "folders", @@ -829,10 +932,13 @@ export default { fileUploadInput, goFoldersView, goVisasView, + hasNamingConflict, isFullTotal, moveFiles, onFileSelected, openAccessManager, + openFolderNamingConstraintManager, + openNamingConflictsModal, openFileDeleteModal, openVisaDeleteModal, openSidePanel, diff --git a/src/components/specific/files/files-manager/files-manager-actions/FilesManagerActions.vue b/src/components/specific/files/files-manager/files-manager-actions/FilesManagerActions.vue index f2228ba58..405627f86 100644 --- a/src/components/specific/files/files-manager/files-manager-actions/FilesManagerActions.vue +++ b/src/components/specific/files/files-manager/files-manager-actions/FilesManagerActions.vue @@ -42,7 +42,7 @@ $t( `ProjectOverview.uploadDisableMessage.${ isFullTotal(spaceSubInfo) ? 'size' : 'permission' - }` + }`, ) " > @@ -104,6 +104,24 @@ clear />
+ +
+ + + + {{ $t("NamingConstraint.managerTitle") }} + + +
@@ -111,18 +129,23 @@ import { computed, ref, inject, watch } from "vue"; import { useI18n } from "vue-i18n"; import { useAppModal } from "../../../app/app-modal/app-modal.js"; +import { useAppSidePanel } from "../../../../../components/specific/app/app-side-panel/app-side-panel.js"; import { useStandardBreakpoints, useCustomBreakpoints, } from "../../../../../composables/responsive.js"; import { useFiles } from "../../../../../state/files.js"; +import { useProjects } from "../../../../../state/projects.js"; import { useUser } from "../../../../../state/user.js"; import { isFullTotal } from "../../../../../utils/spaces.js"; +import { collectDescendants } from "../../../../../utils/file-tree.js"; +import { isFolder } from "../../../../../utils/file-structure.js"; import { fileUploadInput } from "../../../../../utils/upload.js"; // Components import FileDragAndDropModal from "../file-drag-and-drop-modal/FileDragAndDropModal.vue"; import FolderCreationButton from "../../folder-creation-button/FolderCreationButton.vue"; +import NamingConstraintsManager from "../../../../../components/specific/files/naming-constraint/NamingConstraintsManager.vue"; export default { components: { @@ -157,25 +180,36 @@ export default { required: true, }, }, - emits: ["open-subscription-modal", "update:searchText", "upload-files"], + emits: [ + "open-subscription-modal", + "update:searchText", + "upload-files", + "manage-naming-conflicts", + ], setup(props, { emit }) { const { t } = useI18n(); const { isUserOrga, isProjectAdmin, isProjectGuest, hasAdminPerm } = useUser(); const { openModal } = useAppModal(); + const { openSidePanel } = useAppSidePanel(); + const { currentProject } = useProjects(); const shouldSubscribe = inject("shouldSubscribe"); - - const { - downloadFiles: download, - projectFileStructure, - } = useFiles(); + const { downloadFiles: download, projectFileStructure } = useFiles(); const downloadFiles = async (files) => { await download(props.project, files); }; const dropdown = ref(null); + const conflictCount = computed(() => { + const root = projectFileStructure.value; + if (!root) return 0; + return collectDescendants( + root, + (child) => !isFolder(child) && child.naming_constraint_conflict, + ).length; + }); const menuItems = computed(() => { const items = []; @@ -188,10 +222,22 @@ export default { { name: t("FilesManager.gedDownload"), action: () => downloadFiles([projectFileStructure.value]), - } + }, ); } + if (isProjectAdmin(props.project)) { + items.push({ + name: + t("NamingConstraint.renameConflictsMenuItem") + + (conflictCount.value > 0 ? ` (${conflictCount.value})` : ""), + action: () => { + emit("manage-naming-conflicts"); + dropdown.value.displayed = false; + }, + }); + } + if (hasAdminPerm(props.project, props.currentFolder)) { items.splice(1, 0, { name: t("FilesManager.folderImport"), @@ -219,7 +265,7 @@ export default { return `${fileManager.value?.$el?.getBoundingClientRect().height - H - y}px`; }); - const { isMD, isLG, isXL } = useStandardBreakpoints(); + const { isMD, isLG, isXL, isXXL } = useStandardBreakpoints(); const { isMidXXL, isXXXL } = useCustomBreakpoints({ isMidXXL: ({ width }) => width <= 1277 - 0.02, isXXXL: ({ width }) => width <= 1521 - 0.02, @@ -228,19 +274,28 @@ export default { const isLargeLayout = computed( () => (isProjectAdmin(props.project) && !isXXXL.value) || - (!isProjectAdmin(props.project) && !isMidXXL.value) + (!isProjectAdmin(props.project) && !isMidXXL.value), ); const isMediumLayout = computed( () => (isProjectAdmin(props.project) && !isXL.value && isXXXL.value) || - (!isProjectAdmin(props.project) && !isMD.value && isMidXXL.value) + (!isProjectAdmin(props.project) && !isMD.value && isMidXXL.value), ); - const searchText = ref(props.initialSearchText || ''); + const searchText = ref(props.initialSearchText || ""); watch(searchText, (newValue) => { - emit('update:searchText', newValue); + emit("update:searchText", newValue); }); + const openNamingConstraintsManager = () => { + openSidePanel("right", { + component: NamingConstraintsManager, + props: { + project: currentProject.value, + }, + }); + }; + return { // References dropdown, @@ -255,11 +310,14 @@ export default { downloadFiles, hasAdminPerm, isFullTotal, + isProjectAdmin, isUserOrga, fileUploadInput, + openNamingConstraintsManager, // Responsive breakpoints isMD, isLG, + isXXL, }; }, }; diff --git a/src/components/specific/files/files-table/file-actions-cell/FileActionsCell.vue b/src/components/specific/files/files-table/file-actions-cell/FileActionsCell.vue index 132936db2..41f411753 100644 --- a/src/components/specific/files/files-table/file-actions-cell/FileActionsCell.vue +++ b/src/components/specific/files/files-table/file-actions-cell/FileActionsCell.vue @@ -14,27 +14,22 @@ - + @@ -50,7 +45,7 @@ import { isConvertibleToPhotosphere, isModel, isViewable, - openInViewer + openInViewer, } from "../../../../../utils/models.js"; import { dropdownPositioner } from "../../../../../utils/positioner.js"; // Components @@ -60,19 +55,19 @@ import SetAsModelIcon from "../../../../../components/images/SetAsModelIcon.vue" export default { props: { parent: { - type: Object + type: Object, }, project: { type: Object, - required: true + required: true, }, file: { type: Object, - required: true + required: true, }, loading: { type: Boolean, - required: true + required: true, }, }, emits: [ @@ -82,6 +77,7 @@ export default { "download", "file-clicked", "manage-access", + "manage-naming-rule", "open-tag-manager", "open-versioning-manager", "open-visa-manager", @@ -96,6 +92,8 @@ export default { const isOpen = ref(false); const menuItems = shallowRef([]); + let current_key = 0; + const openMenu = () => { if (!props.parent) return; @@ -103,7 +101,7 @@ export default { if (!isFolder(props.file)) { menuItems.value.push({ - key: 1, + key: current_key++, icon: "preview", text: "FileActionsCell.previewModelButtonText", color: "primary", @@ -114,7 +112,7 @@ export default { if (isViewable(props.file)) { const { model_id: id, model_type: type } = props.file; menuItems.value.push({ - key: 2, + key: current_key++, icon: "show", text: "FileActionsCell.openViewerButtonText", color: "primary", @@ -125,15 +123,15 @@ export default { if (!isFolder(props.file) && isConvertible(props.file)) { if (!isModel(props.file)) { menuItems.value.push({ - key: 3, + key: current_key++, iconComponent: SetAsModelIcon, text: "FileActionsCell.createModelButtonText", disabled: !hasAdminPerm(props.project, props.file), - action: () => onClick("create-model") + action: () => onClick("create-model"), }); } else { menuItems.value.push({ - key: 4, + key: current_key++, iconComponent: RemoveModelsIcon, text: "FileActionsCell.removeModelButtonText", action: () => onClick("remove-model"), @@ -143,16 +141,16 @@ export default { if (!isFolder(props.file) && isConvertibleToPhotosphere(props.file) && !isModel(props.file)) { menuItems.value.push({ - key: 3, + key: current_key++, iconComponent: SetAsModelIcon, text: "FileActionsCell.createPhotosphereButtonText", disabled: !hasAdminPerm(props.project, props.file), - action: () => onClick("create-photosphere") + action: () => onClick("create-photosphere"), }); } menuItems.value.push({ - key: 5, + key: current_key++, icon: "edit", text: "t.rename", disabled: !hasAdminPerm(props.project, props.file), @@ -168,7 +166,14 @@ export default { if (isFolder(props.file) && isProjectAdmin(props.project)) { menuItems.value.push({ - key: 7, + key: current_key++, + iconComponent: "BIMDataIconNamingConvention", + text: "NamingConstraint.folderRuleMenuItem", + action: () => onClick("manage-naming-rule"), + dataTestId: "btn-manage-naming-rule", + }); + menuItems.value.push({ + key: current_key++, icon: "key", text: "FileActionsCell.manageAccessButtonText", action: () => onClick("manage-access"), @@ -178,21 +183,21 @@ export default { if (!isFolder(props.file) && hasAdminPerm(props.project, props.file)) { menuItems.value.push({ - key: 8, + key: current_key++, icon: "visa", text: "FileActionsCell.visaButtonText", action: () => onClick("open-visa-manager"), dataTestId: "btn-open-visa-manager", }); menuItems.value.push({ - key: 9, + key: current_key++, icon: "tag", text: "FileActionsCell.addTagsButtonText", action: () => onClick("open-tag-manager"), dataTestId: "btn-open-tag-manager", }); menuItems.value.push({ - key: 10, + key: current_key++, icon: "versioning", text: "FileActionsCell.versioningButtonText", action: () => onClick("open-versioning-manager"), @@ -202,7 +207,7 @@ export default { } menuItems.value.push({ - key: 11, + key: current_key++, icon: "delete", text: "t.delete", color: "high", @@ -214,10 +219,7 @@ export default { nextTick(() => { if (props.parent) { - menu.value.$el.style.top = dropdownPositioner( - props.parent.$el, - menu.value.$el - ); + menu.value.$el.style.top = dropdownPositioner(props.parent.$el, menu.value.$el); } }); }; @@ -230,7 +232,7 @@ export default { }); }; - const onClick = event => { + const onClick = (event) => { closeMenu(); emit(event); }; @@ -242,9 +244,9 @@ export default { menuItems, // Methods closeMenu, - openMenu + openMenu, }; - } + }, }; diff --git a/src/components/specific/files/files-table/file-name-cell/FileNameCell.vue b/src/components/specific/files/files-table/file-name-cell/FileNameCell.vue index e74af99e5..9a71c991b 100644 --- a/src/components/specific/files/files-table/file-name-cell/FileNameCell.vue +++ b/src/components/specific/files/files-table/file-name-cell/FileNameCell.vue @@ -13,7 +13,7 @@ @keyup.esc.stop="closeUpdateForm" @keyup.enter.stop="renameFile" :error="hasError" - :errorMessage="$t('t.invalidName')" + :errorMessage="errorMessage" margin="0" /> + + +
props.file?.history_count > 0); + const isConflictFile = computed(() => { + return !isFolder(props.file) && props.file.naming_constraint_conflict; + }); + const renameFile = debounce(async () => { if (fileName.value) { + const rule = isFolder(props.file) + ? null + : await getEffectiveFolderRule(props.project, { + id: props.file.parent_id, + }); + if (rule?.rule && !matchName(fileName.value, rule.rule)) { + if (rule.strict) { + hasError.value = true; + errorMessage.value = t("t.invalidNameFormat", { + example: buildExample(rule.rule), + }); + nameInput.value.focus(); + return; + } + pushNotification({ + type: "warning", + title: t("NamingConstraint.applyRuleWarningTitle"), + message: t("t.invalidNameFormat", { + example: buildExample(rule.rule), + }), + }); + } try { loading.value = true; await updateFiles(props.project, [ @@ -119,6 +161,7 @@ export default { } } else { hasError.value = true; + errorMessage.value = t("t.invalidName"); nameInput.value.focus(); } }, 500); @@ -132,6 +175,7 @@ export default { const closeUpdateForm = () => { loading.value = false; hasError.value = false; + errorMessage.value = ""; showUpdateForm.value = false; emit("close"); }; @@ -162,9 +206,11 @@ export default { // References fileName, hasError, + errorMessage, loading, nameInput, showUpdateForm, + isConflictFile, // Methods closeUpdateForm, hasHistory, diff --git a/src/components/specific/files/files-table/file-type-cell/FileTypeCell.scss b/src/components/specific/files/files-table/file-type-cell/FileTypeCell.scss index 6d7ea54bc..88d65d07c 100644 --- a/src/components/specific/files/files-table/file-type-cell/FileTypeCell.scss +++ b/src/components/specific/files/files-table/file-type-cell/FileTypeCell.scss @@ -23,4 +23,18 @@ border: 1px solid var(--color-white); } } + .info { + height: 9px; + width: 9px; + position: absolute; + top: 0; + right: 12px; + .round { + width: 9px; + height: 9px; + border: 1px solid white; + background-color: var(--color-warning); + border-radius: 50%; + } + } } diff --git a/src/components/specific/files/files-table/file-type-cell/FileTypeCell.vue b/src/components/specific/files/files-table/file-type-cell/FileTypeCell.vue index 6beab4d7e..49cd50965 100644 --- a/src/components/specific/files/files-table/file-type-cell/FileTypeCell.vue +++ b/src/components/specific/files/files-table/file-type-cell/FileTypeCell.vue @@ -2,15 +2,23 @@
+
@@ -21,7 +29,7 @@ import { isFolder } from "../../../../../utils/file-structure.js"; const { hasAdminPerm } = useUser(); -defineProps({ +const props = defineProps({ file: { type: Object, required: true, diff --git a/src/components/specific/files/folder-table/FoldersTable.vue b/src/components/specific/files/folder-table/FoldersTable.vue index 46bb57989..b66714b80 100644 --- a/src/components/specific/files/folder-table/FoldersTable.vue +++ b/src/components/specific/files/folder-table/FoldersTable.vue @@ -96,6 +96,7 @@ @download="$emit('download', file)" @file-clicked="$emit('file-clicked', file)" @manage-access="$emit('manage-access', file)" + @manage-naming-rule="$emit('manage-naming-rule', file)" @open-tag-manager="$emit('open-tag-manager', file)" @open-versioning-manager="$emit('open-versioning-manager', file)" @open-visa-manager="$emit('open-visa-manager', file)" @@ -162,6 +163,10 @@ export default { type: Array, required: true, }, + hasNamingConflict: { + type: Function, + required: true, + }, }, emits: [ "back-parent-folder", @@ -172,6 +177,7 @@ export default { "file-clicked", "file-uploaded", "manage-access", + "manage-naming-rule", "open-tag-manager", "open-versioning-manager", "open-visa-manager", @@ -205,10 +211,14 @@ export default { return ext.replace(".", "").toUpperCase(); }; + const formattedFiles = computed(() => props.files.map((file) => ({ ...file, type: isFolder(file) ? t("t.folder") : file.name ? formatExtension(file.name) : t("t.file"), + hasNamingConflict: isFolder(file) + ? props.hasNamingConflict(file) + : file.naming_constraint_conflict, })), ); @@ -289,6 +299,7 @@ export default { formattedFiles, nameEditMode, // Methods + // folderHasConflict, cleanUpload, formatBytes, isFolder, diff --git a/src/components/specific/files/naming-constraint/FolderNamingConstraintManager.vue b/src/components/specific/files/naming-constraint/FolderNamingConstraintManager.vue new file mode 100644 index 000000000..44291e127 --- /dev/null +++ b/src/components/specific/files/naming-constraint/FolderNamingConstraintManager.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/src/components/specific/files/naming-constraint/NamingConflictModal.css b/src/components/specific/files/naming-constraint/NamingConflictModal.css new file mode 100644 index 000000000..926d39447 --- /dev/null +++ b/src/components/specific/files/naming-constraint/NamingConflictModal.css @@ -0,0 +1,91 @@ +.naming-conflict-modal { + .header { + .icon { + width: 40px; + min-width: 40px; + height: 40px; + border-radius: 50px; + display: flex; + justify-content: center; + align-items: center; + } + strong { + margin-bottom: 3px; + font-size: 22px; + } + span { + color: var(--color-granite); + } + } +} + +.naming-conflict-modal--strict { + .header { + .icon { + background-color: var(--color-high-lighter); + } + } +} + +.naming-conflict-modal--no-strict { + .header { + .icon { + background-color: var(--color-warning-lighter); + } + } +} + +.naming-conflict-modal__content { + display: flex; + flex-direction: column; + gap: 12px; + text-align: left; +} +.naming-conflict-modal__rule { + gap: 6px; + padding: 12px; + border-radius: 6px; + background: var(--color-primary-lighter); + border: 1px solid #dbe4f0; +} +.naming-conflict-modal__rule__name { + font-weight: 700; + font-size: 12px; + color: var(--color-primary); +} +.naming-conflict-modal__rule__chip { + width: fit-content; + padding: 3px 6px; + background-color: var(--color-neutral-lighter); + color: var(--color-neutral); + border-radius: 6px; + font-size: 12px; +} +.naming-conflict-modal__rule__preview { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 3px; + margin-top: 8px; +} + +.naming-conflict-modal__rule__separator, .naming-conflict-modal__rule__extension { + color: #64748b; + font-weight: 700; +} + +.naming-conflict-modal__intro, .naming-conflict-modal__warning { + margin: 0; + font-size: 14px; + color: var(--color-granite); +} +.naming-conflict-modal__warning { + padding: 12px 14px; + font-size: 14px; + line-height: 1.45; + font-weight: 600; + background-color: var(--color-high-lighter); + border: 1px solid #fecdd3; + color: #991b1b; + border-radius: 6px; +} \ No newline at end of file diff --git a/src/components/specific/files/naming-constraint/NamingConflictModal.vue b/src/components/specific/files/naming-constraint/NamingConflictModal.vue new file mode 100644 index 000000000..5bf07f84f --- /dev/null +++ b/src/components/specific/files/naming-constraint/NamingConflictModal.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/src/components/specific/files/naming-constraint/NamingConstraintManagerCommon.css b/src/components/specific/files/naming-constraint/NamingConstraintManagerCommon.css new file mode 100644 index 000000000..f1078b117 --- /dev/null +++ b/src/components/specific/files/naming-constraint/NamingConstraintManagerCommon.css @@ -0,0 +1,59 @@ +.naming-constraint-manager { + display: flex; + flex-direction: column; + height: 100%; + background-color: var(--color-white); +} + +.naming-constraint-manager__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 34px; +} + +.naming-constraint-manager__header__icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; +} + +/*.naming-constraint-manager__header__back { + margin-left: -6px; + +} */ + +.naming-constraint-manager__header__title { + flex-grow: 1; + font-size: 14px; + color: var(--color-primary); + text-align: center; +} + +.naming-constraint-manager__content { + position: relative; + flex: 1; + overflow-y: auto; +} + +.naming-constraint-manager__loader { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(255, 255, 255, 0.6); + z-index: 1; +} + +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.15s ease; +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} \ No newline at end of file diff --git a/src/components/specific/files/naming-constraint/NamingConstraintsManager.vue b/src/components/specific/files/naming-constraint/NamingConstraintsManager.vue new file mode 100644 index 000000000..8ff125f04 --- /dev/null +++ b/src/components/specific/files/naming-constraint/NamingConstraintsManager.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/src/components/specific/files/naming-constraint/conflicting-document-item/ConflictingDocumentItem.css b/src/components/specific/files/naming-constraint/conflicting-document-item/ConflictingDocumentItem.css new file mode 100644 index 000000000..e12a433c0 --- /dev/null +++ b/src/components/specific/files/naming-constraint/conflicting-document-item/ConflictingDocumentItem.css @@ -0,0 +1,95 @@ +.conflicting-document-item { + width: 100%; + border: 1px solid var(--color-silver); + border-radius: 8px; + /* overflow: hidden; */ + background: white; +} + +.conflicting-document-item__summary { + background: var(--color-silver-light); + padding: 12px; + cursor: pointer; + list-style: none; + border-radius: 8px; +} + +.conflicting-document-item__summary::-webkit-details-marker { + display: none; +} +.summary__actions { + gap: 12px; + justify-content: end; +} + +.summary__row { + gap: 12px; +} +.summary__column { + display: flex; + flex-direction: column; + flex: 1; +} + +.summary__label { + font-size: 9px; + text-transform: uppercase; + color: var(--color-primary); + margin-bottom: 4px; + font-weight: 600; +} + +.summary__value { + display: flex; + align-items: center; + gap: 8px; +} + +.summary__status { + font-size: 13px; + font-weight: 500; +} + +.summary__status--valid { + color: var(--color-success); +} + +.summary__status--invalid { + color: var(--color-high); +} + +.conflicting-document-item__content { + padding: 12px; + text-align: left; +} + +.conflicting-document-item__info { + .file-path { + font-size: 12px; + color: var(--color-granite); + span { + color: var(--color-primary); + font-weight: 600; + } + } +} + +.conflicting-document-item__example { + margin-top: 8px; + color: var(--color-granite); +} + +.conflicting-document-item__rename { + display: flex; + gap: 12px; + margin-bottom: 12px; + .bimdata-input { + width: 100%; + } +} + +.conflicting-document-item__actions { + display: flex; + justify-content: flex-end; + gap: 12px; +} \ No newline at end of file diff --git a/src/components/specific/files/naming-constraint/conflicting-document-item/ConflictingDocumentItem.vue b/src/components/specific/files/naming-constraint/conflicting-document-item/ConflictingDocumentItem.vue new file mode 100644 index 000000000..5edf57900 --- /dev/null +++ b/src/components/specific/files/naming-constraint/conflicting-document-item/ConflictingDocumentItem.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/src/components/specific/files/naming-constraint/conflicting-document-item/naming-constraint-file-editor/NamingConstraintFileEditor.css b/src/components/specific/files/naming-constraint/conflicting-document-item/naming-constraint-file-editor/NamingConstraintFileEditor.css new file mode 100644 index 000000000..fae063c7d --- /dev/null +++ b/src/components/specific/files/naming-constraint/conflicting-document-item/naming-constraint-file-editor/NamingConstraintFileEditor.css @@ -0,0 +1,20 @@ +.naming-constraint-file-editor { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px 12px; + flex: 1; + .naming-constraint-file-editor__field { + min-width: 0; + font-size: 12px; + display: flex; + flex-direction: column; + justify-content: start; + &:deep(.bimdata-select), &:deep(.bimdata-input) { + margin: 12px 0px 6px !important; + } + span { + font-size: 11px; + color: var(--color-granite-light); + } + } +} diff --git a/src/components/specific/files/naming-constraint/conflicting-document-item/naming-constraint-file-editor/NamingConstraintFileEditor.vue b/src/components/specific/files/naming-constraint/conflicting-document-item/naming-constraint-file-editor/NamingConstraintFileEditor.vue new file mode 100644 index 000000000..a7c305fd1 --- /dev/null +++ b/src/components/specific/files/naming-constraint/conflicting-document-item/naming-constraint-file-editor/NamingConstraintFileEditor.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/src/components/specific/files/naming-constraint/conflicting-documents-list/ConflictingDocumentsList.css b/src/components/specific/files/naming-constraint/conflicting-documents-list/ConflictingDocumentsList.css new file mode 100644 index 000000000..934fd2689 --- /dev/null +++ b/src/components/specific/files/naming-constraint/conflicting-documents-list/ConflictingDocumentsList.css @@ -0,0 +1,47 @@ +.conflicting-documents-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 320px; + overflow-y: auto; + margin: 0; + padding: 0; + list-style: none; +} +.conflicting-documents-list__item__name { + flex: 1; + font-size: 13px; + color: var(--color-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.conflicting-documents-list__item__info { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} +.conflicting-documents-list__item__example { + font-size: 11px; + color: var(--color-granite-light); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.conflicting-documents-list__item__path { + font-size: 11px; +} +.conflicting-documents-list__item__input { + flex: 1; +} +.conflicting-documents-list__item--editing { + background-color: var(--color-white); +} +.conflicting-documents-list__item--invalid .conflicting-documents-list__item__name { + color: var(--color-high); +} +.conflicting-documents-list__item--deleted .conflicting-documents-list__item__name { + color: var(--color-granite-light); + text-decoration: line-through; +} \ No newline at end of file diff --git a/src/components/specific/files/naming-constraint/conflicting-documents-list/ConflictingDocumentsList.vue b/src/components/specific/files/naming-constraint/conflicting-documents-list/ConflictingDocumentsList.vue new file mode 100644 index 000000000..b4cabbfe0 --- /dev/null +++ b/src/components/specific/files/naming-constraint/conflicting-documents-list/ConflictingDocumentsList.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/src/components/specific/files/naming-constraint/folder-naming-constraint-selector/FolderNamingConstraintSelector.css b/src/components/specific/files/naming-constraint/folder-naming-constraint-selector/FolderNamingConstraintSelector.css new file mode 100644 index 000000000..36a80bb05 --- /dev/null +++ b/src/components/specific/files/naming-constraint/folder-naming-constraint-selector/FolderNamingConstraintSelector.css @@ -0,0 +1,130 @@ +.folder-naming-constraint-selector { + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; +} +.folder-naming-constraint-selector__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.folder-naming-constraint-selector__head__title { + font-size: 16px; + color: var(--color-granite); + strong { + color: var(--color-primary); + } +} +.folder-naming-constraint-selector__empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 24px 12px; + text-align: center; +} +.folder-naming-constraint-selector__empty__title { + font-size: 16px; + font-weight: 600; + color: var(--color-primary); +} +.folder-naming-constraint-selector__empty__text { + max-width: 280px; + font-size: 13px; + line-height: 18px; + color: var(--color-granite); +} +.folder-naming-constraint-selector__head__actions { + gap: calc(var(--spacing-unit) / 2); + width: 100%; + .folder-naming-constraint-selector__search { + min-height: 32px; + } + .bimdata-btn { + flex: 1; + } +} +.folder-naming-constraint-selector__items { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 6px 6px 12px 6px; + list-style: none; + overflow-y: auto; +} +.folder-naming-constraint-selector__item { + display: flex; + align-items: center; + gap: 6px; + padding: var(--spacing-unit) calc(var(--spacing-unit) / 2); + border: 1px solid transparent; + border-radius: 6px; + background-color: var(--color-white); + border: 1px solid #eceff3; + cursor: pointer; +} +.folder-naming-constraint-selector__item--selected { + border-color: var(--color-primary); +} +.folder-naming-constraint-selector__item__main { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + flex-grow: 1; +} +.folder-naming-constraint-selector__item__name { + max-width: 75%; + flex: 1; + font-weight: 600; + color: var(--color-primary); +} +.folder-naming-constraint-selector__item__badges { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.folder-naming-constraint-selector__item__chip { + padding: 2px 6px; + border-radius: 6px; + background-color: #F0F5FF; + color: var(--color-neutral); + font-family: monospace; + font-size: 12px; +} +.folder-naming-constraint-selector__item__preview { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 3px; +} + +.folder-naming-constraint-selector__item__separator, .folder-naming-constraint-selector__item__extension { + color: #64748b; + font-weight: 700; +} + +.folder-naming-constraint-selector__item__chip--strict { + background-color: var(--color-secondary-lighter); + color: var(--color-warning); + font-family: inherit; +} +.folder-naming-constraint-selector__footer { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 8px; +} +.folder-naming-constraint-selector__footer__help { + font-size: 12px; + line-height: 17px; + color: var(--color-granite); +} +.folder-naming-constraint-selector__footer__save { + margin-top: 6px; +} \ No newline at end of file diff --git a/src/components/specific/files/naming-constraint/folder-naming-constraint-selector/FolderNamingConstraintSelector.vue b/src/components/specific/files/naming-constraint/folder-naming-constraint-selector/FolderNamingConstraintSelector.vue new file mode 100644 index 000000000..c0fb4efa0 --- /dev/null +++ b/src/components/specific/files/naming-constraint/folder-naming-constraint-selector/FolderNamingConstraintSelector.vue @@ -0,0 +1,341 @@ + + + + + diff --git a/src/components/specific/files/naming-constraint/naming-constraint-form/NamingConstraintForm.css b/src/components/specific/files/naming-constraint/naming-constraint-form/NamingConstraintForm.css new file mode 100644 index 000000000..ed6fa0f4d --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraint-form/NamingConstraintForm.css @@ -0,0 +1,97 @@ +.naming-constraint-form { + height: calc(100% - 18px); + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 12px; +} +.naming-constraint-form__body { + height: calc(100% - 32px - 12px); + overflow: auto; +} +.naming-constraint-form__title { + font-size: 16px; + font-weight: 600; + text-align: center; + color: var(--color-primary); +} +.naming-constraint-form__content { + height: calc(100% - 22px - 12px); + overflow: auto; + display: flex; + flex-direction: column; + gap: 12px; + padding: 0 6px 6px; + .naming-constraint-form__step { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + border-radius: 8px; + background-color: var(--color-silver-light); + } + .naming-constraint-form__step__head { + display: flex; + align-items: center; + gap: 8px; + } + .naming-constraint-form__step__num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + background-color: var(--color-primary); + color: var(--color-white); + font-size: 12px; + } + .naming-constraint-form__step__label { + font-weight: 600; + color: var(--color-primary); + } + .naming-constraint-form__radios { + display: flex; + gap: var(--spacing-unit); + .bimdata-radio { + flex: auto; + } + } + .naming-constraint-form__strict { + display: flex; + flex-direction: column; + gap: 6px; + } + .naming-constraint-form__help { + font-size: 12px; + line-height: 17px; + color: var(--color-granite); + } + .naming-constraint-form__error { + font-size: 12px; + color: var(--color-high); + } + .naming-constraint-form__preview { + gap: 6px; + padding: 8px 12px; + border-radius: 6px; + background-color: var(--color-neutral-lighter); + } + .naming-constraint-form__preview__label { + font-size: 12px; + color: var(--color-granite); + } + .naming-constraint-form__preview__value { + font-family: monospace; + font-size: 13px; + color: var(--color-neutral); + } +} +.naming-constraint-form__actions { + display: flex; + justify-content: flex-end; + gap: 12px; + .bimdata-btn { + flex: auto; + } +} \ No newline at end of file diff --git a/src/components/specific/files/naming-constraint/naming-constraint-form/NamingConstraintForm.vue b/src/components/specific/files/naming-constraint/naming-constraint-form/NamingConstraintForm.vue new file mode 100644 index 000000000..2848a3c68 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraint-form/NamingConstraintForm.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/src/components/specific/files/naming-constraint/naming-constraint-form/RuleBuilder.css b/src/components/specific/files/naming-constraint/naming-constraint-form/RuleBuilder.css new file mode 100644 index 000000000..22567381d --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraint-form/RuleBuilder.css @@ -0,0 +1,186 @@ +.rule-builder { + display: flex; + flex-direction: column; + gap: 10px; +} +.rule-builder__empty-text { + margin: 0; + color: var(--color-granite); + font-size: 13px; +} +.rule-builder__add-label { + margin: 0; + color: var(--color-granite-light); + font-size: 10px; +} +.rule-builder__add-buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; + .rule-builder__add-button { + flex: auto; + } +} + +.rule-builder__parts { + position: relative; + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; +} + +.rule-builder__part { + position: relative; + border-radius: 6px; + background-color: var(--color-white); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); + transition: box-shadow 0.15s ease, opacity 0.15s ease; + + &.rule-builder__part--dragging { + opacity: 0.4; + } + + &.rule-builder__part--drag-over-top::before, + &.rule-builder__part--drag-over-bottom::after { + content: ""; + position: absolute; + left: 12px; + right: 12px; + height: 1px; + background-color: var(--color-primary); + border-radius: 1px; + } + &.rule-builder__part--drag-over-top::before { + top: 0; + } + &.rule-builder__part--drag-over-bottom::after { + bottom: 0; + } + + .rule-builder__part__main { + padding: 6px; + gap: 3px; + } + + .rule-builder__part__grip { + cursor: grab; + color: var(--color-granite-light); + display: flex; + align-items: center; + } + .rule-builder__part__grip:active { + cursor: grabbing; + } + + .rule-builder__part__type { + flex-shrink: 0; + min-width: 90px; + font-size: 12px; + color: var(--color-primary); + } + + .rule-builder__part__value { + flex: 1 1 auto; + width: 140px; + min-width: 140px; + display: flex; + align-items: center; + gap: 6px; + } + + .rule-builder__part__actions { + width: 64px; + display: flex; + align-items: center; + justify-content: end; + } +} + +/* --- Champs --- */ +.rule-builder__number { + flex: 1; + height: 36px; + min-width: 0; + padding: 0 8px; + border: 1px solid var(--color-silver); + border-radius: 6px; + background-color: var(--color-white); + color: var(--color-primary); + font-size: 13px; + transition: all 0.15s ease; +} +.rule-builder__number:hover { + border-color: var(--color-silver-dark); + transition: all 0.15s ease; +} +.rule-builder__number:focus { + outline: none; +} + +.rule-builder__dash { + flex-shrink: 0; + color: var(--color-granite); +} + +/* --- Select --- */ +.rule-builder__select { + flex: 1; + &:deep(.bimdata-dropdown__content) { + padding: 0 6px; + font-size: 11px; + svg { + min-height: 11px!important; + height: 11px!important; + width: 11px!important; + min-width: 11px!important; + } + } +} +.rule-builder__select:hover { + border-color: var(--color-silver-dark); + transition: all 0.15s ease; +} +.rule-builder__select:focus { + outline: none; +} + +/* --- Bouton "créer une liste" --- */ +.rule-builder__create-list-btn--active { + background-color: var(--color-primary); + color: var(--color-white); + &:hover { + background-color: var(--color-primary-light); + } +} + +/* --- Panneau de création inline --- */ +.rule-builder__create-panel { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0 14px 12px 14px; + padding: 10px 12px; + border-radius: 6px; + background-color: var(--color-silver-light, #f5f6f8); +} +.rule-builder__create-panel__input { + height: 34px; + padding: 0 10px; + border: 1px solid var(--color-silver); + border-radius: 6px; + background-color: var(--color-white); + font-size: 13px; + color: var(--color-primary); +} +.rule-builder__create-panel__input:focus { + outline: none; + border-color: var(--color-primary); +} +.rule-builder__create-panel__actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} \ No newline at end of file diff --git a/src/components/specific/files/naming-constraint/naming-constraint-form/RuleBuilder.vue b/src/components/specific/files/naming-constraint/naming-constraint-form/RuleBuilder.vue new file mode 100644 index 000000000..787034188 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraint-form/RuleBuilder.vue @@ -0,0 +1,363 @@ + + + + + diff --git a/src/components/specific/files/naming-constraint/naming-constraint-preview/NamingConstraintPreview.css b/src/components/specific/files/naming-constraint/naming-constraint-preview/NamingConstraintPreview.css new file mode 100644 index 000000000..8e6919039 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraint-preview/NamingConstraintPreview.css @@ -0,0 +1,20 @@ +.naming-constraint-preview { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 3px; +} + +.naming-constraint-preview__chip { + padding: 2px 6px; + border-radius: 6px; + background-color: #F0F5FF; + color: var(--color-neutral); + font-family: monospace; + font-size: 12px; +} + +.naming-constraint-preview__separator, .naming-constraint-preview__extension { + color: #64748b; + font-weight: 700; +} diff --git a/src/components/specific/files/naming-constraint/naming-constraint-preview/NamingConstraintPreview.vue b/src/components/specific/files/naming-constraint/naming-constraint-preview/NamingConstraintPreview.vue new file mode 100644 index 000000000..a4fa31d5e --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraint-preview/NamingConstraintPreview.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/src/components/specific/files/naming-constraint/naming-constraints-list/NamingConstraintsList.css b/src/components/specific/files/naming-constraint/naming-constraints-list/NamingConstraintsList.css new file mode 100644 index 000000000..0177984f0 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraints-list/NamingConstraintsList.css @@ -0,0 +1,133 @@ +.naming-constraints-list { + height: 100%; + display: flex; + flex-direction: column; + gap: 12px; +} +.naming-constraints-list__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.naming-constraints-list__head__title { + font-size: 16px; + font-weight: 500; + color: var(--color-primary); +} +.naming-constraints-list__head__actions { + gap: var(--spacing-unit); + width: 100%; + .bimdata-btn { + flex: 1; + } +} +.naming-constraints-list__empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 24px 12px; + text-align: center; +} +.naming-constraints-list__empty__title { + font-size: 16px; + font-weight: 600; + color: var(--color-primary); +} +.naming-constraints-list__empty__text { + max-width: 280px; + font-size: 13px; + line-height: 18px; + color: var(--color-granite); +} +.naming-constraints-list__search { + min-height: 32px; +} +.naming-constraints-list__items { + height: 100%; + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 6px; + list-style: none; + overflow-y: auto; +} +.naming-constraints-list__item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 6px; + background-color: var(--color-white); + box-shadow: var(--box-shadow); +} +.naming-constraints-list__item__main { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + flex-grow: 1; +} +.naming-constraints-list__item__name { + .bimdata-textbox { + color: var(--color-primary); + font-weight: 600; + } +} +.naming-constraints-list__item__badges { + flex-wrap: wrap; + gap: 6px; +} +.naming-constraints-list__item__avatar { + padding: 2px 4px; + border-radius: 6px; + background-color: #F0F5FF; + color: var(--color-neutral); + font-size: 12px; + &:deep(.user-avatar ) { + min-width: 16px; + min-height: 16px; + } +} +.naming-constraints-list__item__preview { + gap: 6px; + flex-wrap: wrap; +} +.naming-constraints-list__item__chip { + padding: 2px 8px; + border-radius: 6px; + background-color: #F0F5FF; + color: var(--color-neutral); + font-family: monospace; + font-size: 12px; +} +.naming-constraints-list__item__separator, .naming-constraints-list__item__extension { + color: #64748b; + font-weight: 600; +} + +.naming-constraints-list__item__chip--strict { + background-color: var(--color-secondary-lighter); + color: var(--color-warning); + font-family: inherit; +} +.naming-constraints-list__item__actions { + display: flex; + align-items: center; + flex-shrink: 0; + .delete-actions { + position: absolute; + right: 9px; + background-color: var(--color-white); + gap: 3px; + z-index: 1; + } +} +.naming-constraints-list__manage { + margin-top: auto; + align-self: flex-start; +} \ No newline at end of file diff --git a/src/components/specific/files/naming-constraint/naming-constraints-list/NamingConstraintsList.vue b/src/components/specific/files/naming-constraint/naming-constraints-list/NamingConstraintsList.vue new file mode 100644 index 000000000..c59228916 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraints-list/NamingConstraintsList.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/src/components/specific/files/naming-constraint/naming-parts-template-form/NamingPartsTemplateForm.css b/src/components/specific/files/naming-constraint/naming-parts-template-form/NamingPartsTemplateForm.css new file mode 100644 index 000000000..6c01f1339 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-parts-template-form/NamingPartsTemplateForm.css @@ -0,0 +1,97 @@ +.naming-parts-template-form__container { + height: 100%; +} +.naming-parts-template-form { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 0; +} +.naming-parts-template-form__header { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} +.naming-parts-template-form__header__title { + font-size: 16px; + font-weight: 600; + color: var(--color-primary); +} +.naming-parts-template-form__step { + width: 100%; + display: flex; + flex-direction: column; + gap: 18px; + padding: 14px; + border-radius: 8px; + background-color: var(--color-silver-light); +} +.naming-parts-template-form__step__head { + display: flex; + align-items: center; + gap: 8px; +} +.naming-parts-template-form__step__num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + background-color: var(--color-primary); + color: var(--color-white); + font-size: 12px; +} +.naming-parts-template-form__step__label { + font-weight: 600; + color: var(--color-primary); +} +.naming-parts-template-form__element { + display: flex; + align-items: flex-end; + gap: 12px; + .bimdata-input { + flex: 1; + } +} +.naming-parts-template-form__element__label { + font-size: 14px; + font-weight: 700; + color: var(--color-primary); +} +.naming-parts-template-form__bulk-import { + .naming-parts-template-form__bulk-import__toggle { + font-size: 14px; + font-weight: 700; + color: var(--color-primary); + cursor: pointer; + } + .naming-parts-template-form__bulk-import__hint { + color: var(--color-granite-light); + font-size: 11px; + } + &:deep(.bimdata-textarea) { + margin-bottom: 0; + textarea { + padding: 6px; + font-size: 12px; + } + } +} + +.naming-parts-template-form__add { + align-self: flex-start; +} +.naming-parts-template-form__error { + font-size: 12px; + color: var(--color-high); +} +.naming-parts-template-form__actions { + display: flex; + justify-content: flex-end; + gap: 12px; + .bimdata-btn { + flex: 1; + } +} \ No newline at end of file diff --git a/src/components/specific/files/naming-constraint/naming-parts-template-form/NamingPartsTemplateForm.vue b/src/components/specific/files/naming-constraint/naming-parts-template-form/NamingPartsTemplateForm.vue new file mode 100644 index 000000000..e733925db --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-parts-template-form/NamingPartsTemplateForm.vue @@ -0,0 +1,300 @@ + + + + + diff --git a/src/components/specific/files/naming-constraint/naming-parts-templates-list/NamingPartsTemplatesList.css b/src/components/specific/files/naming-constraint/naming-parts-templates-list/NamingPartsTemplatesList.css new file mode 100644 index 000000000..ecbf00374 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-parts-templates-list/NamingPartsTemplatesList.css @@ -0,0 +1,92 @@ +.naming-parts-templates-list { + height: 100%; + display: flex; + flex-direction: column; + gap: 12px; +} +.naming-parts-templates-list__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + .naming-parts-templates-list__head__title { + font-size: 16px; + font-weight: 500; + color: var(--color-primary); + } +} +.naming-parts-templates-list__search { + min-height: 32px; +} +.naming-parts-templates-list__empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 24px 12px; + text-align: center; + .naming-parts-templates-list__empty__title { + font-size: 16px; + font-weight: 600; + color: var(--color-primary); + } + .naming-parts-templates-list__empty__text { + max-width: 280px; + font-size: 13px; + line-height: 18px; + color: var(--color-granite); + } +} +.naming-parts-templates-list__items { + height: 100%; + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 6px; + list-style: none; + overflow: auto; +} +.naming-parts-templates-list__item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 6px; + background-color: var(--color-white); + box-shadow: var(--box-shadow); +} +.naming-parts-templates-list__item__main { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + flex-grow: 1; +} +.naming-parts-templates-list__item__name { + font-weight: 600; + color: var(--color-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.naming-parts-templates-list__item__badges { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.naming-parts-templates-list__item__chip { + padding: 2px 8px; + border-radius: 6px; + background-color: var(--color-neutral-lighter); + color: var(--color-neutral); + font-size: 12px; +} +.naming-parts-templates-list__item__actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} \ No newline at end of file diff --git a/src/components/specific/files/naming-constraint/naming-parts-templates-list/NamingPartsTemplatesList.vue b/src/components/specific/files/naming-constraint/naming-parts-templates-list/NamingPartsTemplatesList.vue new file mode 100644 index 000000000..3c123076f --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-parts-templates-list/NamingPartsTemplatesList.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/src/i18n/lang/de.json b/src/i18n/lang/de.json index 18fc239e7..8fc9962fc 100644 --- a/src/i18n/lang/de.json +++ b/src/i18n/lang/de.json @@ -1,4 +1,30 @@ { + "NamingConstraint": { + "managerTitle": "", + "constraintsTab": "", + "strictBadge": "", + "createConstraintTitle": "", + "updateConstraintTitle": "", + "createTemplateTitle": "", + "updateTemplateTitle": "", + "nameLabel": "", + "strictLabel": "", + "strictHelp": "", + "previewLabel": "", + "ruleSectionTitle": "", + "addPartButton": "", + "emptyParts": "", + "partTypeLabel": "", + "partTypeValuesIn": "", + "partTypeBounded": "", + "partTypeNChars": "", + "elementsHelp": "", + "minLabel": "", + "maxLabel": "", + "maxLengthLabel": "", + "emptyElementsError": "", + "invalidBoundsError": "" + }, "OidcCallbackError": { "message": "Bei der Authentifizierung ist ein Fehler aufgetreten...", "retryButtonText": "Nochmals versuchen" @@ -440,6 +466,19 @@ "invitationViewAcceptError": "Die Einladung kann nicht angenommen werden", "folderFetchFolder": "Fehler beim Abrufen von Ordnerinformationen.", "bcfDeleteError": "Das Löschen ist fehlgeschlagen...", + "namingConstraintsFetchError": "", + "namingConstraintFetchError": "", + "namingConstraintCreateError": "", + "namingConstraintUpdateError": "", + "namingConstraintDeleteError": "", + "namingPartsTemplatesFetchError": "", + "namingPartsTemplateCreateError": "", + "namingPartsTemplateUpdateError": "", + "namingPartsTemplateDeleteError": "", + "folderNamingConstraintFetchError": "", + "folderNamingConstraintSetError": "", + "folderNamingConstraintDeleteError": "", + "conflictingDocumentsFetchError": "", "groupImportError": "Fehler beim Importieren von Gruppen", "bcfExportXlsxError": "Der Excel-Export ist fehlgeschlagen...", "invitationViewDeclineError": "Einladung kann nicht abgelehnt werden" @@ -971,4 +1010,4 @@ "title": "Löschen der {visasCount} Freigaben", "message": "Sie sind dabei, die Freigaben für die folgenden Dateien zu löschen:" } -} \ No newline at end of file +} diff --git a/src/i18n/lang/en.json b/src/i18n/lang/en.json index 9635601d3..370e86195 100644 --- a/src/i18n/lang/en.json +++ b/src/i18n/lang/en.json @@ -1,4 +1,96 @@ { + "NamingConstraint": { + "managerTitle": "Naming convention management", + "constraintsTab": "Naming rules", + "rulesSectionTitle": "Rules list", + "addRuleButton": "add a rule", + "searchPlaceholder": "Search", + "rulesEmptyTitle": "No rule added", + "rulesEmptyText": "You don't have any rule yet, start by adding one.", + "manageListsButton": "Manage lists", + "listsSectionTitle": "Lists management", + "createListButton": "Create a list", + "listsEmptyTitle": "No list added", + "listsEmptyText": "You don't have any list yet, start by creating one.", + "strictBadge": "Strict", + "createConstraintTitle": "Creating a rule", + "updateConstraintTitle": "Editing a rule", + "createTemplateTitle": "Creating my list", + "updateTemplateTitle": "Editing my list", + "nameLabel": "Name", + "ruleNameStep": "Rule name", + "ruleNamePlaceholder": "Rule name", + "separatorStep": "Separator type", + "separatorDashOption": "- (dash)", + "separatorDotOption": ". (dot)", + "separatorUnderscoreOption": "_ (underscore)", + "structureSectionTitle": "Rule structure", + "strictLabel": "Strict rule", + "strictHelp": "If you check this box, you will have to rename every file that does not match this new rule.", + "previewLabel": "Preview", + "ruleSectionTitle": "Rule", + "addPartButton": "Add an element", + "emptyParts": "Add at least one element to define the rule.", + "partTypeLabel": "Type", + "partTypeValuesIn": "List", + "partTypeBounded": "Bounded values", + "partTypeNChars": "N characters", + "selectListPlaceholder": "Select a list", + "elementsHelp": "Comma-separated list of allowed values.", + "minLabel": "Min", + "maxLabel": "Max", + "maxLengthLabel": "Max length", + "saveRuleButton": "Save the rule", + "listNameStep": "List name", + "listNamePlaceholder": "List name", + "addElementsStep": "Add elements", + "addOneElementLabel": "Add an element", + "elementNamePlaceholder": "Element name", + "removeElementButton": "Remove", + "addElementButton": "Add an element", + "bulkImportLabel": "Add multiple elements", + "bulkImportPlaceholder": "e.g.: PLAN, VISA, DOE", + "bulkImportHint": "Enter your elements separated by commas", + "importButton": "Generate the list", + "saveListButton": "Save the list", + "emptyElementsError": "Provide at least one value.", + "invalidBoundsError": "Max must be greater than or equal to min.", + "folderRuleMenuItem": "Naming convention", + "recursiveLabel": "Recursive rule", + "recursiveHelp": "If you check this box, this rule will also apply to files contained in the subfolders of your main folder.", + "applyRuleSuccessTitle": "Naming rule applied", + "applyRuleSuccessMessage": "The naming convention has been applied to the folder.", + "applyRuleError": "The naming convention could not be applied.", + "applyRuleWarningTitle": "Name does not match the rule", + "conflictTooltip": "This file does not match the folder naming convention.", + "conflictModalIntro": "Rename here the files that do not match the strict rule you set.", + "conflictModalWarning": "Rename or delete the conflicting documents to continue", + "conflictCurrentNameLabel": "Current name", + "conflictNewNameLabel": "New name", + "conflictStatusDeleted": "Deleted", + "conflictStatusValid": "Compliant", + "conflictStatusInvalid": "Non-compliant", + "conflictSourceFolderLabel": "Source folder:", + "conflictDeleteFileButton": "Delete file", + "conflictRestoreFileButton": "Restore", + "maxCharsHint": "Maximum {count} characters", + "renameButton": "Rename", + "renameFilePlaceholder": "Rename the file", + "renameConflictsMenuItem": "Rename conflicting files", + "noConflictsTitle": "No conflicting files", + "noConflictsMessage": "All files match their folder naming convention.", + "modal": { + "noStrictTitle": "Rename non-compliant files", + "strictTitle": "Rename non-compliant files", + "strictDescription": "This folder enforces a strict naming convention. Upload cannot be completed until the files below match the expected rule.", + "noStrictDescription": "Build the new name from allowed lists. Free-text fields are validated and preview is generated automatically.", + "expectedConventionLabel": "Expected convention in this folder" + }, + "safeZoneModal": { + "title": "Warning!", + "text": "The naming rule has been modified but not saved. Are you sure you want to close the rule manager? (Changes will be lost)" + } + }, "OidcCallbackError": { "message": "An error occured during authentication...", "retryButtonText": "Try Again" @@ -510,6 +602,19 @@ "invitationViewAcceptError": "Unable to accept the invitation", "folderFetchFolder": "Fail to retrieve folder data", "bcfDeleteError": "Deletion failed", + "namingConstraintsFetchError": "Unable to retrieve naming rules", + "namingConstraintFetchError": "Unable to retrieve this naming rule", + "namingConstraintCreateError": "Unable to create this naming rule", + "namingConstraintUpdateError": "Unable to update this naming rule", + "namingConstraintDeleteError": "Unable to delete this naming rule", + "namingPartsTemplatesFetchError": "Unable to retrieve naming templates", + "namingPartsTemplateCreateError": "Unable to create this naming template", + "namingPartsTemplateUpdateError": "Unable to update this naming template", + "namingPartsTemplateDeleteError": "Unable to delete this naming template", + "folderNamingConstraintFetchError": "Unable to retrieve the folder naming rule", + "folderNamingConstraintSetError": "Unable to apply the naming rule on this folder", + "folderNamingConstraintDeleteError": "Unable to remove the naming rule from this folder", + "conflictingDocumentsFetchError": "Unable to retrieve conflicting documents", "groupImportError": "Group import failure", "bcfExportXlsxError": "xlsx export failed...", "invitationViewDeclineError": "Unable to decline invitation" @@ -964,6 +1069,7 @@ "back": "Back", "cancel": "Cancel", "change": "Change", + "clear": "Clear", "comment": "Comment", "confirm": "Confirm", "create": "Create", @@ -982,6 +1088,7 @@ "folder": "Folder", "import": "Import", "invalidName": "Invalid name", + "invalidNameFormat": "Name does not match the naming rule (e.g. {example}).", "leave": "Leave", "modifiedOn": "Last modified", "modify": "Edit", diff --git a/src/i18n/lang/es.json b/src/i18n/lang/es.json index 01fb515f9..ba016dc71 100644 --- a/src/i18n/lang/es.json +++ b/src/i18n/lang/es.json @@ -1,4 +1,30 @@ { + "NamingConstraint": { + "managerTitle": "", + "constraintsTab": "", + "strictBadge": "", + "createConstraintTitle": "", + "updateConstraintTitle": "", + "createTemplateTitle": "", + "updateTemplateTitle": "", + "nameLabel": "", + "strictLabel": "", + "strictHelp": "", + "previewLabel": "", + "ruleSectionTitle": "", + "addPartButton": "", + "emptyParts": "", + "partTypeLabel": "", + "partTypeValuesIn": "", + "partTypeBounded": "", + "partTypeNChars": "", + "elementsHelp": "", + "minLabel": "", + "maxLabel": "", + "maxLengthLabel": "", + "emptyElementsError": "", + "invalidBoundsError": "" + }, "OidcCallbackError": { "message": "Ha ocurrido un error de autenticación...", "retryButtonText": "Intente de nuevo" @@ -440,6 +466,19 @@ "invitationViewAcceptError": "No se puede aceptar la invitación", "folderFetchFolder": "Error al recuperar la información de la carpeta", "bcfDeleteError": "Eliminación fallida...", + "namingConstraintsFetchError": "", + "namingConstraintFetchError": "", + "namingConstraintCreateError": "", + "namingConstraintUpdateError": "", + "namingConstraintDeleteError": "", + "namingPartsTemplatesFetchError": "", + "namingPartsTemplateCreateError": "", + "namingPartsTemplateUpdateError": "", + "namingPartsTemplateDeleteError": "", + "folderNamingConstraintFetchError": "", + "folderNamingConstraintSetError": "", + "folderNamingConstraintDeleteError": "", + "conflictingDocumentsFetchError": "", "groupImportError": "Error al importar grupos", "bcfExportXlsxError": "La exportación a Excél ha fracasado", "invitationViewDeclineError": "No se permite rechazar la invitación" @@ -971,4 +1010,4 @@ "title": "Eliminación de {visasCount} visas", "message": "Está a punto de eliminar las visas de los siguientes archivos:" } -} \ No newline at end of file +} diff --git a/src/i18n/lang/fr.json b/src/i18n/lang/fr.json index 6804246bc..0c9d867a7 100644 --- a/src/i18n/lang/fr.json +++ b/src/i18n/lang/fr.json @@ -7,6 +7,7 @@ "back": "Retour", "cancel": "Annuler", "change": "Changer", + "clear": "Effacer", "comment": "Commenter", "confirm": "Confirmer", "create": "Créer", @@ -31,6 +32,7 @@ "hours_ago": "il y a {count} heure | il y a {count} heures", "import": "Importer", "invalidName": "Nom invalide", + "invalidNameFormat": "Le nom ne respecte pas la convention de nommage (ex. {example}).", "just_now": "À l'instant", "leave": "Quitter", "location": "Emplacement", @@ -233,6 +235,107 @@ } } }, + "NamingConstraint": { + "managerTitle": "Conventions de nommage", + "constraintsTab": "Gestion des règles", + "listsTab": "Gestion des listes", + "rulesSectionTitle": "Toutes les règles du projet", + "addRuleButton": "ajouter une règle", + "searchConstraintPlaceholder": "Rechercher une règle", + "searchListPlaceholder": "Rechercher une liste", + "rulesEmptyTitle": "Pas de règle ajoutée", + "rulesEmptyText": "Vous n'avez aucune règle pour l'instant, commencez par en ajouter une.", + "manageListsButton": "Gérer les listes", + "listsSectionTitle": "Toutes les listes du projet", + "createListButton": "Créer une liste", + "listsEmptyTitle": "Pas de liste ajoutée", + "listsEmptyText": "Vous n'avez aucune liste pour l'instant, commencez par en créer une.", + "strictBadge": "Stricte", + "createConstraintTitle": "Création d'une règle", + "updateConstraintTitle": "Modification d'une règle", + "createTemplateTitle": "Création de ma liste", + "updateTemplateTitle": "Modification de ma liste", + "nameLabel": "Nom", + "ruleNameStep": "Nom de la règle", + "ruleNamePlaceholder": "Nom de la règle", + "separatorStep": "Type de séparateur", + "separatorDashOption": "- (tiret)", + "separatorDotOption": ". (point)", + "separatorUnderscoreOption": "_ (trait de soulignement)", + "structureSectionTitle": "Structure de la règle", + "strictLabel": "Règle stricte", + "strictHelp": "Si vous cochez cette case, vous devrez changer le nom de l'intégralité des fichiers qui ne correspondent pas à cette nouvelle règle.", + "previewLabel": "Aperçu :", + "ruleSectionTitle": "Règle", + "emptyStateHelp": "Ajoutez votre premier élément, pour commencer à établir votre règle.", + "addPartButton": "Ajouter un élément :", + "emptyParts": "Ajoutez au moins un élément pour définir la règle.", + "partTypeLabel": "Type", + "partTypeValuesIn": "Liste", + "partTypeBounded": "Valeurs bornées", + "partTypeNChars": "N caractères", + "selectListPlaceholder": "Sélectionner une liste", + "elementsHelp": "Liste de valeurs autorisées séparées par des virgules.", + "minLabel": "Min", + "maxLabel": "Max", + "maxLengthLabel": "Longueur max", + "saveRuleButton": "Enregistrer la règle", + "listNameStep": "Nom de la liste", + "listNamePlaceholder": "Nom de la liste", + "addElementsStep": "Ajouter des éléments", + "addOneElementLabel": "Ajouter un élément", + "elementNamePlaceholder": "Nom de l'élément", + "removeElementButton": "Effacer", + "addElementButton": "Ajouter un élément", + "bulkImportLabel": "Ajouter plusieurs éléments", + "bulkImportPlaceholder": "Ex : PLAN, VISA, DOE", + "bulkImportHint": "Entrez vos éléments séparés par des virgules", + "importButton": "Générer la liste", + "saveListButton": "Enregistrer la liste", + "emptyElementsError": "Indiquez au moins une valeur.", + "invalidBoundsError": "Le max doit être supérieur ou égal au min.", + "folderRuleMenuItem": "Convention de nommage", + "recursiveLabel": "Règle récursive", + "recursiveHelp": "Si vous cochez cette case, cette règle s'appliquera également aux fichiers contenus dans les sous-dossiers de votre dossier principal.", + "applyRuleSuccessTitle": "Règle de nommage appliquée", + "applyRuleSuccessMessage": "La convention de nommage a été appliquée au dossier.", + "applyRuleError": "La convention de nommage n'a pas pu être appliquée.", + "applyRuleWarningTitle": "Le nom ne respecte pas la règle", + "conflictTooltip": "Ce fichier ne respecte pas la convention de nommage du dossier.", + "conflictModalWarning": "Ce dossier applique une convention de nommage stricte. Les fichiers ci-dessous doivent être renommés ou supprimés avant de pouvoir la sauvegarder.", + "conflictCurrentNameLabel": "Nom actuel", + "conflictNewNameLabel": "Nouveau nom", + "conflictStatusDeleted": "Supprimé", + "conflictStatusValid": "Conforme", + "conflictStatusInvalid": "Non conforme", + "conflictSourceFolderLabel": "Dossier source :", + "conflictDeleteFileButton": "Supprimer le fichier", + "conflictRestoreFileButton": "Restaurer", + "maxCharsHint": "Maximum {count} caractères", + "renameButton": "Renommer", + "renameConflictsMenuItem": "Renommer les fichiers en conflit", + "noConflictsTitle": "Aucun fichier en conflit", + "noConflictsMessage": "Tous les fichiers respectent la convention de nommage de leur dossier.", + "noRuleOption": "Ne pas appliquer de règle", + "searchPlaceholder": "Rechercher une règle", + "removeRuleError": "La règle de nommage n'a pas pu être supprimée.", + "removeRuleSuccessTitle": "Règle de nommage supprimée", + "removeRuleSuccessMessage": "La règle de nommage a été supprimée avec succès.", + "modal": { + "noStrictTitle": "Renommer les {count} fichiers non conformes", + "strictTitle": "Renommer les {count} fichiers non conformes", + "strictDescription": "Ce dossier applique une convention de nommage stricte. L'upload ne peut pas être finalisé tant que les fichiers ci-dessous ne respectent pas la règle attendue.", + "noStrictDescription": "Construisez le nouveau nom à partir des listes autorisées. Les champs libres sont contrôlés et l'aperçu est généré automatiquement.", + "expectedConventionLabel": "Convention attendue dans ce dossier" + }, + "safeZoneModal": { + "title": "Attention !", + "text": "La règle de nommage a été modifiée mais n'a pas été enregistrée, êtes-vous sûr de vouloir fermer le gestionnaire de règles ? (les modifications seront perdues)" + } + }, + "FolderNamingConstraint": { + "ruleSectionTitle": "Règles appliquées au dossier : " + }, "LanguageSelector": { "title": "Choix de la langue" }, @@ -612,6 +715,19 @@ "bcfExportError": "L'export BCF échoué...", "bcfExportXlsxError": "L'export Excel a échoué...", "bcfDeleteError": "La suppression a échouée...", + "namingConstraintsFetchError": "Impossible de récupérer les règles de nommage", + "namingConstraintFetchError": "Impossible de récupérer cette règle de nommage", + "namingConstraintCreateError": "Impossible de créer cette règle de nommage", + "namingConstraintUpdateError": "Impossible de modifier cette règle de nommage", + "namingConstraintDeleteError": "Impossible de supprimer cette règle de nommage", + "namingPartsTemplatesFetchError": "Impossible de récupérer les modèles de nommage", + "namingPartsTemplateCreateError": "Impossible de créer ce modèle de nommage", + "namingPartsTemplateUpdateError": "Impossible de modifier ce modèle de nommage", + "namingPartsTemplateDeleteError": "Impossible de supprimer ce modèle de nommage", + "folderNamingConstraintFetchError": "Impossible de récupérer la règle de nommage du dossier", + "folderNamingConstraintSetError": "Impossible d'appliquer la règle de nommage sur ce dossier", + "folderNamingConstraintDeleteError": "Impossible de retirer la règle de nommage de ce dossier", + "conflictingDocumentsFetchError": "Impossible de récupérer les documents en conflit", "fileVersionsFetchError": "Impossible de récupérer les versions de ce document", "fileVersionsMakeHeadError": "Échec lors du passage du document en version actuelle", "fileVersionDelete": "Échec lors de la suppression du document", diff --git a/src/i18n/lang/it.json b/src/i18n/lang/it.json index 6663e729d..32024a364 100644 --- a/src/i18n/lang/it.json +++ b/src/i18n/lang/it.json @@ -1,4 +1,30 @@ { + "NamingConstraint": { + "managerTitle": "", + "constraintsTab": "", + "strictBadge": "", + "createConstraintTitle": "", + "updateConstraintTitle": "", + "createTemplateTitle": "", + "updateTemplateTitle": "", + "nameLabel": "", + "strictLabel": "", + "strictHelp": "", + "previewLabel": "", + "ruleSectionTitle": "", + "addPartButton": "", + "emptyParts": "", + "partTypeLabel": "", + "partTypeValuesIn": "", + "partTypeBounded": "", + "partTypeNChars": "", + "elementsHelp": "", + "minLabel": "", + "maxLabel": "", + "maxLengthLabel": "", + "emptyElementsError": "", + "invalidBoundsError": "" + }, "OidcCallbackError": { "message": "Si è verificato un errore durante l'autenticazione...", "retryButtonText": "Riprova" @@ -361,6 +387,19 @@ "invitationViewDeclineError": "Impossibile rifiutare l'invito", "folderFetchFolder": "Errore durante il recupero delle informazioni sulla cartella", "bcfDeleteError": "Eliminazione non riuscita...", + "namingConstraintsFetchError": "", + "namingConstraintFetchError": "", + "namingConstraintCreateError": "", + "namingConstraintUpdateError": "", + "namingConstraintDeleteError": "", + "namingPartsTemplatesFetchError": "", + "namingPartsTemplateCreateError": "", + "namingPartsTemplateUpdateError": "", + "namingPartsTemplateDeleteError": "", + "folderNamingConstraintFetchError": "", + "folderNamingConstraintSetError": "", + "folderNamingConstraintDeleteError": "", + "conflictingDocumentsFetchError": "", "groupImportError": "Impossibile importare i gruppi" }, "ProjectStatusBadge": { @@ -855,4 +894,4 @@ "FileTreePreviewModal": { "title": "Importa la struttura del file da" } -} \ No newline at end of file +} diff --git a/src/i18n/lang/nl.json b/src/i18n/lang/nl.json index 2b839d2b7..5b7365fa7 100644 --- a/src/i18n/lang/nl.json +++ b/src/i18n/lang/nl.json @@ -1,4 +1,30 @@ { + "NamingConstraint": { + "managerTitle": "", + "constraintsTab": "", + "strictBadge": "", + "createConstraintTitle": "", + "updateConstraintTitle": "", + "createTemplateTitle": "", + "updateTemplateTitle": "", + "nameLabel": "", + "strictLabel": "", + "strictHelp": "", + "previewLabel": "", + "ruleSectionTitle": "", + "addPartButton": "", + "emptyParts": "", + "partTypeLabel": "", + "partTypeValuesIn": "", + "partTypeBounded": "", + "partTypeNChars": "", + "elementsHelp": "", + "minLabel": "", + "maxLabel": "", + "maxLengthLabel": "", + "emptyElementsError": "", + "invalidBoundsError": "" + }, "OidcCallbackError": { "message": "Er heeft zich een fout voorgedaan bij de authenticatie", "retryButtonText": "" @@ -357,6 +383,19 @@ "invitationViewDeclineError": "", "folderFetchFolder": "", "bcfDeleteError": "", + "namingConstraintsFetchError": "", + "namingConstraintFetchError": "", + "namingConstraintCreateError": "", + "namingConstraintUpdateError": "", + "namingConstraintDeleteError": "", + "namingPartsTemplatesFetchError": "", + "namingPartsTemplateCreateError": "", + "namingPartsTemplateUpdateError": "", + "namingPartsTemplateDeleteError": "", + "folderNamingConstraintFetchError": "", + "folderNamingConstraintSetError": "", + "folderNamingConstraintDeleteError": "", + "conflictingDocumentsFetchError": "", "groupImportError": "" }, "ProjectStatusBadge": { @@ -851,4 +890,4 @@ "FileTreePreviewModal": { "title": "" } -} \ No newline at end of file +} diff --git a/src/i18n/lang/no.json b/src/i18n/lang/no.json index da63aa636..487ed5dc2 100644 --- a/src/i18n/lang/no.json +++ b/src/i18n/lang/no.json @@ -1,4 +1,30 @@ { + "NamingConstraint": { + "managerTitle": "", + "constraintsTab": "", + "strictBadge": "", + "createConstraintTitle": "", + "updateConstraintTitle": "", + "createTemplateTitle": "", + "updateTemplateTitle": "", + "nameLabel": "", + "strictLabel": "", + "strictHelp": "", + "previewLabel": "", + "ruleSectionTitle": "", + "addPartButton": "", + "emptyParts": "", + "partTypeLabel": "", + "partTypeValuesIn": "", + "partTypeBounded": "", + "partTypeNChars": "", + "elementsHelp": "", + "minLabel": "", + "maxLabel": "", + "maxLengthLabel": "", + "emptyElementsError": "", + "invalidBoundsError": "" + }, "OidcCallbackError": { "message": "En feil oppsto under autentiseringen...", "retryButtonText": "" @@ -357,6 +383,19 @@ "invitationViewDeclineError": "", "folderFetchFolder": "", "bcfDeleteError": "", + "namingConstraintsFetchError": "", + "namingConstraintFetchError": "", + "namingConstraintCreateError": "", + "namingConstraintUpdateError": "", + "namingConstraintDeleteError": "", + "namingPartsTemplatesFetchError": "", + "namingPartsTemplateCreateError": "", + "namingPartsTemplateUpdateError": "", + "namingPartsTemplateDeleteError": "", + "folderNamingConstraintFetchError": "", + "folderNamingConstraintSetError": "", + "folderNamingConstraintDeleteError": "", + "conflictingDocumentsFetchError": "", "groupImportError": "" }, "ProjectStatusBadge": { @@ -851,4 +890,4 @@ "FileTreePreviewModal": { "title": "" } -} \ No newline at end of file +} diff --git a/src/services/ErrorService.js b/src/services/ErrorService.js index 811667e99..1452e9390 100644 --- a/src/services/ErrorService.js +++ b/src/services/ErrorService.js @@ -87,6 +87,19 @@ const ERRORS = Object.freeze({ BCF_IMPORT_ERROR: "bcfImportError", BCF_EXPORT_ERROR: "bcfExportError", BCF_DELETE_ERROR: "bcfDeleteError", + NAMING_CONSTRAINTS_FETCH_ERROR: "namingConstraintsFetchError", + NAMING_CONSTRAINT_FETCH_ERROR: "namingConstraintFetchError", + NAMING_CONSTRAINT_CREATE_ERROR: "namingConstraintCreateError", + NAMING_CONSTRAINT_UPDATE_ERROR: "namingConstraintUpdateError", + NAMING_CONSTRAINT_DELETE_ERROR: "namingConstraintDeleteError", + NAMING_PARTS_TEMPLATES_FETCH_ERROR: "namingPartsTemplatesFetchError", + NAMING_PARTS_TEMPLATE_CREATE_ERROR: "namingPartsTemplateCreateError", + NAMING_PARTS_TEMPLATE_UPDATE_ERROR: "namingPartsTemplateUpdateError", + NAMING_PARTS_TEMPLATE_DELETE_ERROR: "namingPartsTemplateDeleteError", + FOLDER_NAMING_CONSTRAINT_FETCH_ERROR: "folderNamingConstraintFetchError", + FOLDER_NAMING_CONSTRAINT_SET_ERROR: "folderNamingConstraintSetError", + FOLDER_NAMING_CONSTRAINT_DELETE_ERROR: "folderNamingConstraintDeleteError", + CONFLICTING_DOCUMENTS_FETCH_ERROR: "conflictingDocumentsFetchError", }); class RuntimeError { diff --git a/src/services/NamingConstraintService.js b/src/services/NamingConstraintService.js new file mode 100644 index 000000000..ab3ab4068 --- /dev/null +++ b/src/services/NamingConstraintService.js @@ -0,0 +1,298 @@ +import apiClient from "./api-client.js"; +import { ERRORS, RuntimeError, ErrorService } from "./ErrorService.js"; + +/** + * Thrown when a strict folder naming constraint cannot be applied because + * existing documents in scope do not match the rule (API responds 409). + * `documents` holds the conflicting `LightDocument[]` returned by the API. + */ +class NamingConstraintConflictError { + constructor(documents) { + this.documents = documents; + } +} + +const isResponse = (error) => typeof Response !== "undefined" && error instanceof Response; + +// In-memory cache of effective folder rules, keyed by folder id, to avoid +// re-fetching the rule on every rename/upload within the same folder. +const folderRuleCache = new Map(); + +class NamingConstraintService { + // --- Naming constraints catalog ------------------------------------------ + + async fetchNamingConstraints(project) { + try { + return await apiClient.collaborationApi.getNamingConstraints(project.cloud.id, project.id); + } catch (error) { + ErrorService.handleError(new RuntimeError(ERRORS.NAMING_CONSTRAINTS_FETCH_ERROR, error)); + return []; + } + } + + async fetchNamingConstraint(project, constraint) { + try { + return await apiClient.collaborationApi.getNamingConstraint( + project.cloud.id, + constraint.id, + project.id, + ); + } catch (error) { + throw new RuntimeError(ERRORS.NAMING_CONSTRAINT_FETCH_ERROR, error); + } + } + + async createNamingConstraint(project, payload) { + try { + return await apiClient.collaborationApi.createNamingConstraint( + project.cloud.id, + project.id, + payload, + ); + } catch (error) { + throw new RuntimeError(ERRORS.NAMING_CONSTRAINT_CREATE_ERROR, error); + } + } + + // Resolve the effective naming rule applying to a folder, with its `strict` + // flag. Returns null when no rule applies. Results are cached per folder id. + async getEffectiveFolderRule(project, folder) { + if (!folder?.id) return null; + + if (folderRuleCache.has(folder.id)) { + return folderRuleCache.get(folder.id); + } + + const folderConstraint = await this.fetchFolderNamingConstraint(project, folder); + + const constraint = folderConstraint?.constraint ?? null; + + const effective = constraint + ? { + rule: constraint.rule, + strict: constraint.strict, + name: constraint.name, + } + : null; + + folderRuleCache.set(folder.id, effective); + + return effective; + } + + /** + * Updates a naming constraint. + * Resolves with the `NamingConstraint` (its `conflicting_documents` lists + * the non-blocking conflicts for non-strict rules). + * Throws `NamingConstraintConflictError` (with `documents`) when a strict + * rule conflicts with existing documents (API responds 409). + */ + async updateNamingConstraint(project, constraint, payload) { + try { + return await apiClient.collaborationApi.updateNamingConstraint( + project.cloud.id, + constraint.id, + project.id, + payload, + ); + } catch (error) { + if (isResponse(error) && error.status === 409) { + const documents = await error.json(); + throw new NamingConstraintConflictError(documents); + } + throw new RuntimeError(ERRORS.NAMING_CONSTRAINT_UPDATE_ERROR, error); + } + } + + async deleteNamingConstraint(project, constraint) { + try { + const response = await fetch( + `${ENV.VUE_APP_API_BASE_URL}/cloud/${project.cloud.id}/project/${project.id}/naming-constraint/${constraint.id}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + ...apiClient.authHeader, + }, + }, + ); + if (response.status === 200) { + const documents = await response.json(); + throw new NamingConstraintConflictError(documents); + } + if (response.status !== 204) { + throw new RuntimeError(ERRORS.NAMING_CONSTRAINT_DELETE_ERROR, await response.text()); + } + } catch (error) { + throw new RuntimeError(ERRORS.NAMING_CONSTRAINT_DELETE_ERROR, error); + } + } + + // --- Naming parts templates ---------------------------------------------- + + async fetchNamingPartsTemplates(project) { + try { + return await apiClient.collaborationApi.getNamingPartsTemplates(project.cloud.id, project.id); + } catch (error) { + ErrorService.handleError(new RuntimeError(ERRORS.NAMING_PARTS_TEMPLATES_FETCH_ERROR, error)); + return []; + } + } + + async createNamingPartsTemplate(project, payload) { + try { + return await apiClient.collaborationApi.createNamingPartsTemplate( + project.cloud.id, + project.id, + payload, + ); + } catch (error) { + throw new RuntimeError(ERRORS.NAMING_PARTS_TEMPLATE_CREATE_ERROR, error); + } + } + + async updateNamingPartsTemplate(project, template, payload) { + try { + return await apiClient.collaborationApi.updateNamingPartsTemplate( + project.cloud.id, + template.id, + project.id, + payload, + ); + } catch (error) { + throw new RuntimeError(ERRORS.NAMING_PARTS_TEMPLATE_UPDATE_ERROR, error); + } + } + + async deleteNamingPartsTemplate(project, template) { + try { + return await apiClient.collaborationApi.deleteNamingPartsTemplate( + project.cloud.id, + template.id, + project.id, + ); + } catch (error) { + throw new RuntimeError(ERRORS.NAMING_PARTS_TEMPLATE_DELETE_ERROR, error); + } + } + + // --- Folder naming constraint -------------------------------------------- + + /** + * Returns the effective `FolderNamingConstraint` for a folder (may be + * inherited from a recursive parent rule), or `null` when no rule applies + * (API responds 404). + */ + async fetchFolderNamingConstraint(project, folder) { + try { + return await apiClient.collaborationApi.getFolderNamingConstraint( + project.cloud.id, + folder.id, + project.id, + ); + } catch (error) { + if (isResponse(error) && error.status === 404) { + return null; + } + throw new RuntimeError(ERRORS.FOLDER_NAMING_CONSTRAINT_FETCH_ERROR, error); + } + } + + /** + * Sets or replaces the naming constraint applied on a folder. + * Resolves with the `FolderNamingConstraint` (its `conflicting_documents` + * lists the non-blocking conflicts for non-strict rules). + * Throws `NamingConstraintConflictError` (with `documents`) when a strict + * rule conflicts with existing documents (API responds 409). + */ + async setFolderNamingConstraint(project, folder, { constraint_id, recursive }) { + try { + const result = await apiClient.collaborationApi.setFolderNamingConstraint( + project.cloud.id, + folder.id, + project.id, + { constraint_id, recursive }, + ); + this.clearFolderRuleCache(); + return result; + } catch (error) { + if (isResponse(error) && error.status === 409) { + const documents = await error.json(); + throw new NamingConstraintConflictError(documents); + } + throw new RuntimeError(ERRORS.FOLDER_NAMING_CONSTRAINT_SET_ERROR, error); + } + } + + /** + * Removes the direct naming constraint of a folder. + * Resolves with `[]` when removed cleanly (API responds 204), or with the + * `LightDocument[]` newly conflicting with an inherited parent rule (API + * responds 200 with a body). + */ + async deleteFolderNamingConstraint(project, folder) { + try { + const documents = await apiClient.collaborationApi.deleteFolderNamingConstraint( + project.cloud.id, + folder.id, + project.id, + ); + this.clearFolderRuleCache(); + return documents ?? []; + } catch (error) { + if (isResponse(error) && error.status === 404) { + return []; + } + if (error instanceof SyntaxError) { + return []; + } + throw new RuntimeError(ERRORS.FOLDER_NAMING_CONSTRAINT_DELETE_ERROR, error); + } + } + + // --- Conflicting documents ----------------------------------------------- + + /** + * Lists documents flagged with `naming_constraint_conflict = true`. + */ + async fetchConflictingDocuments(project) { + try { + return await apiClient.collaborationApi.getDocuments( + project.cloud.id, + project.id, + undefined, // created_after + undefined, // created_before + undefined, // creator_email + undefined, // description + undefined, // description__contains + undefined, // description__endswith + undefined, // description__startswith + undefined, // file_name + undefined, // file_name__contains + undefined, // file_name__endswith + undefined, // file_name__startswith + undefined, // file_type + undefined, // has__visa + undefined, // id__in + undefined, // name + undefined, // name__contains + undefined, // name__endswith + undefined, // name__startswith + true, // naming_constraint_conflict + ); + } catch (error) { + ErrorService.handleError(new RuntimeError(ERRORS.CONFLICTING_DOCUMENTS_FETCH_ERROR, error)); + return []; + } + } + + clearFolderRuleCache() { + folderRuleCache.clear(); + } +} + +const service = new NamingConstraintService(); + +export { NamingConstraintConflictError }; + +export default service; diff --git a/src/state/naming-constraints.js b/src/state/naming-constraints.js new file mode 100644 index 000000000..f2cbd798f --- /dev/null +++ b/src/state/naming-constraints.js @@ -0,0 +1,114 @@ +import { reactive, readonly, toRefs } from "vue"; +import NamingConstraintService from "../services/NamingConstraintService.js"; + +const state = reactive({ + namingConstraints: [], + namingPartsTemplates: [], +}); + +// --- Naming constraints catalog -------------------------------------------- + +const loadNamingConstraints = async (project) => { + const constraints = await NamingConstraintService.fetchNamingConstraints(project); + state.namingConstraints = constraints; + return constraints; +}; + +const createNamingConstraint = async (project, payload) => { + const constraint = await NamingConstraintService.createNamingConstraint(project, payload); + state.namingConstraints = [...state.namingConstraints, constraint]; + return constraint; +}; + +const updateNamingConstraint = async (project, constraint, payload) => { + const updated = await NamingConstraintService.updateNamingConstraint( + project, + constraint, + payload, + ); + state.namingConstraints = state.namingConstraints.map((item) => + item.id === updated.id ? updated : item, + ); + return updated; +}; + +const deleteNamingConstraint = async (project, constraint) => { + await NamingConstraintService.deleteNamingConstraint(project, constraint); + state.namingConstraints = state.namingConstraints.filter((item) => item.id !== constraint.id); +}; + +// --- Naming parts templates ------------------------------------------------ + +const loadNamingPartsTemplates = async (project) => { + const templates = await NamingConstraintService.fetchNamingPartsTemplates(project); + state.namingPartsTemplates = templates; + return templates; +}; + +const createNamingPartsTemplate = async (project, payload) => { + const template = await NamingConstraintService.createNamingPartsTemplate(project, payload); + state.namingPartsTemplates = [...state.namingPartsTemplates, template]; + return template; +}; + +const updateNamingPartsTemplate = async (project, template, payload) => { + const updated = await NamingConstraintService.updateNamingPartsTemplate( + project, + template, + payload, + ); + state.namingPartsTemplates = state.namingPartsTemplates.map((item) => + item.id === updated.id ? updated : item, + ); + return updated; +}; + +const deleteNamingPartsTemplate = async (project, template) => { + await NamingConstraintService.deleteNamingPartsTemplate(project, template); + state.namingPartsTemplates = state.namingPartsTemplates.filter((item) => item.id !== template.id); +}; + +// --- Folder naming constraint ---------------------------------------------- +const fetchFolderNamingConstraint = async (project, folder) => { + return NamingConstraintService.fetchFolderNamingConstraint(project, folder); +}; + +const setFolderNamingConstraint = async (project, folder, payload) => { + return NamingConstraintService.setFolderNamingConstraint(project, folder, payload); +}; + +const deleteFolderNamingConstraint = async (project, folder) => { + return NamingConstraintService.deleteFolderNamingConstraint(project, folder); +}; + +const getEffectiveFolderRule = async (project, folder) => { + return NamingConstraintService.getEffectiveFolderRule(project, folder); +}; + +// --- Conflicting documents ------------------------------------------------- + +const fetchConflictingDocuments = async (project) => { + return NamingConstraintService.fetchConflictingDocuments(project); +}; + +export function useNamingConstraints() { + const readOnlyState = readonly(state); + return { + // References + ...toRefs(readOnlyState), + // Methods + loadNamingConstraints, + createNamingConstraint, + updateNamingConstraint, + deleteNamingConstraint, + loadNamingPartsTemplates, + createNamingPartsTemplate, + updateNamingPartsTemplate, + deleteNamingPartsTemplate, + fetchFolderNamingConstraint, + setFolderNamingConstraint, + deleteFolderNamingConstraint, + getEffectiveFolderRule, + fetchConflictingDocuments, + }; +} diff --git a/src/utils/naming-constraint.js b/src/utils/naming-constraint.js new file mode 100644 index 000000000..b2bf4b495 --- /dev/null +++ b/src/utils/naming-constraint.js @@ -0,0 +1,210 @@ +/** + * Client-side helpers to work with naming-constraint rules without round-trips + * to the API (e.g. validate a name before starting a large upload). + * + * A rule has the shape: + * { + * separator: "_", + * parts: [ + * { type: "values_in", elements: ["ARC", "STR"] }, + * { type: "bounded", min_value: 1, max_value: 99 }, + * { type: "n_chars", max_length: 12 } + * ] + * } + * + * Names are validated on the file name without its extension, the same way the + * backend evaluates them. + */ + +const PART_TYPES = Object.freeze({ + VALUES_IN: "values_in", + BOUNDED: "bounded", + N_CHARS: "n_chars", +}); + +/** + * Strip the extension from a file name (e.g. "ARC_01.ifc" -> "ARC_01"). + * Names without an extension are returned unchanged. + * + * @param {String} name + * @returns {String} + */ +function stripExtension(name) { + const dotIndex = name.lastIndexOf("."); + return dotIndex > 0 ? name.slice(0, dotIndex) : name; +} + +/** + * Check whether a single segment matches a rule part. + * + * @param {Object} part + * @param {String} segment + * @returns {Boolean} + */ +function matchPart(part, segment) { + switch (part?.type) { + case PART_TYPES.VALUES_IN: + return Array.isArray(part.elements) && part.elements.includes(segment); + case PART_TYPES.BOUNDED: { + if (!/^\d+$/.test(segment)) return false; + const value = Number(segment); + return value >= part.min_value && value <= part.max_value; + } + case PART_TYPES.N_CHARS: + return segment.length > 0 && segment.length <= part.max_length; + default: + return false; + } +} + +/** + * Check whether a file name matches a naming-constraint rule. + * Returns `true` when there is no rule (nothing to enforce). + * + * @param {String} name file name (with or without extension) + * @param {Object|null} rule naming-constraint rule + * @returns {Boolean} + */ +function matchName(name, rule) { + if (!rule || !Array.isArray(rule.parts) || rule.parts.length === 0) { + return true; + } + if (typeof name !== "string" || name.length === 0) return false; + + const baseName = stripExtension(name); + const separator = rule.separator ?? ""; + const segments = separator === "" ? [baseName] : baseName.split(separator); + + if (segments.length !== rule.parts.length) return false; + + return rule.parts.every((part, index) => matchPart(part, segments[index])); +} + +/** + * Build a human-friendly example segment for a single rule part. + * + * Uses the part name when available. + * + * @param {Object} part + * @returns {String} + */ +function buildPartExample(part) { + return part?.name ?? ""; +} + +/** + * Build a human-friendly example name from a rule, to preview the expected + * format in the UI (e.g. "ARC_[1-99]_XXX"). + * + * @param {Object|null} rule + * @returns {String} + */ +function buildExample(rule) { + if (!rule || !Array.isArray(rule.parts) || rule.parts.length === 0) { + return ""; + } + const separator = rule.separator ?? ""; + return rule.parts.map(buildPartExample).join(separator) + ".ext"; +} + +/** + * Split a filename into editable values and extension. + * + * @param {String} name + * @param {Object} rule + * @returns {{values: String[], extension: String}} + */ +function splitName(name, rule) { + if (!rule) { + return { + values: [], + extension: "", + }; + } + + const dot = name.lastIndexOf("."); + const extension = dot > 0 ? name.slice(dot) : ""; + + const basename = stripExtension(name); + + // Si le nom est déjà conforme, on le découpe normalement + if (matchName(name, rule)) { + return { + extension, + values: rule.separator === "" ? [basename] : basename.split(rule.separator), + }; + } + + // Sinon on construit un formulaire par défaut + const values = rule.parts.map((part) => { + switch (part.type) { + case PART_TYPES.VALUES_IN: + return part.elements?.[0] ?? ""; + + case PART_TYPES.BOUNDED: + return part.min_value; + + case PART_TYPES.N_CHARS: + return basename; + + default: + return ""; + } + }); + + return { + values, + extension, + }; +} + +/** + * Left-pad a bounded value according to its max_value. + * + * max_value=999 -> 001 + * max_value=99 -> 01 + */ +function padBoundedValue(value, part) { + if (value === "" || value === null || value === undefined) { + return ""; + } + + const digits = String(part.max_value).length; + + return String(value).padStart(digits, "0"); +} + +/** + * Build a filename from rule values. + */ +function buildName(values, rule, extension = "") { + if (!rule) return ""; + + const separator = rule.separator ?? ""; + + const result = values + .map((value, index) => { + const part = rule.parts[index]; + + if (part.type === PART_TYPES.BOUNDED) { + return padBoundedValue(value, part); + } + + return value; + }) + .join(separator); + + return result + extension; +} + +export { + PART_TYPES, + matchName, + matchPart, + buildExample, + buildPartExample, + stripExtension, + splitName, + padBoundedValue, + buildName, +}; diff --git a/src/views/project-board/project-files/ProjectFiles.vue b/src/views/project-board/project-files/ProjectFiles.vue index d1309c30b..e11cd64ba 100644 --- a/src/views/project-board/project-files/ProjectFiles.vue +++ b/src/views/project-board/project-files/ProjectFiles.vue @@ -6,8 +6,8 @@ name: routeNames.projectGroups, params: { spaceID: project.cloud.id, - projectID: project.id - } + projectID: project.id, + }, }" >
@@ -57,13 +57,15 @@ import AppLink from "../../../components/specific/app/app-link/AppLink.vue"; import AppLoading from "../../../components/specific/app/app-loading/AppLoading.vue"; import AppSlotContent from "../../../components/specific/app/app-slot/AppSlotContent.js"; import FilesManager from "../../../components/specific/files/files-manager/FilesManager.vue"; +import NamingConstraintsManager from "../../../components/specific/files/naming-constraint/NamingConstraintsManager.vue"; +import { useAppSidePanel } from "../../../components/specific/app/app-side-panel/app-side-panel.js"; export default { components: { AppLink, AppLoading, AppSlotContent, - FilesManager + FilesManager, }, setup() { const { isProjectAdmin } = useUser(); @@ -71,14 +73,17 @@ export default { const { currentProject } = useProjects(); const { loadProjectModels } = useModels(); const { projectFileStructure, loadProjectFileStructure } = useFiles(); + const { openSidePanel } = useAppSidePanel(); - const reloadData = debounce(async () => { + const reloadData = async () => { await Promise.all([ loadSpaceSubInfo(currentSpace.value), loadProjectFileStructure(currentProject.value), - loadProjectModels(currentProject.value) + loadProjectModels(currentProject.value), ]); - }, 1000); + }; + + const reloadDataDebounced = debounce(reloadData, 1000); return { // References @@ -89,10 +94,11 @@ export default { // Methods isProjectAdmin, reloadData, + reloadDataDebounced, // Responsive breakpoints - ...useStandardBreakpoints() + ...useStandardBreakpoints(), }; - } + }, }; diff --git a/tests/unit/utils/naming-constraint.spec.js b/tests/unit/utils/naming-constraint.spec.js new file mode 100644 index 000000000..f563d26c3 --- /dev/null +++ b/tests/unit/utils/naming-constraint.spec.js @@ -0,0 +1,123 @@ +import { + matchName, + matchPart, + buildExample, + buildPartExample, + stripExtension, + PART_TYPES +} from "../../../src/utils/naming-constraint.js"; + +const rule = { + separator: "_", + parts: [ + { type: "values_in", elements: ["ARC", "STR"] }, + { type: "bounded", min_value: 1, max_value: 99 }, + { type: "n_chars", max_length: 12 } + ] +}; + +describe("Naming constraint - stripExtension", () => { + it("Should remove the extension", () => { + expect(stripExtension("ARC_01_plan.ifc")).toBe("ARC_01_plan"); + expect(stripExtension("My.File.Name.pdf")).toBe("My.File.Name"); + }); + + it("Should keep names without extension", () => { + expect(stripExtension("ARC_01_plan")).toBe("ARC_01_plan"); + expect(stripExtension(".gitignore")).toBe(".gitignore"); + }); +}); + +describe("Naming constraint - matchPart", () => { + it("values_in matches only listed elements", () => { + const part = { type: PART_TYPES.VALUES_IN, elements: ["ARC", "STR"] }; + expect(matchPart(part, "ARC")).toBe(true); + expect(matchPart(part, "STR")).toBe(true); + expect(matchPart(part, "MEP")).toBe(false); + expect(matchPart(part, "arc")).toBe(false); + }); + + it("bounded matches integers within range", () => { + const part = { type: PART_TYPES.BOUNDED, min_value: 1, max_value: 99 }; + expect(matchPart(part, "1")).toBe(true); + expect(matchPart(part, "99")).toBe(true); + expect(matchPart(part, "0")).toBe(false); + expect(matchPart(part, "100")).toBe(false); + expect(matchPart(part, "12a")).toBe(false); + expect(matchPart(part, "")).toBe(false); + }); + + it("n_chars matches non-empty strings up to max length", () => { + const part = { type: PART_TYPES.N_CHARS, max_length: 3 }; + expect(matchPart(part, "a")).toBe(true); + expect(matchPart(part, "abc")).toBe(true); + expect(matchPart(part, "abcd")).toBe(false); + expect(matchPart(part, "")).toBe(false); + }); + + it("Unknown part type does not match", () => { + expect(matchPart({ type: "unknown" }, "x")).toBe(false); + expect(matchPart(undefined, "x")).toBe(false); + }); +}); + +describe("Naming constraint - matchName", () => { + it("Should match valid names", () => { + expect(matchName("ARC_01_plan", rule)).toBe(true); + expect(matchName("STR_99_a", rule)).toBe(true); + expect(matchName("ARC_5_groundfloor.ifc", rule)).toBe(true); + }); + + it("Should reject names with wrong segment count", () => { + expect(matchName("ARC_01", rule)).toBe(false); + expect(matchName("ARC_01_plan_extra", rule)).toBe(false); + }); + + it("Should reject names with invalid segments", () => { + expect(matchName("MEP_01_plan", rule)).toBe(false); + expect(matchName("ARC_0_plan", rule)).toBe(false); + expect(matchName("ARC_100_plan", rule)).toBe(false); + expect(matchName("ARC_01_thisnameistoolong", rule)).toBe(false); + }); + + it("Should treat empty or missing rule as always valid", () => { + expect(matchName("anything", null)).toBe(true); + expect(matchName("anything", { separator: "_", parts: [] })).toBe(true); + }); + + it("Should reject empty names against a real rule", () => { + expect(matchName("", rule)).toBe(false); + }); + + it("Should support an empty separator (single part)", () => { + const singleRule = { + separator: "", + parts: [{ type: "values_in", elements: ["ARC"] }] + }; + expect(matchName("ARC", singleRule)).toBe(true); + expect(matchName("ARC.ifc", singleRule)).toBe(true); + expect(matchName("STR", singleRule)).toBe(false); + }); +}); + +describe("Naming constraint - buildExample", () => { + it("Should build a human-friendly example", () => { + expect(buildExample(rule)).toBe("ARC_[1-99]_XXX"); + }); + + it("Should use placeholder when values_in has no elements", () => { + const r = { separator: "-", parts: [{ type: "values_in", elements: [] }] }; + expect(buildExample(r)).toBe("..."); + }); + + it("Should return an empty string for empty rules", () => { + expect(buildExample(null)).toBe(""); + expect(buildExample({ separator: "_", parts: [] })).toBe(""); + }); + + it("buildPartExample handles each type", () => { + expect(buildPartExample({ type: "values_in", elements: ["A"] })).toBe("A"); + expect(buildPartExample({ type: "bounded", min_value: 1, max_value: 9 })).toBe("[1-9]"); + expect(buildPartExample({ type: "n_chars", max_length: 4 })).toBe("XXX"); + }); +});