diff --git a/package-lock.json b/package-lock.json index 473a908d4..6be98ffef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@bimdata/bcf-components": "6.7.8", "@bimdata/components": "1.10.3", "@bimdata/design-system": "2.4.4", - "@bimdata/typescript-fetch-api-client": "10.48.0", + "@bimdata/typescript-fetch-api-client": "10.48.1", "@bimdata/viewer": "2.20.2-beta.2", "@paddle/paddle-js": "^1.6.5", "async": "^3.2.6", @@ -1595,9 +1595,9 @@ "integrity": "sha512-S9rpxvwR30ylGeyuqIXGbeZO7oOEBPfwxxYV7xkkT/TkFvjcx1GmzzE2VzoaoM9zhXX8RP5Z3e+XXPFn7fDG1A==" }, "node_modules/@bimdata/typescript-fetch-api-client": { - "version": "10.48.0", - "resolved": "https://registry.npmjs.org/@bimdata/typescript-fetch-api-client/-/typescript-fetch-api-client-10.48.0.tgz", - "integrity": "sha512-QLMOSAzvPArPxPauRJ/n5Mg6VMdWUWGg5eamngno06svmofzFInj+lkyVIwpwvTBhMGez80cOqCGZXi6kxGlFw==" + "version": "10.48.1", + "resolved": "https://registry.npmjs.org/@bimdata/typescript-fetch-api-client/-/typescript-fetch-api-client-10.48.1.tgz", + "integrity": "sha512-Q/CVJjENfADu2RPdNRjSZH4I1PCrOhprv32P6X4CoO0i4C2RUUfC7b7qj3q94L/ZKSrUZscR7nCMKwvQ2NiKTQ==" }, "node_modules/@bimdata/viewer": { "version": "2.20.2-beta.2", @@ -2868,9 +2868,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", - "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", + "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", "dev": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index b198702ce..9ec70de22 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@bimdata/bcf-components": "6.7.8", "@bimdata/components": "1.10.3", "@bimdata/design-system": "2.4.4", - "@bimdata/typescript-fetch-api-client": "10.48.0", + "@bimdata/typescript-fetch-api-client": "10.48.1", "@bimdata/viewer": "2.20.2-beta.2", "@paddle/paddle-js": "^1.6.5", "async": "^3.2.6", 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..29db8dc3d 100644 --- a/src/components/specific/files/files-manager/FilesManager.vue +++ b/src/components/specific/files/files-manager/FilesManager.vue @@ -9,8 +9,10 @@ :spaceSubInfo="spaceSubInfo" :importFromOtherProjectsActions="importFromOtherProjectsActions" :initialSearchText="searchText" + :refreshFiles="refreshFiles" @update:searchText="searchText = $event" @upload-files="uploadFiles" + @manage-naming-conflicts="openNamingConflictsModal" />
@@ -74,8 +76,9 @@ :loadingFileIds="loadingFileIds" :selection="selection" :filesToUpload="filesToUpload" - :folder="currentFolder" + :folder="folderWithNamingPreview" :foldersToUpload="foldersToUpload" + :hasNamingConflict="hasNamingConflict" @back-parent-folder="backToParent" @create-model="createModelFromFile" @create-photosphere="createModelFromFile($event, MODEL_TYPE.PHOTOSPHERE)" @@ -88,6 +91,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 +115,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 +210,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 +232,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 +273,10 @@ export default { type: Object, required: true, }, + refreshFiles: { + type: Function, + required: true, + }, }, emits: ["file-uploaded", "file-updated", "model-created"], setup(props, { emit }) { @@ -280,8 +293,14 @@ export default { const { createModel, createPhotosphere, deleteModels } = useModels(); const { fetchToValidateVisas, fetchCreatedVisas } = useVisa(); - + const { getEffectiveFolderRule } = useNamingConstraints(); const currentFolder = ref(null); + const currentFolderNamingRule = ref(null); + const folderWithNamingPreview = computed(() => + currentFolder.value + ? { ...currentFolder.value, namingConstraintPreview: currentFolderNamingRule.value } + : currentFolder.value, + ); const currentFiles = ref([]); const toValidateVisas = ref([]); const createdVisas = ref([]); @@ -337,20 +356,53 @@ 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 ? files.filter((file) => !matchName(file.name, 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)); + proceedUpload({ files: [...validFiles, ...finalFiles], folders }, folder); + }, + }, + }); + return; + } + + proceedUpload({ files, folders }, folder); + }; const loadingFileIds = ref([]); const isCreatingModels = ref(false); @@ -544,6 +596,50 @@ 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 }; + }), + ); + openModal({ + component: NamingConflictModal, + props: { + project: props.project, + documents, + allFolders: allFolders.value, + rule: null, + showRulesByGroup: true, + onClose: closeModal, + onConfirm: async () => { + await props.refreshFiles(); + }, + }, + }); + }; + const visasLoading = ref(false); const openVisaManager = (file) => { onTabChange(filesTabs[2]); @@ -683,6 +779,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", @@ -758,11 +867,15 @@ export default { watch( () => props.fileStructure, (struct) => { - const folderId = gedTargetFolder.get(); + const currentFolderId = currentFolder.value?.id; + + if (currentFolderId) { + const folder = handler.get({ + nature: FILE_TYPE.FOLDER, + id: currentFolderId, + }); - if (folderId) { - jumpToTargetFolder(folderId); - gedTargetFolder.clear(); + currentFolder.value = folder ? handler.deserialize(folder) : struct; } else { currentFolder.value = struct; } @@ -772,11 +885,15 @@ export default { watch( () => currentFolder.value, - (folder) => { + async (folder) => { + if (!folder) return; + const childrenFolders = folder.children.filter(isFolder).sort(sortByName); const childrenFiles = folder.children.filter((c) => !isFolder(c)).sort(sortByName); + currentFiles.value = childrenFolders.concat(childrenFiles); gedTargetFolder.set(folder.id); + currentFolderNamingRule.value = await getEffectiveFolderRule(props.project, folder); }, { immediate: true }, ); @@ -788,6 +905,7 @@ export default { allTags, allVisas, currentFolder, + folderWithNamingPreview, currentSpace, currentVisa, displayedFiles, @@ -829,10 +947,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..290060929 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: { @@ -148,6 +171,10 @@ export default { type: Object, required: true, }, + refreshFiles: { + type: Function, + default: null, + }, selectedFileTab: { type: Object, required: true, @@ -157,25 +184,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 +226,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 +269,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 +278,29 @@ 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, + refreshFiles: props.refreshFiles, + }, + }); + }; + return { // References dropdown, @@ -255,11 +315,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..700d420a1 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), @@ -160,7 +158,7 @@ export default { }); menuItems.value.push({ - key: 6, + key: current_key++, icon: "download", text: "t.download", action: () => onClick("download"), @@ -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.scss b/src/components/specific/files/files-table/file-name-cell/FileNameCell.scss index 76411a201..a845c687d 100644 --- a/src/components/specific/files/files-table/file-name-cell/FileNameCell.scss +++ b/src/components/specific/files/files-table/file-name-cell/FileNameCell.scss @@ -6,9 +6,12 @@ } &__update-form { - display: flex; - align-items: center; - gap: var(--spacing-unit); + padding: calc(var(--spacing-unit) / 2) 0; + gap: calc(var(--spacing-unit) / 2); + + & > .flex { + gap: calc(var(--spacing-unit) / 2); + } &__input { flex-grow: 1; 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..97852c547 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 @@ -5,39 +5,54 @@ -
- +
+ + + {{ $t("t.validate") }} + + + + +
+ - - {{ $t("t.validate") }} - - - -
-
+
+ + +
import { ref, watch, computed } from "vue"; +import { useI18n } from "vue-i18n"; import { useFiles } from "../../../../../state/files.js"; +import { useNamingConstraints } from "../../../../../state/naming-constraints.js"; +import { useAppNotification } from "../../../app/app-notification/app-notification.js"; import { debounce } from "../../../../../utils/async.js"; import { isFolder } from "../../../../../utils/file-structure.js"; +import { matchName, buildExample } from "../../../../../utils/naming-constraint.js"; import { isConvertible, isConvertibleToPhotosphere } from "../../../../../utils/models.js"; +import { useAppModal } from "../../../app/app-modal/app-modal.js"; + +import NamingConstraintPreview from "../../naming-constraint/naming-constraint-preview/NamingConstraintPreview.vue"; +import NamingConflictModal from "../../naming-constraint/NamingConflictModal.vue"; export default { + components: { + NamingConstraintPreview, + }, props: { project: { type: Object, @@ -85,6 +112,10 @@ export default { type: Object, required: true, }, + namingConstraintPreview: { + type: Object, + default: null, + }, editMode: { type: Boolean, default: false, @@ -92,18 +123,45 @@ export default { }, emits: ["close", "file-clicked", "open-versioning-manager", "success"], setup(props, { emit }) { + const { t } = useI18n(); const { updateFiles } = useFiles(); + const { getEffectiveFolderRule } = useNamingConstraints(); + const { pushNotification } = useAppNotification(); + const { openModal, closeModal } = useAppModal(); const loading = ref(false); const nameInput = ref(null); const fileName = ref(""); const hasError = ref(false); + const errorMessage = ref(""); const hasHistory = computed(() => props.file?.history_count > 0); const renameFile = debounce(async () => { if (fileName.value) { + const rule = isFolder(props.file) + ? null + : await getEffectiveFolderRule(props.project, { + id: props.file.parent_id, + }); + if (rule && !matchName(fileName.value, rule)) { + if (rule.strict) { + hasError.value = true; + errorMessage.value = t("t.invalidNameFormat", { + example: buildExample(rule), + }); + nameInput.value.focus(); + return; + } + pushNotification({ + type: "warning", + title: t("NamingConstraint.applyRuleWarningTitle"), + message: t("t.invalidNameFormat", { + example: buildExample(rule), + }), + }); + } try { loading.value = true; await updateFiles(props.project, [ @@ -119,6 +177,7 @@ export default { } } else { hasError.value = true; + errorMessage.value = t("t.invalidName"); nameInput.value.focus(); } }, 500); @@ -132,6 +191,7 @@ export default { const closeUpdateForm = () => { loading.value = false; hasError.value = false; + errorMessage.value = ""; showUpdateForm.value = false; emit("close"); }; @@ -158,10 +218,29 @@ export default { } }; + const openNamingConflictModal = async () => { + const rule = await getEffectiveFolderRule(props.project, { + id: props.file.parent_id, + }); + + openModal({ + component: NamingConflictModal, + props: { + project: props.project, + documents: [props.file], + allFolders: [], + rule, + onClose: closeModal, + onConfirm: closeModal, + }, + }); + }; + return { // References fileName, hasError, + errorMessage, loading, nameInput, showUpdateForm, @@ -172,6 +251,7 @@ export default { isFolder, openUpdateForm, renameFile, + openNamingConflictModal, }; }, }; 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..a0e217d44 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,19 @@ border: 1px solid var(--color-white); } } + .info { + height: 9px; + width: 9px; + position: absolute; + top: 0; + right: 12px; + cursor: pointer; + .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..166866e38 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,26 +2,49 @@
+
diff --git a/src/components/specific/files/folder-table/FoldersTable.vue b/src/components/specific/files/folder-table/FoldersTable.vue index 46bb57989..c91650ff2 100644 --- a/src/components/specific/files/folder-table/FoldersTable.vue +++ b/src/components/specific/files/folder-table/FoldersTable.vue @@ -63,6 +63,7 @@ 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 +300,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..4ba4522b2 --- /dev/null +++ b/src/components/specific/files/naming-constraint/FolderNamingConstraintManager.vue @@ -0,0 +1,166 @@ + + + + + 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..a9f5ea0a4 --- /dev/null +++ b/src/components/specific/files/naming-constraint/NamingConflictModal.vue @@ -0,0 +1,191 @@ + + + + + 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..3f8556970 --- /dev/null +++ b/src/components/specific/files/naming-constraint/NamingConstraintsManager.vue @@ -0,0 +1,282 @@ + + + + + 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..9a3e72c1d --- /dev/null +++ b/src/components/specific/files/naming-constraint/conflicting-document-item/ConflictingDocumentItem.css @@ -0,0 +1,92 @@ +.conflicting-document-item { + width: 100%; +} + +.conflicting-document-item__summary { + padding: 12px; + cursor: pointer; + list-style: none; +} + +.conflicting-document-item__summary::-webkit-details-marker { + display: none; +} + +.summary__row { + gap: 12px; +} + +.summary__column { + display: flex; + flex: 1; + flex-direction: column; +} + +.summary__label { + margin-bottom: 4px; + color: var(--color-primary); + font-size: 9px; + font-weight: 600; + text-transform: uppercase; +} + +.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); +} + +.summary__actions { + gap: 12px; + justify-content: flex-end; +} + +.conflicting-document-item__content { + padding: 12px; + background: var(--color-white); + border-radius: 0 0 8px 8px; +} + +.conflicting-document-item__info .file-path { + color: var(--color-granite); + font-size: 12px; +} + +.conflicting-document-item__info .file-path 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; +} + +.conflicting-document-item__rename .bimdata-input { + width: 100%; +} + +.conflicting-document-item__actions { + display: flex; + justify-content: flex-end; + gap: 12px; +} 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..3f1edde4b --- /dev/null +++ b/src/components/specific/files/naming-constraint/conflicting-document-item/ConflictingDocumentItem.vue @@ -0,0 +1,146 @@ + + + + + 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..23b1c412a --- /dev/null +++ b/src/components/specific/files/naming-constraint/conflicting-document-item/naming-constraint-file-editor/NamingConstraintFileEditor.vue @@ -0,0 +1,164 @@ + + + + + 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..f2f75c0df --- /dev/null +++ b/src/components/specific/files/naming-constraint/conflicting-documents-list/ConflictingDocumentsList.css @@ -0,0 +1,107 @@ +.conflicting-documents-list { + display: flex; + flex-direction: column; + gap: 16px; + max-height: 450px; + margin: 0; + padding: 0; + overflow-y: auto; + list-style: none; +} + +.conflicting-documents-list__group { + display: flex; + flex-direction: column; + border: 1px solid var(--color-silver); + border-radius: 8px; + background-color: #f8f9fa; +} + +.conflicting-documents-list__group__header { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 16px; + border-bottom: 1px solid var(--color-silver); +} + +.conflicting-documents-list__group__label { + color: var(--color-granite-light); + font-size: 11px; + font-weight: 600; + line-height: 14px; + text-transform: uppercase; +} + +.conflicting-documents-list__group > ul, +.conflicting-documents-list__ungrouped > ul { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; +} + +.conflicting-documents-list__group .conflicting-documents-list__item { + border-bottom: 1px solid var(--color-silver); + background: var(--color-silver-light); +} + +.conflicting-documents-list__group .conflicting-documents-list__item:last-child { + border-bottom: none; +} + +.conflicting-documents-list__ungrouped > ul { + gap: 8px; +} + +.conflicting-documents-list__ungrouped .conflicting-documents-list__item { + background-color: var(--color-silver-light); + border: 1px solid var(--color-silver); + border-radius: 8px; +} + +.conflicting-documents-list__item__name { + flex: 1; + overflow: hidden; + color: var(--color-primary); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conflicting-documents-list__item__info { + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; +} + +.conflicting-documents-list__item__example { + overflow: hidden; + color: var(--color-granite-light); + font-size: 11px; + 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; +} 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..2f55cd4dd --- /dev/null +++ b/src/components/specific/files/naming-constraint/conflicting-documents-list/ConflictingDocumentsList.vue @@ -0,0 +1,174 @@ + + + + + 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..e74aa8893 --- /dev/null +++ b/src/components/specific/files/naming-constraint/folder-naming-constraint-selector/FolderNamingConstraintSelector.css @@ -0,0 +1,145 @@ +.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__inheritance-info { + gap: 6px; + padding: var(--spacing-unit); + border-radius: 6px; + background-color: var(--color-primary-lighter); + font-size: 13px; + p { + margin: 0; + font-size: 12px; + color: var(--color-granite); + } +} + +.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..65cebf693 --- /dev/null +++ b/src/components/specific/files/naming-constraint/folder-naming-constraint-selector/FolderNamingConstraintSelector.vue @@ -0,0 +1,464 @@ + + + + + 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..82b16d631 --- /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 0; + .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..3e100e2d5 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraint-form/NamingConstraintForm.vue @@ -0,0 +1,431 @@ + + + + + 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..7cd8fec23 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraint-form/RuleBuilder.css @@ -0,0 +1,218 @@ +.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; + } + + .rule-builder__part__description { + padding: 3px var(--spacing-unit) calc(var(--spacing-unit) / 2) var(--spacing-unit); + width: calc(100% - (90px + 18px)); + display: flex; + justify-self: flex-end; + .bimdata-input-outlined { + &:deep(fieldset) { + label { + margin-bottom: 3px; + font-size: 9px; + text-transform: none; + font-weight: 400; + } + } + &:deep(.bimdata-input-outlined__container){ + height: 30px; + min-height: 30px; + input { + min-height: auto; + font-size: 11px; + } + } + } + } +} + +/* --- Champs --- */ +.rule-builder__number { + flex: 1; + height: 30px; + 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; + &__label { + max-width: 85%; + overflow: hidden; + text-overflow: ellipsis; + } + &:deep(.bimdata-dropdown__content) { + padding: 0 6px; + font-size: 11px; + max-width: 140px; + overflow: hidden; + 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..9a2c09875 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraint-form/RuleBuilder.vue @@ -0,0 +1,379 @@ + + + + + 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..ae6375b31 --- /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: 1px; +} + +.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..f40e4d658 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraint-preview/NamingConstraintPreview.vue @@ -0,0 +1,50 @@ + + + + + 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..8f280413e --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraints-list/NamingConstraintsList.css @@ -0,0 +1,141 @@ +.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; + color: var(--color-granite); +} +.naming-constraints-list__empty__warning { + padding: var(--spacing-unit) calc(var(--spacing-unit) * 1.5); + gap: 6px; + background-color: var(--color-warning-lighter); + border-radius: 8px; + font-size: 12px; + 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..06f9c56e9 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraints-list/NamingConstraintsList.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/src/components/specific/files/naming-constraint/naming-part-list-form/NamingPartListForm.css b/src/components/specific/files/naming-constraint/naming-part-list-form/NamingPartListForm.css new file mode 100644 index 000000000..258c5ae27 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-part-list-form/NamingPartListForm.css @@ -0,0 +1,97 @@ +.naming-part-list-form__container { + height: 100%; +} +.naming-part-list-form { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 0; +} +.naming-part-list-form__header { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} +.naming-part-list-form__header__title { + font-size: 16px; + font-weight: 600; + color: var(--color-primary); +} +.naming-part-list-form__step { + width: 100%; + display: flex; + flex-direction: column; + gap: 18px; + padding: 14px; + border-radius: 8px; + background-color: var(--color-silver-light); +} +.naming-part-list-form__step__head { + display: flex; + align-items: center; + gap: 8px; +} +.naming-part-list-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-part-list-form__step__label { + font-weight: 600; + color: var(--color-primary); +} +.naming-part-list-form__element { + display: flex; + align-items: flex-end; + gap: 12px; + .bimdata-input { + flex: 1; + } +} +.naming-part-list-form__element__label { + font-size: 14px; + font-weight: 700; + color: var(--color-primary); +} +.naming-part-list-form__bulk-import { + .naming-part-list-form__bulk-import__toggle { + font-size: 14px; + font-weight: 700; + color: var(--color-primary); + cursor: pointer; + } + .naming-part-list-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-part-list-form__add { + align-self: flex-start; +} +.naming-part-list-form__error { + font-size: 12px; + color: var(--color-high); +} +.naming-part-list-form__actions { + display: flex; + justify-content: flex-end; + gap: 12px; + .bimdata-btn { + flex: 1; + } +} diff --git a/src/components/specific/files/naming-constraint/naming-part-list-form/NamingPartListForm.vue b/src/components/specific/files/naming-constraint/naming-part-list-form/NamingPartListForm.vue new file mode 100644 index 000000000..aa3e586a9 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-part-list-form/NamingPartListForm.vue @@ -0,0 +1,399 @@ + + + + + diff --git a/src/components/specific/files/naming-constraint/naming-part-lists-list/NamingPartListsList.css b/src/components/specific/files/naming-constraint/naming-part-lists-list/NamingPartListsList.css new file mode 100644 index 000000000..f2c52aa24 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-part-lists-list/NamingPartListsList.css @@ -0,0 +1,92 @@ +.naming-part-lists-list { + height: 100%; + display: flex; + flex-direction: column; + gap: 12px; +} +.naming-part-lists-list__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + .naming-part-lists-list__head__title { + font-size: 16px; + font-weight: 500; + color: var(--color-primary); + } +} +.naming-part-lists-list__search { + min-height: 32px; +} +.naming-part-lists-list__empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 24px 12px; + text-align: center; + .naming-part-lists-list__empty__title { + font-size: 16px; + font-weight: 600; + color: var(--color-primary); + } + .naming-part-lists-list__empty__text { + max-width: 280px; + font-size: 13px; + line-height: 18px; + color: var(--color-granite); + } +} +.naming-part-lists-list__items { + height: 100%; + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 6px; + list-style: none; + overflow: auto; +} +.naming-part-lists-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-part-lists-list__item__main { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + flex-grow: 1; +} +.naming-part-lists-list__item__name { + font-weight: 600; + color: var(--color-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.naming-part-lists-list__item__badges { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.naming-part-lists-list__item__chip { + padding: 2px 8px; + border-radius: 6px; + background-color: var(--color-neutral-lighter); + color: var(--color-neutral); + font-size: 12px; +} +.naming-part-lists-list__item__actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} diff --git a/src/components/specific/files/naming-constraint/naming-part-lists-list/NamingPartListsList.vue b/src/components/specific/files/naming-constraint/naming-part-lists-list/NamingPartListsList.vue new file mode 100644 index 000000000..3e9863710 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-part-lists-list/NamingPartListsList.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/src/components/specific/files/naming-constraint/naming-part-lists-list/naming-part-list-in-use-modal/NamingPartListInUseModal.css b/src/components/specific/files/naming-constraint/naming-part-lists-list/naming-part-list-in-use-modal/NamingPartListInUseModal.css new file mode 100644 index 000000000..85417504f --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-part-lists-list/naming-part-list-in-use-modal/NamingPartListInUseModal.css @@ -0,0 +1,33 @@ +.naming-part-list-in-use-modal { + padding: 0 var(--spacing-unit); +} +.naming-part-list-in-use-modal__content { + display: flex; + flex-direction: column; + gap: 12px; + text-align: left; + font-size: 13px; +} + +.naming-part-list-in-use-modal__content p { + margin: 0; +} + +.naming-part-list-in-use-modal__rules { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 6px 12px; + li { + list-style-type: decimal; + overflow-wrap: anywhere; + } +} + +.naming-part-list-in-use-modal__hint { + padding: 6px 12px; + background-color: var(--color-silver-light); + color: var(--color-granite-light); + border-radius: 6px; +} diff --git a/src/components/specific/files/naming-constraint/naming-part-lists-list/naming-part-list-in-use-modal/NamingPartListInUseModal.vue b/src/components/specific/files/naming-constraint/naming-part-lists-list/naming-part-list-in-use-modal/NamingPartListInUseModal.vue new file mode 100644 index 000000000..0b9854980 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-part-lists-list/naming-part-list-in-use-modal/NamingPartListInUseModal.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/src/config/naming-constraints.js b/src/config/naming-constraints.js new file mode 100644 index 000000000..9cecae210 --- /dev/null +++ b/src/config/naming-constraints.js @@ -0,0 +1 @@ +export const NONE_OPTION_ID = "__none__"; diff --git a/src/i18n/lang/de.json b/src/i18n/lang/de.json index d12b95fb7..3eb0e9572 100644 --- a/src/i18n/lang/de.json +++ b/src/i18n/lang/de.json @@ -1,4 +1,35 @@ { + "NamingConstraint": { + "managerTitle": "", + "constraintsTab": "", + "strictBadge": "", + "createConstraintTitle": "", + "updateConstraintTitle": "", + "createListTitle": "", + "updateListTitle": "", + "nameLabel": "", + "strictLabel": "", + "strictHelp": "", + "previewLabel": "", + "ruleSectionTitle": "", + "addPartButton": "", + "emptyParts": "", + "partTypeLabel": "", + "partTypeValuesIn": "", + "partTypeBounded": "", + "partTypeNChars": "", + "elementsHelp": "", + "minLabel": "", + "maxLabel": "", + "maxLengthLabel": "", + "emptyElementsError": "", + "invalidBoundsError": "", + "missingBoundsError": "", + "invalidMaxLengthError": "", + "missingListError": "", + "listInUseErrorTitle": "", + "listInUseErrorMessage": "" + }, "OidcCallbackError": { "message": "Bei der Authentifizierung ist ein Fehler aufgetreten...", "retryButtonText": "Nochmals versuchen" @@ -442,6 +473,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": "", + "namingPartListsFetchError": "", + "namingPartListCreateError": "", + "namingPartListUpdateError": "", + "namingPartListDeleteError": "", + "folderNamingConstraintFetchError": "", + "folderNamingConstraintSetError": "", + "folderNamingConstraintDeleteError": "", + "conflictingDocumentsFetchError": "", "groupImportError": "Fehler beim Importieren von Gruppen", "bcfExportXlsxError": "Der Excel-Export ist fehlgeschlagen...", "invitationViewDeclineError": "Einladung kann nicht abgelehnt werden" @@ -973,4 +1017,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 34c903bab..6740d2fd0 100644 --- a/src/i18n/lang/en.json +++ b/src/i18n/lang/en.json @@ -1,4 +1,122 @@ { + "NamingConstraint": { + "managerTitle": "Naming convention management", + "constraintsTab": "Naming rules", + "rulesSectionTitle": "Rules list", + "addRuleButton": "add a rule", + "searchConstraintPlaceholder": "Search for a rule", + "searchListPlaceholder": "Search for a list", + "rulesEmptyTitle": "No rule added", + "rulesEmptyText": "You don't have any rule yet, start by adding one.", + "rulesEmptyPrerequisitesText": "Naming rules only apply to files inside a folder.", + "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", + "createListTitle": "Creating my list", + "updateListTitle": "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", + "partNameLabel": "Field name (optional)", + "partNamePlaceholder": "Enter a name", + "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.", + "missingBoundsError": "Min and max are required for bounded values.", + "invalidMaxLengthError": "Max length must be at least 1.", + "missingListError": "Select a list for every list element.", + "listInUseErrorTitle": "List in use", + "listInUseErrorMessage": "This list is used by the following rules: {constraints}. Edit them before deleting the list.", + "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 \"{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", + "errorMessage": "The number of characters exceeds the allowed limit.", + "renameButton": "Confirm new name", + "applyRenameButton": "Apply new names", + "confirmRenameButton": "Accept names", + "renameFilePlaceholder": "Rename the file", + "renameConflictsMenuItem": "Rename conflicting files", + "noConflictsTitle": "No conflicting files", + "noConflictsMessage": "All files match their folder naming convention.", + "noDirectRuleOption": "No direct rule for this folder", + "inheritedRuleTitle": "An inherited rule applies to this folder", + "inheritedRuleDescription": "The rule « {rule} » is set on the folder « {folder} » and also applies to its subfolders. Removing the direct rule for this folder will not disable this inherited rule.", + "searchPlaceholder": "Search for a rule", + "removeRuleError": "The naming rule could not be removed.", + "removeRuleSuccessTitle": "Naming rule removed", + "removeRuleSuccessMessage": "The naming rule has been successfully removed.", + "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)" + }, + "partListInUseModal": { + "title": "Unable to delete", + "description": "This list cannot be deleted.", + "listLabel": "List concerned:", + "rulesDescription": "This list is used by the following naming rules:", + "resolution": "To delete this list, first update the affected rules so that they no longer use it." + } + }, "OidcCallbackError": { "message": "An error occured during authentication...", "retryButtonText": "Try Again" @@ -512,6 +630,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", + "namingPartListsFetchError": "Unable to retrieve naming lists", + "namingPartListCreateError": "Unable to create this naming list", + "namingPartListUpdateError": "Unable to update this naming list", + "namingPartListDeleteError": "Unable to delete this naming list", + "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" @@ -966,6 +1097,7 @@ "back": "Back", "cancel": "Cancel", "change": "Change", + "clear": "Clear", "comment": "Comment", "confirm": "Confirm", "create": "Create", @@ -984,6 +1116,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", @@ -1046,4 +1179,4 @@ "title": "Delete {visasCount} visas", "message": "You are about to delete the visas on the following files:" } -} \ No newline at end of file +} diff --git a/src/i18n/lang/es.json b/src/i18n/lang/es.json index a17dc7201..e0abe59f7 100644 --- a/src/i18n/lang/es.json +++ b/src/i18n/lang/es.json @@ -1,4 +1,35 @@ { + "NamingConstraint": { + "managerTitle": "", + "constraintsTab": "", + "strictBadge": "", + "createConstraintTitle": "", + "updateConstraintTitle": "", + "createListTitle": "", + "updateListTitle": "", + "nameLabel": "", + "strictLabel": "", + "strictHelp": "", + "previewLabel": "", + "ruleSectionTitle": "", + "addPartButton": "", + "emptyParts": "", + "partTypeLabel": "", + "partTypeValuesIn": "", + "partTypeBounded": "", + "partTypeNChars": "", + "elementsHelp": "", + "minLabel": "", + "maxLabel": "", + "maxLengthLabel": "", + "emptyElementsError": "", + "invalidBoundsError": "", + "missingBoundsError": "", + "invalidMaxLengthError": "", + "missingListError": "", + "listInUseErrorTitle": "", + "listInUseErrorMessage": "" + }, "OidcCallbackError": { "message": "Ha ocurrido un error de autenticación...", "retryButtonText": "Intente de nuevo" @@ -442,6 +473,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": "", + "namingPartListsFetchError": "", + "namingPartListCreateError": "", + "namingPartListUpdateError": "", + "namingPartListDeleteError": "", + "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" @@ -973,4 +1017,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 b4ee5221d..1e3248db8 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", @@ -231,6 +233,129 @@ } } }, + "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.", + "rulesEmptyPrerequisitesText": "Les règles de nommage s’appliquent uniquement aux fichiers à l'intérieur d'un dossier.", + "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", + "createListTitle": "Création de ma liste", + "updateListTitle": "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", + "partNameLabel": "Nom du champ (facultatif)", + "partNamePlaceholder": "Saisir un nom", + "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.", + "missingBoundsError": "Le min et le max sont obligatoires pour les valeurs bornées.", + "invalidMaxLengthError": "La longueur max doit être d'au moins 1.", + "missingListError": "Sélectionnez une liste pour chaque élément de type liste.", + "listInUseErrorTitle": "Liste utilisée", + "listInUseErrorMessage": "Cette liste est utilisée par les règles suivantes : {constraints}. Modifiez-les avant de supprimer la liste.", + "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 règle de nommage a bien été appliquée au dossier \"{folder}\".", + "applyRuleWithConflictsSuccessTitle": "Règle appliquée avec des conflits", + "applyRuleWithConflictsSuccessMessage": "La règle de nommage a bien été appliquée au dossier \"{folder}\". Certains fichiers peuvent encore être en conflit.", + "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", + "errorMessage": "Le nombre de caractères dépasse la limite autorisée.", + "renameButton": "Valider ce nouveau nom", + "applyRenameButton": "Confirmer ces nouveaux noms", + "confirmRenameButton": "Accepter ces noms", + "renameConflictsMenuItem": "Renommer les fichiers en conflit", + "noConflictsTitle": "Aucun fichier en conflit", + "noConflictsMessage": "Tous les fichiers respectent la convention de nommage de leur dossier.", + "noDirectRuleOption": "Aucune règle propre à ce dossier", + "inheritedRuleTitle": "Une règle héritée s'applique à ce dossier", + "inheritedRuleDescription": "La règle « {rule} » est définie sur le dossier « {folder} » et s'applique également à ses sous-dossiers. Retirer la règle propre à ce dossier ne désactivera pas cette règle héritée.", + "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)" + }, + "partListInUseModal": { + "title": "Suppression impossible", + "description": "Cette liste ne peut pas être supprimée.", + "listLabel": "Liste concernée :", + "rulesDescription": "Cette liste est utilisée par les règles de nommage suivantes :", + "resolution": "Pour supprimer cette liste, modifiez d'abord les règles concernées afin qu'elles ne l'utilisent plus." + } + }, + "FolderNamingConstraint": { + "ruleSectionTitle": "Règles appliquées au dossier : " + }, "LanguageSelector": { "title": "Choix de la langue" }, @@ -614,6 +739,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", + "namingPartListsFetchError": "Impossible de récupérer les listes de nommage", + "namingPartListCreateError": "Impossible de créer cette liste de nommage", + "namingPartListUpdateError": "Impossible de modifier cette liste de nommage", + "namingPartListDeleteError": "Impossible de supprimer cette liste 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", @@ -1071,4 +1209,4 @@ "title": "Suppression de {visasCount} visas", "message": "Vous êtes sur le point de supprimer les visas sur les fichiers suivants :" } -} \ No newline at end of file +} diff --git a/src/i18n/lang/it.json b/src/i18n/lang/it.json index ac654b924..925bd83d0 100644 --- a/src/i18n/lang/it.json +++ b/src/i18n/lang/it.json @@ -1,4 +1,35 @@ { + "NamingConstraint": { + "managerTitle": "", + "constraintsTab": "", + "strictBadge": "", + "createConstraintTitle": "", + "updateConstraintTitle": "", + "createListTitle": "", + "updateListTitle": "", + "nameLabel": "", + "strictLabel": "", + "strictHelp": "", + "previewLabel": "", + "ruleSectionTitle": "", + "addPartButton": "", + "emptyParts": "", + "partTypeLabel": "", + "partTypeValuesIn": "", + "partTypeBounded": "", + "partTypeNChars": "", + "elementsHelp": "", + "minLabel": "", + "maxLabel": "", + "maxLengthLabel": "", + "emptyElementsError": "", + "invalidBoundsError": "", + "missingBoundsError": "", + "invalidMaxLengthError": "", + "missingListError": "", + "listInUseErrorTitle": "", + "listInUseErrorMessage": "" + }, "OidcCallbackError": { "message": "Si è verificato un errore durante l'autenticazione...", "retryButtonText": "Riprova" @@ -363,6 +394,19 @@ "invitationViewDeclineError": "Impossibile rifiutare l'invito", "folderFetchFolder": "Errore durante il recupero delle informazioni sulla cartella", "bcfDeleteError": "Eliminazione non riuscita...", + "namingConstraintsFetchError": "", + "namingConstraintFetchError": "", + "namingConstraintCreateError": "", + "namingConstraintUpdateError": "", + "namingConstraintDeleteError": "", + "namingPartListsFetchError": "", + "namingPartListCreateError": "", + "namingPartListUpdateError": "", + "namingPartListDeleteError": "", + "folderNamingConstraintFetchError": "", + "folderNamingConstraintSetError": "", + "folderNamingConstraintDeleteError": "", + "conflictingDocumentsFetchError": "", "groupImportError": "Impossibile importare i gruppi" }, "ProjectStatusBadge": { @@ -857,4 +901,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 8680aa46c..19627fd7e 100644 --- a/src/i18n/lang/nl.json +++ b/src/i18n/lang/nl.json @@ -1,4 +1,35 @@ { + "NamingConstraint": { + "managerTitle": "", + "constraintsTab": "", + "strictBadge": "", + "createConstraintTitle": "", + "updateConstraintTitle": "", + "createListTitle": "", + "updateListTitle": "", + "nameLabel": "", + "strictLabel": "", + "strictHelp": "", + "previewLabel": "", + "ruleSectionTitle": "", + "addPartButton": "", + "emptyParts": "", + "partTypeLabel": "", + "partTypeValuesIn": "", + "partTypeBounded": "", + "partTypeNChars": "", + "elementsHelp": "", + "minLabel": "", + "maxLabel": "", + "maxLengthLabel": "", + "emptyElementsError": "", + "invalidBoundsError": "", + "missingBoundsError": "", + "invalidMaxLengthError": "", + "missingListError": "", + "listInUseErrorTitle": "", + "listInUseErrorMessage": "" + }, "OidcCallbackError": { "message": "Er heeft zich een fout voorgedaan bij de authenticatie", "retryButtonText": "" @@ -359,6 +390,19 @@ "invitationViewDeclineError": "", "folderFetchFolder": "", "bcfDeleteError": "", + "namingConstraintsFetchError": "", + "namingConstraintFetchError": "", + "namingConstraintCreateError": "", + "namingConstraintUpdateError": "", + "namingConstraintDeleteError": "", + "namingPartListsFetchError": "", + "namingPartListCreateError": "", + "namingPartListUpdateError": "", + "namingPartListDeleteError": "", + "folderNamingConstraintFetchError": "", + "folderNamingConstraintSetError": "", + "folderNamingConstraintDeleteError": "", + "conflictingDocumentsFetchError": "", "groupImportError": "" }, "ProjectStatusBadge": { @@ -853,4 +897,4 @@ "FileTreePreviewModal": { "title": "" } -} \ No newline at end of file +} diff --git a/src/i18n/lang/no.json b/src/i18n/lang/no.json index 610a8e9d8..39477d4ed 100644 --- a/src/i18n/lang/no.json +++ b/src/i18n/lang/no.json @@ -1,4 +1,35 @@ { + "NamingConstraint": { + "managerTitle": "", + "constraintsTab": "", + "strictBadge": "", + "createConstraintTitle": "", + "updateConstraintTitle": "", + "createListTitle": "", + "updateListTitle": "", + "nameLabel": "", + "strictLabel": "", + "strictHelp": "", + "previewLabel": "", + "ruleSectionTitle": "", + "addPartButton": "", + "emptyParts": "", + "partTypeLabel": "", + "partTypeValuesIn": "", + "partTypeBounded": "", + "partTypeNChars": "", + "elementsHelp": "", + "minLabel": "", + "maxLabel": "", + "maxLengthLabel": "", + "emptyElementsError": "", + "invalidBoundsError": "", + "missingBoundsError": "", + "invalidMaxLengthError": "", + "missingListError": "", + "listInUseErrorTitle": "", + "listInUseErrorMessage": "" + }, "OidcCallbackError": { "message": "En feil oppsto under autentiseringen...", "retryButtonText": "" @@ -359,6 +390,19 @@ "invitationViewDeclineError": "", "folderFetchFolder": "", "bcfDeleteError": "", + "namingConstraintsFetchError": "", + "namingConstraintFetchError": "", + "namingConstraintCreateError": "", + "namingConstraintUpdateError": "", + "namingConstraintDeleteError": "", + "namingPartListsFetchError": "", + "namingPartListCreateError": "", + "namingPartListUpdateError": "", + "namingPartListDeleteError": "", + "folderNamingConstraintFetchError": "", + "folderNamingConstraintSetError": "", + "folderNamingConstraintDeleteError": "", + "conflictingDocumentsFetchError": "", "groupImportError": "" }, "ProjectStatusBadge": { @@ -853,4 +897,4 @@ "FileTreePreviewModal": { "title": "" } -} \ No newline at end of file +} diff --git a/src/services/ErrorService.js b/src/services/ErrorService.js index 811667e99..221a8c9c6 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_PART_LISTS_FETCH_ERROR: "namingPartListsFetchError", + NAMING_PART_LIST_CREATE_ERROR: "namingPartListCreateError", + NAMING_PART_LIST_UPDATE_ERROR: "namingPartListUpdateError", + NAMING_PART_LIST_DELETE_ERROR: "namingPartListDeleteError", + 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..9d7f94ea4 --- /dev/null +++ b/src/services/NamingConstraintService.js @@ -0,0 +1,325 @@ +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; + } +} + +/** + * Thrown when a value list cannot be deleted because constraints use it (API responds 409). + * `constraints` holds the `NamingConstraint[]` returned by the API. + */ +class NamingPartListInUseError { + constructor(constraints) { + this.constraints = constraints; + } +} + +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 + ? { + id: constraint.id, + separator: constraint.separator, + parts: constraint.parts, + strict: constraint.strict, + name: constraint.name, + recursive: !!folderConstraint.recursive, + applied_on_folder_id: folderConstraint.applied_on_folder_id, + } + : null; + + folderRuleCache.set(folder.id, effective); + + return effective; + } + + /** + * Updates a naming constraint. Never blocking: its `conflicting_documents` + * lists the documents now in conflict. + */ + async updateNamingConstraint(project, constraint, payload) { + try { + const result = await apiClient.collaborationApi.updateNamingConstraint( + project.cloud.id, + constraint.id, + project.id, + payload, + ); + this.clearFolderRuleCache(); + return result; + } catch (error) { + 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, + }, + }, + ); + this.clearFolderRuleCache(); + 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 part lists (value lists) ------------------------------------- + + async fetchNamingPartLists(project) { + try { + return await apiClient.collaborationApi.getNamingConstraintPartLists( + project.cloud.id, + project.id, + ); + } catch (error) { + ErrorService.handleError(new RuntimeError(ERRORS.NAMING_PART_LISTS_FETCH_ERROR, error)); + return []; + } + } + + async createNamingPartList(project, payload) { + try { + return await apiClient.collaborationApi.createNamingConstraintPartList( + project.cloud.id, + project.id, + payload, + ); + } catch (error) { + throw new RuntimeError(ERRORS.NAMING_PART_LIST_CREATE_ERROR, error); + } + } + + /** + * Updates a value list. Never blocking: its `conflicting_documents` lists + * the documents now in conflict. + */ + async updateNamingPartList(project, partList, payload) { + try { + const result = await apiClient.collaborationApi.updateNamingConstraintPartList( + project.cloud.id, + partList.id, + project.id, + payload, + ); + this.clearFolderRuleCache(); + return result; + } catch (error) { + throw new RuntimeError(ERRORS.NAMING_PART_LIST_UPDATE_ERROR, error); + } + } + + /** + * Throws `NamingPartListInUseError` (with `constraints`) when the list is + * used by constraints (API responds 409). + */ + async deleteNamingPartList(project, partList) { + try { + return await apiClient.collaborationApi.deleteNamingConstraintPartList( + project.cloud.id, + partList.id, + project.id, + ); + } catch (error) { + if (isResponse(error) && error.status === 409) { + const constraints = await error.json(); + throw new NamingPartListInUseError(constraints); + } + throw new RuntimeError(ERRORS.NAMING_PART_LIST_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, NamingPartListInUseError }; + +export default service; diff --git a/src/state/naming-constraints.js b/src/state/naming-constraints.js new file mode 100644 index 000000000..33496498f --- /dev/null +++ b/src/state/naming-constraints.js @@ -0,0 +1,110 @@ +import { reactive, readonly, toRefs } from "vue"; +import NamingConstraintService from "../services/NamingConstraintService.js"; + +const state = reactive({ + namingConstraints: [], + namingPartLists: [], +}); + +// --- 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 part lists (value lists) --------------------------------------- + +const loadNamingPartLists = async (project) => { + const partLists = await NamingConstraintService.fetchNamingPartLists(project); + state.namingPartLists = partLists; + return partLists; +}; + +const createNamingPartList = async (project, payload) => { + const partList = await NamingConstraintService.createNamingPartList(project, payload); + state.namingPartLists = [...state.namingPartLists, partList]; + return partList; +}; + +const updateNamingPartList = async (project, partList, payload) => { + const updated = await NamingConstraintService.updateNamingPartList(project, partList, payload); + state.namingPartLists = state.namingPartLists.map((item) => + item.id === updated.id ? updated : item, + ); + return updated; +}; + +const deleteNamingPartList = async (project, partList) => { + await NamingConstraintService.deleteNamingPartList(project, partList); + state.namingPartLists = state.namingPartLists.filter((item) => item.id !== partList.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, + loadNamingPartLists, + createNamingPartList, + updateNamingPartList, + deleteNamingPartList, + 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..fcdded14f --- /dev/null +++ b/src/utils/naming-constraint.js @@ -0,0 +1,309 @@ +/** + * 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. + * + * @param {String} name + * @returns {String} + */ +function stripExtension(name) { + const dotIndex = name.lastIndexOf("."); + return dotIndex > 0 ? name.slice(0, dotIndex) : name; +} + +/** + * Get the file extension. + * + * @param {String} name + * @returns {String} + */ +function getExtension(name) { + const dotIndex = name.lastIndexOf("."); + return dotIndex > 0 ? name.slice(dotIndex) : ""; +} + +/** + * 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; + } +} + +/** + * Parse a filename according to a naming constraint rule. + * + * This cannot rely on String.split() because the separator can also be part + * of a valid value. For example, with "-" as separator: + * + * rule.parts[0] = { type: "values_in", elements: ["A-B"] } + * + * The parser therefore tries valid values for each part and only treats the + * separator as a delimiter when the remaining parts can also be matched. + * + * @param {String} name + * @param {Object|null} rule + * @returns {String[]|null} + */ +function parseNameParts(name, rule) { + if (!rule?.parts?.length || typeof name !== "string" || !name.length) { + return null; + } + + const baseName = stripExtension(name); + const separator = rule.separator ?? ""; + + if (!separator) { + return rule.parts.length === 1 ? [baseName] : null; + } + + const parsePart = (value, partIndex) => { + const part = rule.parts[partIndex]; + const isLastPart = partIndex === rule.parts.length - 1; + + for (let end = 1; end <= value.length; end++) { + const candidate = value.slice(0, end); + + if (!matchPart(part, candidate)) { + continue; + } + + const remaining = value.slice(end); + + if (isLastPart) { + if (!remaining) { + return [candidate]; + } + + continue; + } + + if (!remaining.startsWith(separator)) { + continue; + } + + const parsedRemaining = parsePart(remaining.slice(separator.length), partIndex + 1); + + if (parsedRemaining) { + return [candidate, ...parsedRemaining]; + } + } + + return null; + }; + + return parsePart(baseName, 0); +} + +/** + * Check whether a file name matches a naming-constraint rule. + * + * Returns true when there is no rule (nothing to enforce). + * + * @param {String} name + * @param {Object|null} rule + * @returns {Boolean} + */ +function matchName(name, rule) { + if (!rule?.parts?.length) { + return true; + } + + return parseNameParts(name, rule) !== null; +} + +/** + * 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. + * + * @param {Object|null} rule + * @returns {String} + */ +function buildExample(rule) { + if (!rule?.parts?.length) { + return ""; + } + + const separator = rule.separator ?? ""; + + return `${rule.parts.map(buildPartExample).join(separator)}.ext`; +} + +/** + * Build default editable values for a rule. + * + * @param {String} basename + * @param {Object} rule + * @returns {String[]} + */ +function buildDefaultValues(basename, rule) { + if (!Array.isArray(rule?.parts)) { + return []; + } + + return 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.slice(0, part.max_length); + + default: + return ""; + } + }); +} + +/** + * Split a filename into editable values and extension. + * + * @param {String} name + * @param {Object|null} rule + * @returns {{values: String[], extension: String}} + */ +function splitName(name, rule) { + const extension = typeof name === "string" ? getExtension(name) : ""; + + if (!Array.isArray(rule?.parts) || rule.parts.length === 0) { + return { + values: [], + extension, + }; + } + + const parsedValues = parseNameParts(name, rule); + + if (parsedValues) { + return { + values: parsedValues, + extension, + }; + } + + return { + values: buildDefaultValues(stripExtension(name), rule), + extension, + }; +} + +/** + * Left-pad a bounded value according to its max_value. + * + * max_value=999 -> 001 + * max_value=99 -> 01 + * + * @param {String|Number} value + * @param {Object} part + * @returns {String} + */ +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. + * + * @param {String[]} values + * @param {Object} rule + * @param {String} extension + * @returns {String} + */ +function buildName(values, rule, extension = "") { + if (!rule) { + return ""; + } + + const separator = rule.separator ?? ""; + + const name = values + .map((value, index) => { + const part = rule.parts[index]; + + if (part?.type === PART_TYPES.BOUNDED) { + return padBoundedValue(value, part); + } + + return value; + }) + .join(separator); + + return name + extension; +} + +export { + PART_TYPES, + matchName, + matchPart, + parseNameParts, + 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..68ad8ad62 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,19 @@ export default { const { currentProject } = useProjects(); const { loadProjectModels } = useModels(); const { projectFileStructure, loadProjectFileStructure } = useFiles(); + const { openSidePanel } = useAppSidePanel(); - const reloadData = debounce(async () => { - await Promise.all([ + const reloadData = async () => { + const [, fileStructure] = await Promise.all([ loadSpaceSubInfo(currentSpace.value), loadProjectFileStructure(currentProject.value), - loadProjectModels(currentProject.value) + loadProjectModels(currentProject.value), ]); - }, 1000); + + return fileStructure; + }; + + const reloadDataDebounced = debounce(reloadData, 1000); return { // References @@ -89,10 +96,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"); + }); +});