diff --git a/package-lock.json b/package-lock.json index de2a56a07..392a2e19e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@bimdata/bcf-components": "6.7.7", "@bimdata/components": "1.10.1", "@bimdata/design-system": "2.4.2", - "@bimdata/typescript-fetch-api-client": "10.41.0", + "@bimdata/typescript-fetch-api-client": "10.44.2", "@bimdata/viewer": "2.17.1-alpha.fragments.3", "@paddle/paddle-js": "^1.6.4", "async": "^3.2.6", @@ -1882,9 +1882,9 @@ "integrity": "sha512-pldzXuZEL4oUzjxnd0+AVlM551yDag4mXJCehKAvfScdhq4bt1Iks17ux1yk4Ppt+dFJTb2EO0vYBuipaBRq9Q==" }, "node_modules/@bimdata/typescript-fetch-api-client": { - "version": "10.41.0", - "resolved": "https://registry.npmjs.org/@bimdata/typescript-fetch-api-client/-/typescript-fetch-api-client-10.41.0.tgz", - "integrity": "sha512-/CCvzpbuyb1pSkvuKvskiSscic8Z4GplkKHBn48RGfsCe6t2yzIakg78tQp3wnFSpRBSr2BBOXYhuq44B9MzpA==" + "version": "10.44.2", + "resolved": "https://registry.npmjs.org/@bimdata/typescript-fetch-api-client/-/typescript-fetch-api-client-10.44.2.tgz", + "integrity": "sha512-R465512WDHqbHY0PcVKMBtM5rL/EKJmmYC8ZHqw2CzHK1Ro+kD0LpHXtbmxNN0Yf149gqQ2wWJR3x1ye9IDkzw==" }, "node_modules/@bimdata/viewer": { "version": "2.17.1-alpha.fragments.3", diff --git a/package.json b/package.json index 3305d15b5..b9ca06886 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@bimdata/bcf-components": "6.7.7", "@bimdata/components": "1.10.1", "@bimdata/design-system": "2.4.2", - "@bimdata/typescript-fetch-api-client": "10.41.0", + "@bimdata/typescript-fetch-api-client": "10.44.2", "@bimdata/viewer": "2.17.1-alpha.fragments.3", "@paddle/paddle-js": "^1.6.4", "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..17d9c9571 100644 --- a/src/components/specific/files/files-manager/FilesManager.vue +++ b/src/components/specific/files/files-manager/FilesManager.vue @@ -11,6 +11,7 @@ :initialSearchText="searchText" @update:searchText="searchText = $event" @upload-files="uploadFiles" + @manage-naming-conflicts="openNamingConflictsModal" />
@@ -88,6 +89,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 +113,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 +208,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 +230,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"; @@ -280,7 +287,8 @@ export default { const { createModel, createPhotosphere, deleteModels } = useModels(); const { fetchToValidateVisas, fetchCreatedVisas } = useVisa(); - + const { getEffectiveFolderRule } = + useNamingConstraints(); const currentFolder = ref(null); const currentFiles = ref([]); const toValidateVisas = ref([]); @@ -337,20 +345,57 @@ 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, 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 fromEvent = await getFilesFromEvent(event); + const files = fromEvent.files; + folders = fromEvent.folders; + + const rule = await getEffectiveFolderRule(props.project, folder); + const invalidFiles = rule?.rule + ? files.filter((file) => !matchName(file.name, rule.rule)) + : []; + + if (invalidFiles.length > 0) { + openModal({ + component: NamingConflictModal, + props: { + project: props.project, + documents: invalidFiles.map((file, i) => ({ id: `upload-${i}`, name: file.name })), + rule, + persistChanges: false, + onClose: closeModal, + onConfirm: ({ renamed, deleted }) => { + const deletedIds = new Set(deleted.map((d) => d.id)); + const renamedById = new Map(renamed.map((r) => [r.id, r.name])); + const finalFiles = invalidFiles + .map((file, i) => ({ file, id: `upload-${i}` })) + .filter(({ id }) => !deletedIds.has(id)) + .map(({ file, id }) => { + const name = renamedById.get(id); + return name ? new File([file], name, { type: file.type }) : file; + }); + const validFiles = files.filter((file) => matchName(file.name, rule.rule)); + proceedUpload([...validFiles, ...finalFiles], folder); + }, + }, + }); + return; + } + + proceedUpload(files, folder); + }; const loadingFileIds = ref([]); const isCreatingModels = ref(false); @@ -544,6 +589,50 @@ export default { }, 100); }; + const openFolderNamingConstraintManager = (folder) => { + openSidePanel("right", { + component: FolderNamingConstraintManager, + props: { + project: props.project, + folder, + }, + }); + }; + + const openNamingConflictsModal = async () => { + const conflicting = allFiles.value.filter( + (file) => file.naming_constraint_conflict, + ); + if (conflicting.length === 0) { + pushNotification({ + type: "success", + title: t("NamingConstraint.noConflictsTitle"), + message: t("NamingConstraint.noConflictsMessage"), + }); + return; + } + const documents = await Promise.all( + conflicting.map(async (file) => { + const folder = allFolders.value.find((f) => f.id === file.parent_id); + const effective = folder + ? await getEffectiveFolderRule(props.project, folder) + : null; + return { ...file, namingRule: effective?.rule ?? null }; + }), + ); + openModal({ + component: NamingConflictModal, + props: { + project: props.project, + documents, + allFolders: allFolders.value, + rule: null, + onClose: closeModal, + onConfirm: () => emit("file-updated"), + }, + }); + }; + const visasLoading = ref(false); const openVisaManager = (file) => { onTabChange(filesTabs[2]); @@ -833,6 +922,8 @@ export default { 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..96060e458 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,23 @@ clear />
+ +
+ + + + {{ $t("NamingConstraint.managerTitle") }} + + +
@@ -111,18 +128,23 @@ import { computed, ref, inject, watch } from "vue"; import { useI18n } from "vue-i18n"; import { useAppModal } from "../../../app/app-modal/app-modal.js"; +import { useAppSidePanel } from "../../../../../components/specific/app/app-side-panel/app-side-panel.js"; import { useStandardBreakpoints, useCustomBreakpoints, } from "../../../../../composables/responsive.js"; import { useFiles } from "../../../../../state/files.js"; +import { useProjects } from "../../../../../state/projects.js"; import { useUser } from "../../../../../state/user.js"; import { isFullTotal } from "../../../../../utils/spaces.js"; +import { collectDescendants } from "../../../../../utils/file-tree.js"; +import { isFolder } from "../../../../../utils/file-structure.js"; import { fileUploadInput } from "../../../../../utils/upload.js"; // Components import FileDragAndDropModal from "../file-drag-and-drop-modal/FileDragAndDropModal.vue"; import FolderCreationButton from "../../folder-creation-button/FolderCreationButton.vue"; +import NamingConstraintsManager from "../../../../../components/specific/files/naming-constraint/NamingConstraintsManager.vue"; export default { components: { @@ -157,25 +179,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 +221,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 +264,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 +273,28 @@ export default { const isLargeLayout = computed( () => (isProjectAdmin(props.project) && !isXXXL.value) || - (!isProjectAdmin(props.project) && !isMidXXL.value) + (!isProjectAdmin(props.project) && !isMidXXL.value), ); const isMediumLayout = computed( () => (isProjectAdmin(props.project) && !isXL.value && isXXXL.value) || - (!isProjectAdmin(props.project) && !isMD.value && isMidXXL.value) + (!isProjectAdmin(props.project) && !isMD.value && isMidXXL.value), ); - const searchText = ref(props.initialSearchText || ''); + const searchText = ref(props.initialSearchText || ""); watch(searchText, (newValue) => { - emit('update:searchText', newValue); + emit("update:searchText", newValue); }); + const openNamingConstraintsManager = () => { + openSidePanel("right", { + component: NamingConstraintsManager, + props: { + project: currentProject.value, + }, + }); + }; + return { // References dropdown, @@ -255,11 +309,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..cd47c905a 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 @@ -82,6 +82,7 @@ export default { "download", "file-clicked", "manage-access", + "manage-naming-rule", "open-tag-manager", "open-versioning-manager", "open-visa-manager", @@ -96,6 +97,8 @@ export default { const isOpen = ref(false); const menuItems = shallowRef([]); + let current_key = 0; + const openMenu = () => { if (!props.parent) return; @@ -103,7 +106,7 @@ export default { if (!isFolder(props.file)) { menuItems.value.push({ - key: 1, + key: current_key++, icon: "preview", text: "FileActionsCell.previewModelButtonText", color: "primary", @@ -114,7 +117,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,7 +128,7 @@ 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), @@ -133,7 +136,7 @@ export default { }); } else { menuItems.value.push({ - key: 4, + key: current_key++, iconComponent: RemoveModelsIcon, text: "FileActionsCell.removeModelButtonText", action: () => onClick("remove-model"), @@ -143,7 +146,7 @@ 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), @@ -152,7 +155,7 @@ export default { } menuItems.value.push({ - key: 5, + key: current_key++, icon: "edit", text: "t.rename", disabled: !hasAdminPerm(props.project, props.file), @@ -168,31 +171,38 @@ export default { if (isFolder(props.file) && isProjectAdmin(props.project)) { menuItems.value.push({ - key: 7, + key: current_key++, icon: "key", text: "FileActionsCell.manageAccessButtonText", action: () => onClick("manage-access"), divider: true, }); + menuItems.value.push({ + key: current_key++, + iconComponent: "BIMDataIconLock", + text: "NamingConstraint.folderRuleMenuItem", + action: () => onClick("manage-naming-rule"), + dataTestId: "btn-manage-naming-rule", + }); } 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 +212,7 @@ export default { } menuItems.value.push({ - key: 11, + key: current_key++, icon: "delete", text: "t.delete", color: "high", 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..8aaa200d8 100644 --- a/src/components/specific/files/files-table/file-name-cell/FileNameCell.vue +++ b/src/components/specific/files/files-table/file-name-cell/FileNameCell.vue @@ -13,7 +13,7 @@ @keyup.esc.stop="closeUpdateForm" @keyup.enter.stop="renameFile" :error="hasError" - :errorMessage="$t('t.invalidName')" + :errorMessage="errorMessage" margin="0" /> + + +
props.file?.history_count > 0); const renameFile = debounce(async () => { if (fileName.value) { + const rule = isFolder(props.file) + ? null + : await getEffectiveFolderRule(props.project, { + id: props.file.parent_id, + }); + if (rule?.rule && !matchName(fileName.value, rule.rule)) { + if (rule.strict) { + hasError.value = true; + errorMessage.value = t("t.invalidNameFormat", { + example: buildExample(rule.rule), + }); + nameInput.value.focus(); + return; + } + pushNotification({ + type: "warning", + title: t("NamingConstraint.applyRuleWarningTitle"), + message: t("t.invalidNameFormat", { + example: buildExample(rule.rule), + }), + }); + } try { loading.value = true; await updateFiles(props.project, [ @@ -119,6 +162,7 @@ export default { } } else { hasError.value = true; + errorMessage.value = t("t.invalidName"); nameInput.value.focus(); } }, 500); @@ -132,6 +176,7 @@ export default { const closeUpdateForm = () => { loading.value = false; hasError.value = false; + errorMessage.value = ""; showUpdateForm.value = false; emit("close"); }; @@ -162,6 +207,7 @@ export default { // References fileName, hasError, + errorMessage, loading, nameInput, showUpdateForm, diff --git a/src/components/specific/files/folder-table/FoldersTable.vue b/src/components/specific/files/folder-table/FoldersTable.vue index 76c4e7a55..ea2f94be7 100644 --- a/src/components/specific/files/folder-table/FoldersTable.vue +++ b/src/components/specific/files/folder-table/FoldersTable.vue @@ -96,6 +96,7 @@ @download="$emit('download', file)" @file-clicked="$emit('file-clicked', file)" @manage-access="$emit('manage-access', file)" + @manage-naming-rule="$emit('manage-naming-rule', file)" @open-tag-manager="$emit('open-tag-manager', file)" @open-versioning-manager="$emit('open-versioning-manager', file)" @open-visa-manager="$emit('open-visa-manager', file)" @@ -172,6 +173,7 @@ export default { "file-clicked", "file-uploaded", "manage-access", + "manage-naming-rule", "open-tag-manager", "open-versioning-manager", "open-visa-manager", diff --git a/src/components/specific/files/naming-constraint/FolderNamingConstraintManager.css b/src/components/specific/files/naming-constraint/FolderNamingConstraintManager.css new file mode 100644 index 000000000..2acaca05f --- /dev/null +++ b/src/components/specific/files/naming-constraint/FolderNamingConstraintManager.css @@ -0,0 +1,46 @@ +.folder-naming-constraint-manager { + display: flex; + flex-direction: column; + height: 100%; + background-color: var(--color-white); +} +.folder-naming-constraint-manager__header { + display: flex; + align-items: center; + gap: 12px; + min-height: 52px; + border-bottom: 1px solid var(--color-silver-light); +} +.folder-naming-constraint-manager__header__back { + margin-left: -6px; +} +.folder-naming-constraint-manager__header__title { + flex-grow: 1; + font-size: 16px; + font-weight: 500; + color: var(--color-primary); +} +.folder-naming-constraint-manager__content { + position: relative; + flex: 1; + overflow-y: auto; +} +.folder-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/FolderNamingConstraintManager.vue b/src/components/specific/files/naming-constraint/FolderNamingConstraintManager.vue new file mode 100644 index 000000000..57a497f04 --- /dev/null +++ b/src/components/specific/files/naming-constraint/FolderNamingConstraintManager.vue @@ -0,0 +1,129 @@ + + + + + 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..782abe2a2 --- /dev/null +++ b/src/components/specific/files/naming-constraint/NamingConflictModal.css @@ -0,0 +1,31 @@ +.naming-conflict-modal__content { + display: flex; + flex-direction: column; + gap: 16px; + text-align: left; +} +.naming-conflict-modal__rule { + display: flex; + align-items: center; + gap: 12px; +} +.naming-conflict-modal__rule__name { + font-weight: 700; + font-size: 12px; + color: var(--color-primary); +} +.naming-conflict-modal__rule__chip { + padding: 3px 7px; + background-color: var(--color-neutral-lighter); + color: var(--color-neutral); + border-radius: 6px; + font-size: 12px; +} +.naming-conflict-modal__intro, .naming-conflict-modal__warning { + margin: 0; + font-size: 14px; + color: var(--color-granite); +} +.naming-conflict-modal__warning { + font-weight: 700; +} \ 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..3e9393c6e --- /dev/null +++ b/src/components/specific/files/naming-constraint/NamingConflictModal.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/src/components/specific/files/naming-constraint/NamingConstraintsManager.css b/src/components/specific/files/naming-constraint/NamingConstraintsManager.css new file mode 100644 index 000000000..51b512e2f --- /dev/null +++ b/src/components/specific/files/naming-constraint/NamingConstraintsManager.css @@ -0,0 +1,53 @@ +.naming-constraints-manager { + display: flex; + flex-direction: column; + height: 100%; + background-color: var(--color-white); + + & .naming-constraints-manager__header { + display: flex; + align-items: center; + gap: 12px; + min-height: 34px; + + & .naming-constraints-manager__header__icon { + width: 40px; + } + + & .naming-constraints-manager__header__back { + margin-left: -6px; + } + + & .naming-constraints-manager__header__title { + flex-grow: 1; + font-size: 12px; + color: var(--color-primary); + text-align: center; + } + } + + & .naming-constraints-manager__content { + height: calc(100% - 34px); + position: relative; + flex: 1; + } + + & .naming-constraints-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..c3aa2a167 --- /dev/null +++ b/src/components/specific/files/naming-constraint/NamingConstraintsManager.vue @@ -0,0 +1,132 @@ + + + + + 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..0b55f5336 --- /dev/null +++ b/src/components/specific/files/naming-constraint/conflicting-documents-list/ConflictingDocumentsList.css @@ -0,0 +1,56 @@ +.conflicting-documents-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 320px; + overflow-y: auto; + margin: 0; + padding: 0; + list-style: none; +} +.conflicting-documents-list__item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + background-color: var(--color-tertiary-lightest); + border-radius: 6px; + box-shadow: var(--box-shadow); +} +.conflicting-documents-list__item__name { + flex: 1; + font-size: 13px; + color: var(--color-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.conflicting-documents-list__item__info { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} +.conflicting-documents-list__item__example { + font-size: 11px; + color: var(--color-granite-light); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.conflicting-documents-list__item__path { + font-size: 11px; +} +.conflicting-documents-list__item__input { + flex: 1; +} +.conflicting-documents-list__item--editing { + background-color: var(--color-white); +} +.conflicting-documents-list__item--invalid .conflicting-documents-list__item__name { + color: var(--color-high); +} +.conflicting-documents-list__item--deleted .conflicting-documents-list__item__name { + color: var(--color-granite-light); + text-decoration: line-through; +} \ No newline at end of file diff --git a/src/components/specific/files/naming-constraint/conflicting-documents-list/ConflictingDocumentsList.vue b/src/components/specific/files/naming-constraint/conflicting-documents-list/ConflictingDocumentsList.vue new file mode 100644 index 000000000..fa65a0c04 --- /dev/null +++ b/src/components/specific/files/naming-constraint/conflicting-documents-list/ConflictingDocumentsList.vue @@ -0,0 +1,165 @@ + + + + + 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..a4e24b724 --- /dev/null +++ b/src/components/specific/files/naming-constraint/folder-naming-constraint-selector/FolderNamingConstraintSelector.css @@ -0,0 +1,108 @@ +.folder-naming-constraint-selector { + display: flex; + flex-direction: column; + gap: 18px; + 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; + font-weight: 500; + 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__items { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} +.folder-naming-constraint-selector__item { + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + border: 1px solid transparent; + border-radius: 6px; + background-color: var(--color-white); + box-shadow: var(--box-shadow); + 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 { + font-weight: 600; + color: var(--color-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.folder-naming-constraint-selector__item__badges { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.folder-naming-constraint-selector__item__chip { + padding: 2px 8px; + border-radius: 6px; + background-color: #F0F5FF; + color: var(--color-neutral); + font-family: monospace; + font-size: 12px; +} +.folder-naming-constraint-selector__item__chip--strict { + background-color: var(--color-warning-lightest, var(--color-silver-light)); + color: var(--color-warning); + font-family: inherit; +} +.folder-naming-constraint-selector__footer { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 12px; + border-top: 1px solid var(--color-silver-light); +} +.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..2540af868 --- /dev/null +++ b/src/components/specific/files/naming-constraint/folder-naming-constraint-selector/FolderNamingConstraintSelector.vue @@ -0,0 +1,251 @@ + + + + + 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..d1854ad79 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraint-form/NamingConstraintForm.css @@ -0,0 +1,85 @@ +.naming-constraint-form { + display: flex; + flex-direction: column; + gap: 16px; +} +.naming-constraint-form__title { + font-size: 16px; + font-weight: 600; + text-align: center; + color: var(--color-primary); +} +.naming-constraint-form__step { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + 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 { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 6px; + background-color: var(--color-tertiary-lightest, var(--color-silver-light)); +} +.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-primary); +} +.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..a46087d24 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraint-form/NamingConstraintForm.vue @@ -0,0 +1,273 @@ + + + + + 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..99346d1bd --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraint-form/RuleBuilder.css @@ -0,0 +1,94 @@ +.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__type-label { + flex-shrink: 0; + min-width: 100px; + font-size: 12px; + color: var(--color-primary); +} +.rule-builder__parts { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; + .rule-builder__part { + padding: 6px 12px; + display: flex; + align-items: flex-start; + gap: 8px; + border-radius: 6px; + background-color: var(--color-white); + .rule-builder__part__grip { + display: grid; + grid-template-columns: repeat(2, 4px); + gap: 3px; + margin-top: 8px; + } + .rule-builder__part__dot { + width: 4px; + height: 4px; + border-radius: 50%; + background-color: var(--color-granite-light); + } + .rule-builder__part__body { + flex-grow: 1; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + } + .rule-builder__part__row { + display: flex; + align-items: center; + gap: 8px; + } + .rule-builder__part__actions { + display: flex; + gap: 2px; + } + } +} +.rule-builder__number, .rule-builder__elements { + height: 32px; + padding: 0 8px; + border: 1px solid var(--color-silver); + border-radius: 4px; + background-color: var(--color-white); + color: var(--color-primary); + font-size: 13px; +} +/* .rule-builder__select--list { + flex-grow: 1; +} */ +.rule-builder__number { + width: 72px; +} +.rule-builder__dash { + color: var(--color-granite); +} +.rule-builder__elements { + width: 100%; +} \ 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..b95714208 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraint-form/RuleBuilder.vue @@ -0,0 +1,260 @@ + + + + + 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..67ca5dc48 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraints-list/NamingConstraintsList.css @@ -0,0 +1,117 @@ +.naming-constraints-list { + height: 100%; + display: flex; + flex-direction: column; + gap: 18px; +} +.naming-constraints-list__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.naming-constraints-list__head__title { + font-size: 14px; + color: var(--color-primary); +} +.naming-constraints-list__head__actions { + gap: var(--spacing-unit); + width: 100%; + .bimdata-btn { + flex: 1; + } +} +.naming-constraints-list__empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 24px 12px; + text-align: center; +} +.naming-constraints-list__empty__title { + font-size: 16px; + font-weight: 600; + color: var(--color-primary); +} +.naming-constraints-list__empty__text { + max-width: 280px; + font-size: 13px; + line-height: 18px; + color: var(--color-granite); +} +.naming-constraints-list__search { + min-height: 32px; +} +.naming-constraints-list__items { + height: 100%; + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 6px; + list-style: none; + overflow-y: auto; +} +.naming-constraints-list__item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 6px; + background-color: var(--color-white); + box-shadow: var(--box-shadow); +} +.naming-constraints-list__item__main { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + flex-grow: 1; +} +.naming-constraints-list__item__name { + font-weight: 600; + color: var(--color-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.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__chip { + padding: 2px 8px; + border-radius: 6px; + background-color: #F0F5FF; + color: var(--color-neutral); + font-family: monospace; + font-size: 12px; +} +.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; +} +.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..58cc48d0a --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-constraints-list/NamingConstraintsList.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/src/components/specific/files/naming-constraint/naming-parts-template-form/NamingPartsTemplateForm.css b/src/components/specific/files/naming-constraint/naming-parts-template-form/NamingPartsTemplateForm.css new file mode 100644 index 000000000..dfa52759d --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-parts-template-form/NamingPartsTemplateForm.css @@ -0,0 +1,72 @@ +.naming-parts-template-form__container { + height: 100%; +} +.naming-parts-template-form { + display: flex; + flex-direction: column; + gap: 16px; + padding: 18px 0; +} +.naming-parts-template-form__header { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} +.naming-parts-template-form__header__title { + font-size: 16px; + font-weight: 600; + color: var(--color-primary); +} +.naming-parts-template-form__step { + width: 100%; + display: flex; + flex-direction: column; + gap: 18px; + padding: 14px; + border-radius: 8px; + background-color: var(--color-silver-light); +} +.naming-parts-template-form__step__head { + display: flex; + align-items: center; + gap: 8px; +} +.naming-parts-template-form__step__num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + background-color: var(--color-primary); + color: var(--color-white); + font-size: 12px; +} +.naming-parts-template-form__step__label { + font-weight: 600; + color: var(--color-primary); +} +.naming-parts-template-form__element { + display: flex; + align-items: flex-end; + gap: 12px; + .bimdata-input { + flex: 1; + } +} +.naming-parts-template-form__add { + align-self: flex-start; +} +.naming-parts-template-form__error { + font-size: 12px; + color: var(--color-high); +} +.naming-parts-template-form__actions { + display: flex; + justify-content: flex-end; + gap: 12px; + .bimdata-btn { + flex: 1; + } +} \ No newline at end of file diff --git a/src/components/specific/files/naming-constraint/naming-parts-template-form/NamingPartsTemplateForm.vue b/src/components/specific/files/naming-constraint/naming-parts-template-form/NamingPartsTemplateForm.vue new file mode 100644 index 000000000..cbdae42ae --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-parts-template-form/NamingPartsTemplateForm.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/src/components/specific/files/naming-constraint/naming-parts-templates-list/NamingPartsTemplatesList.css b/src/components/specific/files/naming-constraint/naming-parts-templates-list/NamingPartsTemplatesList.css new file mode 100644 index 000000000..5c7b1a969 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-parts-templates-list/NamingPartsTemplatesList.css @@ -0,0 +1,91 @@ +.naming-parts-templates-list { + display: flex; + flex-direction: column; + gap: 18px; + height: 100%; +} +.naming-parts-templates-list__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + .naming-parts-templates-list__head__title { + font-size: 16px; + font-weight: 500; + color: var(--color-primary); + } +} +.naming-parts-templates-list__search { + min-height: 32px; +} +.naming-parts-templates-list__empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 24px 12px; + text-align: center; + .naming-parts-templates-list__empty__title { + font-size: 16px; + font-weight: 600; + color: var(--color-primary); + } + .naming-parts-templates-list__empty__text { + max-width: 280px; + font-size: 13px; + line-height: 18px; + color: var(--color-granite); + } +} +.naming-parts-templates-list__items { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 6px; + list-style: none; + overflow: auto; +} +.naming-parts-templates-list__item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 6px; + background-color: var(--color-white); + box-shadow: var(--box-shadow); +} +.naming-parts-templates-list__item__main { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + flex-grow: 1; +} +.naming-parts-templates-list__item__name { + font-weight: 600; + color: var(--color-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.naming-parts-templates-list__item__badges { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.naming-parts-templates-list__item__chip { + padding: 2px 8px; + border-radius: 6px; + background-color: var(--color-neutral-lighter); + color: var(--color-neutral); + font-size: 12px; +} +.naming-parts-templates-list__item__actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} \ No newline at end of file diff --git a/src/components/specific/files/naming-constraint/naming-parts-templates-list/NamingPartsTemplatesList.vue b/src/components/specific/files/naming-constraint/naming-parts-templates-list/NamingPartsTemplatesList.vue new file mode 100644 index 000000000..29d4e0551 --- /dev/null +++ b/src/components/specific/files/naming-constraint/naming-parts-templates-list/NamingPartsTemplatesList.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/src/i18n/lang/de.json b/src/i18n/lang/de.json index 18fc239e7..7bd75713c 100644 --- a/src/i18n/lang/de.json +++ b/src/i18n/lang/de.json @@ -1,4 +1,43 @@ { + "NamingConstraint": { + "managerTitle": "", + "constraintsTab": "", + "templatesTab": "", + "constraintsListCreateButton": "", + "constraintsListEmpty": "", + "templatesListCreateButton": "", + "templatesListEmpty": "", + "strictBadge": "", + "nonStrictBadge": "", + "createConstraintTitle": "", + "updateConstraintTitle": "", + "createTemplateTitle": "", + "updateTemplateTitle": "", + "nameLabel": "", + "strictLabel": "", + "strictHelp": "", + "previewLabel": "", + "ruleSectionTitle": "", + "separatorLabel": "", + "addPartButton": "", + "emptyParts": "", + "partTypeLabel": "", + "partTypeValuesIn": "", + "partTypeBounded": "", + "partTypeNChars": "", + "elementsLabel": "", + "elementsHelp": "", + "loadFromTemplateLabel": "", + "minLabel": "", + "maxLabel": "", + "maxLengthLabel": "", + "templateElementsLabel": "", + "templateElementsHelp": "", + "emptyElementsError": "", + "invalidBoundsError": "", + "deleteConstraintConfirm": "", + "deleteTemplateConfirm": "" + }, "OidcCallbackError": { "message": "Bei der Authentifizierung ist ein Fehler aufgetreten...", "retryButtonText": "Nochmals versuchen" @@ -440,6 +479,19 @@ "invitationViewAcceptError": "Die Einladung kann nicht angenommen werden", "folderFetchFolder": "Fehler beim Abrufen von Ordnerinformationen.", "bcfDeleteError": "Das Löschen ist fehlgeschlagen...", + "namingConstraintsFetchError": "", + "namingConstraintFetchError": "", + "namingConstraintCreateError": "", + "namingConstraintUpdateError": "", + "namingConstraintDeleteError": "", + "namingPartsTemplatesFetchError": "", + "namingPartsTemplateCreateError": "", + "namingPartsTemplateUpdateError": "", + "namingPartsTemplateDeleteError": "", + "folderNamingConstraintFetchError": "", + "folderNamingConstraintSetError": "", + "folderNamingConstraintDeleteError": "", + "conflictingDocumentsFetchError": "", "groupImportError": "Fehler beim Importieren von Gruppen", "bcfExportXlsxError": "Der Excel-Export ist fehlgeschlagen...", "invitationViewDeclineError": "Einladung kann nicht abgelehnt werden" @@ -971,4 +1023,4 @@ "title": "Löschen der {visasCount} Freigaben", "message": "Sie sind dabei, die Freigaben für die folgenden Dateien zu löschen:" } -} \ No newline at end of file +} diff --git a/src/i18n/lang/en.json b/src/i18n/lang/en.json index 9635601d3..253c79323 100644 --- a/src/i18n/lang/en.json +++ b/src/i18n/lang/en.json @@ -1,4 +1,91 @@ { + "NamingConstraint": { + "managerTitle": "Naming convention management", + "constraintsTab": "Naming rules", + "templatesTab": "Value lists", + "rulesSectionTitle": "Rules list", + "addRuleButton": "add a rule", + "searchPlaceholder": "Search", + "rulesEmptyTitle": "No rule added", + "rulesEmptyText": "You don't have any rule yet, start by adding one.", + "manageListsButton": "Manage lists", + "listsSectionTitle": "Lists management", + "createListButton": "Create a list", + "listsEmptyTitle": "No list added", + "listsEmptyText": "You don't have any list yet, start by creating one.", + "constraintsListCreateButton": "New naming rule", + "constraintsListEmpty": "No naming rule yet.", + "templatesListCreateButton": "New value list", + "templatesListEmpty": "No value list yet.", + "strictBadge": "Strict", + "nonStrictBadge": "Non-strict", + "createConstraintTitle": "Creating a rule", + "updateConstraintTitle": "Editing a rule", + "createTemplateTitle": "Creating my list", + "updateTemplateTitle": "Editing my list", + "nameLabel": "Name", + "ruleNameStep": "Rule name", + "ruleNamePlaceholder": "Rule name", + "separatorStep": "Separator type", + "separatorDashOption": "- (dash)", + "separatorDotOption": ". (dot)", + "separatorUnderscoreOption": "_ (underscore)", + "structureSectionTitle": "Rule structure", + "strictLabel": "Strict rule", + "strictHelp": "If you check this box, you will have to rename every file that does not match this new rule.", + "previewLabel": "Preview", + "ruleSectionTitle": "Rule", + "separatorLabel": "Separator", + "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", + "elementsLabel": "Allowed values", + "elementsHelp": "Comma-separated list of allowed values.", + "loadFromTemplateLabel": "Load from a list", + "minLabel": "Min", + "maxLabel": "Max", + "maxLengthLabel": "Max length", + "saveRuleButton": "Save the rule", + "listNameStep": "List name", + "listNamePlaceholder": "List name", + "addElementsStep": "Add elements", + "elementNamePlaceholder": "Element name", + "removeElementButton": "Remove", + "addElementButton": "Add an element", + "saveListButton": "Save the list", + "templateElementsLabel": "Values", + "templateElementsHelp": "Comma-separated list of values.", + "emptyElementsError": "Provide at least one value.", + "invalidBoundsError": "Max must be greater than or equal to min.", + "deleteConstraintConfirm": "Delete this naming rule?", + "deleteTemplateConfirm": "Delete this 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.", + "applyRuleConflictTitle": "Some files do not match", + "applyRuleConflictMessage": "The rule was applied but {count} existing file(s) do not match it.", + "applyRuleBlockedTitle": "Strict rule cannot be applied", + "applyRuleBlockedMessage": "{count} existing file(s) do not match this strict rule. Rename them first.", + "applyRuleError": "The naming convention could not be applied.", + "noFolderRuleSelection": "Select a rule to apply.", + "applyRuleWarningTitle": "Name does not match the rule", + "uploadBlocked": "{count} file(s) were not uploaded because their name does not match the rule (e.g. {example}).", + "uploadWarning": "{count} file(s) do not match the naming rule (e.g. {example}).", + "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", + "renameButton": "Rename", + "renameFilePlaceholder": "Rename the file", + "renameConflictsMenuItem": "Rename conflicting files", + "noConflictsTitle": "No conflicting files", + "noConflictsMessage": "All files match their folder naming convention." + }, "OidcCallbackError": { "message": "An error occured during authentication...", "retryButtonText": "Try Again" @@ -510,6 +597,19 @@ "invitationViewAcceptError": "Unable to accept the invitation", "folderFetchFolder": "Fail to retrieve folder data", "bcfDeleteError": "Deletion failed", + "namingConstraintsFetchError": "Unable to retrieve naming rules", + "namingConstraintFetchError": "Unable to retrieve this naming rule", + "namingConstraintCreateError": "Unable to create this naming rule", + "namingConstraintUpdateError": "Unable to update this naming rule", + "namingConstraintDeleteError": "Unable to delete this naming rule", + "namingPartsTemplatesFetchError": "Unable to retrieve naming templates", + "namingPartsTemplateCreateError": "Unable to create this naming template", + "namingPartsTemplateUpdateError": "Unable to update this naming template", + "namingPartsTemplateDeleteError": "Unable to delete this naming template", + "folderNamingConstraintFetchError": "Unable to retrieve the folder naming rule", + "folderNamingConstraintSetError": "Unable to apply the naming rule on this folder", + "folderNamingConstraintDeleteError": "Unable to remove the naming rule from this folder", + "conflictingDocumentsFetchError": "Unable to retrieve conflicting documents", "groupImportError": "Group import failure", "bcfExportXlsxError": "xlsx export failed...", "invitationViewDeclineError": "Unable to decline invitation" @@ -982,6 +1082,7 @@ "folder": "Folder", "import": "Import", "invalidName": "Invalid name", + "invalidNameFormat": "Name does not match the naming rule (e.g. {example}).", "leave": "Leave", "modifiedOn": "Last modified", "modify": "Edit", diff --git a/src/i18n/lang/es.json b/src/i18n/lang/es.json index 01fb515f9..f8a9d6459 100644 --- a/src/i18n/lang/es.json +++ b/src/i18n/lang/es.json @@ -1,4 +1,43 @@ { + "NamingConstraint": { + "managerTitle": "", + "constraintsTab": "", + "templatesTab": "", + "constraintsListCreateButton": "", + "constraintsListEmpty": "", + "templatesListCreateButton": "", + "templatesListEmpty": "", + "strictBadge": "", + "nonStrictBadge": "", + "createConstraintTitle": "", + "updateConstraintTitle": "", + "createTemplateTitle": "", + "updateTemplateTitle": "", + "nameLabel": "", + "strictLabel": "", + "strictHelp": "", + "previewLabel": "", + "ruleSectionTitle": "", + "separatorLabel": "", + "addPartButton": "", + "emptyParts": "", + "partTypeLabel": "", + "partTypeValuesIn": "", + "partTypeBounded": "", + "partTypeNChars": "", + "elementsLabel": "", + "elementsHelp": "", + "loadFromTemplateLabel": "", + "minLabel": "", + "maxLabel": "", + "maxLengthLabel": "", + "templateElementsLabel": "", + "templateElementsHelp": "", + "emptyElementsError": "", + "invalidBoundsError": "", + "deleteConstraintConfirm": "", + "deleteTemplateConfirm": "" + }, "OidcCallbackError": { "message": "Ha ocurrido un error de autenticación...", "retryButtonText": "Intente de nuevo" @@ -440,6 +479,19 @@ "invitationViewAcceptError": "No se puede aceptar la invitación", "folderFetchFolder": "Error al recuperar la información de la carpeta", "bcfDeleteError": "Eliminación fallida...", + "namingConstraintsFetchError": "", + "namingConstraintFetchError": "", + "namingConstraintCreateError": "", + "namingConstraintUpdateError": "", + "namingConstraintDeleteError": "", + "namingPartsTemplatesFetchError": "", + "namingPartsTemplateCreateError": "", + "namingPartsTemplateUpdateError": "", + "namingPartsTemplateDeleteError": "", + "folderNamingConstraintFetchError": "", + "folderNamingConstraintSetError": "", + "folderNamingConstraintDeleteError": "", + "conflictingDocumentsFetchError": "", "groupImportError": "Error al importar grupos", "bcfExportXlsxError": "La exportación a Excél ha fracasado", "invitationViewDeclineError": "No se permite rechazar la invitación" @@ -971,4 +1023,4 @@ "title": "Eliminación de {visasCount} visas", "message": "Está a punto de eliminar las visas de los siguientes archivos:" } -} \ No newline at end of file +} diff --git a/src/i18n/lang/fr.json b/src/i18n/lang/fr.json index 6804246bc..6939f2930 100644 --- a/src/i18n/lang/fr.json +++ b/src/i18n/lang/fr.json @@ -1,4 +1,92 @@ { + "NamingConstraint": { + "managerTitle": "Conventions de nommage", + "constraintsTab": "Règles de nommage", + "templatesTab": "Listes de valeurs", + "rulesSectionTitle": "Liste des règles du projet", + "addRuleButton": "ajouter une règle", + "searchPlaceholder": "Rechercher", + "rulesEmptyTitle": "Pas de règle ajoutée", + "rulesEmptyText": "Vous n'avez aucune règle pour l'instant, commencez par en ajouter une.", + "manageListsButton": "Gérer les listes", + "listsSectionTitle": "Gestion des listes", + "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.", + "constraintsListCreateButton": "Nouvelle règle de nommage", + "constraintsListEmpty": "Aucune règle de nommage pour le moment.", + "templatesListCreateButton": "Nouvelle liste de valeurs", + "templatesListEmpty": "Aucune liste de valeurs pour le moment.", + "strictBadge": "Stricte", + "nonStrictBadge": "Non stricte", + "createConstraintTitle": "Création d'une règle", + "updateConstraintTitle": "Modification d'une règle", + "createTemplateTitle": "Création de ma liste", + "updateTemplateTitle": "Modification de ma liste", + "nameLabel": "Nom", + "ruleNameStep": "Nom de la règle", + "ruleNamePlaceholder": "Nom de la règle", + "separatorStep": "Type de séparateur", + "separatorDashOption": "- (tiret)", + "separatorDotOption": ". (point)", + "separatorUnderscoreOption": "_ (trait de soulignement)", + "structureSectionTitle": "Structure de la règle", + "strictLabel": "Règle stricte", + "strictHelp": "Si vous cochez cette case, vous devrez changer le nom de l'intégralité des fichiers qui ne correspondent pas à cette nouvelle règle.", + "previewLabel": "Aperçu", + "ruleSectionTitle": "Règle", + "separatorLabel": "Séparateur", + "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", + "elementsLabel": "Valeurs autorisées", + "elementsHelp": "Liste de valeurs autorisées séparées par des virgules.", + "loadFromTemplateLabel": "Charger depuis une liste", + "minLabel": "Min", + "maxLabel": "Max", + "maxLengthLabel": "Longueur max", + "saveRuleButton": "Enregistrer la règle", + "listNameStep": "Nom de la liste", + "listNamePlaceholder": "Nom de la liste", + "addElementsStep": "Ajouter des éléments", + "elementNamePlaceholder": "Nom de l'élément", + "removeElementButton": "Effacer", + "addElementButton": "Ajouter un élément", + "saveListButton": "Enregistrer la liste", + "templateElementsLabel": "Valeurs", + "templateElementsHelp": "Liste de valeurs séparées par des virgules.", + "emptyElementsError": "Indiquez au moins une valeur.", + "invalidBoundsError": "Le max doit être supérieur ou égal au min.", + "deleteConstraintConfirm": "Supprimer cette règle de nommage ?", + "deleteTemplateConfirm": "Supprimer cette 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 convention de nommage a été appliquée au dossier.", + "applyRuleConflictTitle": "Certains fichiers ne correspondent pas", + "applyRuleConflictMessage": "La règle a été appliquée mais {count} fichier(s) existant(s) ne la respectent pas.", + "applyRuleBlockedTitle": "Règle stricte non applicable", + "applyRuleBlockedMessage": "{count} fichier(s) existant(s) ne respectent pas cette règle stricte. Renommez-les d'abord.", + "applyRuleError": "La convention de nommage n'a pas pu être appliquée.", + "noFolderRuleSelection": "Sélectionnez une règle à appliquer.", + "applyRuleWarningTitle": "Le nom ne respecte pas la règle", + "uploadBlocked": "{count} fichier(s) n'ont pas été importés car leur nom ne respecte pas la règle (ex. {example}).", + "uploadWarning": "{count} fichier(s) ne respectent pas la convention de nommage (ex. {example}).", + "conflictTooltip": "Ce fichier ne respecte pas la convention de nommage du dossier.", + "conflictModalIntro": "Renommez ici les fichiers qui ne correspondent pas à la règle stricte que vous avez programmé.", + "conflictModalWarning": "Renommez ou supprimez les documents en conflit pour continuer", + "renameButton": "Renommer", + "renameFilePlaceholder": "Renommer le fichier", + "renameConflictsMenuItem": "Renommer les fichiers en conflit", + "noConflictsTitle": "Aucun fichier en conflit", + "noConflictsMessage": "Tous les fichiers respectent la convention de nommage de leur dossier." + }, "t": { "add": "Ajouter", "amount": "Montant", @@ -31,6 +119,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", @@ -612,6 +701,19 @@ "bcfExportError": "L'export BCF échoué...", "bcfExportXlsxError": "L'export Excel a échoué...", "bcfDeleteError": "La suppression a échouée...", + "namingConstraintsFetchError": "Impossible de récupérer les règles de nommage", + "namingConstraintFetchError": "Impossible de récupérer cette règle de nommage", + "namingConstraintCreateError": "Impossible de créer cette règle de nommage", + "namingConstraintUpdateError": "Impossible de modifier cette règle de nommage", + "namingConstraintDeleteError": "Impossible de supprimer cette règle de nommage", + "namingPartsTemplatesFetchError": "Impossible de récupérer les modèles de nommage", + "namingPartsTemplateCreateError": "Impossible de créer ce modèle de nommage", + "namingPartsTemplateUpdateError": "Impossible de modifier ce modèle de nommage", + "namingPartsTemplateDeleteError": "Impossible de supprimer ce modèle de nommage", + "folderNamingConstraintFetchError": "Impossible de récupérer la règle de nommage du dossier", + "folderNamingConstraintSetError": "Impossible d'appliquer la règle de nommage sur ce dossier", + "folderNamingConstraintDeleteError": "Impossible de retirer la règle de nommage de ce dossier", + "conflictingDocumentsFetchError": "Impossible de récupérer les documents en conflit", "fileVersionsFetchError": "Impossible de récupérer les versions de ce document", "fileVersionsMakeHeadError": "Échec lors du passage du document en version actuelle", "fileVersionDelete": "Échec lors de la suppression du document", diff --git a/src/i18n/lang/it.json b/src/i18n/lang/it.json index 6663e729d..f88275823 100644 --- a/src/i18n/lang/it.json +++ b/src/i18n/lang/it.json @@ -1,4 +1,43 @@ { + "NamingConstraint": { + "managerTitle": "", + "constraintsTab": "", + "templatesTab": "", + "constraintsListCreateButton": "", + "constraintsListEmpty": "", + "templatesListCreateButton": "", + "templatesListEmpty": "", + "strictBadge": "", + "nonStrictBadge": "", + "createConstraintTitle": "", + "updateConstraintTitle": "", + "createTemplateTitle": "", + "updateTemplateTitle": "", + "nameLabel": "", + "strictLabel": "", + "strictHelp": "", + "previewLabel": "", + "ruleSectionTitle": "", + "separatorLabel": "", + "addPartButton": "", + "emptyParts": "", + "partTypeLabel": "", + "partTypeValuesIn": "", + "partTypeBounded": "", + "partTypeNChars": "", + "elementsLabel": "", + "elementsHelp": "", + "loadFromTemplateLabel": "", + "minLabel": "", + "maxLabel": "", + "maxLengthLabel": "", + "templateElementsLabel": "", + "templateElementsHelp": "", + "emptyElementsError": "", + "invalidBoundsError": "", + "deleteConstraintConfirm": "", + "deleteTemplateConfirm": "" + }, "OidcCallbackError": { "message": "Si è verificato un errore durante l'autenticazione...", "retryButtonText": "Riprova" @@ -361,6 +400,19 @@ "invitationViewDeclineError": "Impossibile rifiutare l'invito", "folderFetchFolder": "Errore durante il recupero delle informazioni sulla cartella", "bcfDeleteError": "Eliminazione non riuscita...", + "namingConstraintsFetchError": "", + "namingConstraintFetchError": "", + "namingConstraintCreateError": "", + "namingConstraintUpdateError": "", + "namingConstraintDeleteError": "", + "namingPartsTemplatesFetchError": "", + "namingPartsTemplateCreateError": "", + "namingPartsTemplateUpdateError": "", + "namingPartsTemplateDeleteError": "", + "folderNamingConstraintFetchError": "", + "folderNamingConstraintSetError": "", + "folderNamingConstraintDeleteError": "", + "conflictingDocumentsFetchError": "", "groupImportError": "Impossibile importare i gruppi" }, "ProjectStatusBadge": { @@ -855,4 +907,4 @@ "FileTreePreviewModal": { "title": "Importa la struttura del file da" } -} \ No newline at end of file +} diff --git a/src/i18n/lang/nl.json b/src/i18n/lang/nl.json index 2b839d2b7..98390d3c0 100644 --- a/src/i18n/lang/nl.json +++ b/src/i18n/lang/nl.json @@ -1,4 +1,43 @@ { + "NamingConstraint": { + "managerTitle": "", + "constraintsTab": "", + "templatesTab": "", + "constraintsListCreateButton": "", + "constraintsListEmpty": "", + "templatesListCreateButton": "", + "templatesListEmpty": "", + "strictBadge": "", + "nonStrictBadge": "", + "createConstraintTitle": "", + "updateConstraintTitle": "", + "createTemplateTitle": "", + "updateTemplateTitle": "", + "nameLabel": "", + "strictLabel": "", + "strictHelp": "", + "previewLabel": "", + "ruleSectionTitle": "", + "separatorLabel": "", + "addPartButton": "", + "emptyParts": "", + "partTypeLabel": "", + "partTypeValuesIn": "", + "partTypeBounded": "", + "partTypeNChars": "", + "elementsLabel": "", + "elementsHelp": "", + "loadFromTemplateLabel": "", + "minLabel": "", + "maxLabel": "", + "maxLengthLabel": "", + "templateElementsLabel": "", + "templateElementsHelp": "", + "emptyElementsError": "", + "invalidBoundsError": "", + "deleteConstraintConfirm": "", + "deleteTemplateConfirm": "" + }, "OidcCallbackError": { "message": "Er heeft zich een fout voorgedaan bij de authenticatie", "retryButtonText": "" @@ -357,6 +396,19 @@ "invitationViewDeclineError": "", "folderFetchFolder": "", "bcfDeleteError": "", + "namingConstraintsFetchError": "", + "namingConstraintFetchError": "", + "namingConstraintCreateError": "", + "namingConstraintUpdateError": "", + "namingConstraintDeleteError": "", + "namingPartsTemplatesFetchError": "", + "namingPartsTemplateCreateError": "", + "namingPartsTemplateUpdateError": "", + "namingPartsTemplateDeleteError": "", + "folderNamingConstraintFetchError": "", + "folderNamingConstraintSetError": "", + "folderNamingConstraintDeleteError": "", + "conflictingDocumentsFetchError": "", "groupImportError": "" }, "ProjectStatusBadge": { @@ -851,4 +903,4 @@ "FileTreePreviewModal": { "title": "" } -} \ No newline at end of file +} diff --git a/src/i18n/lang/no.json b/src/i18n/lang/no.json index da63aa636..9c69eceec 100644 --- a/src/i18n/lang/no.json +++ b/src/i18n/lang/no.json @@ -1,4 +1,43 @@ { + "NamingConstraint": { + "managerTitle": "", + "constraintsTab": "", + "templatesTab": "", + "constraintsListCreateButton": "", + "constraintsListEmpty": "", + "templatesListCreateButton": "", + "templatesListEmpty": "", + "strictBadge": "", + "nonStrictBadge": "", + "createConstraintTitle": "", + "updateConstraintTitle": "", + "createTemplateTitle": "", + "updateTemplateTitle": "", + "nameLabel": "", + "strictLabel": "", + "strictHelp": "", + "previewLabel": "", + "ruleSectionTitle": "", + "separatorLabel": "", + "addPartButton": "", + "emptyParts": "", + "partTypeLabel": "", + "partTypeValuesIn": "", + "partTypeBounded": "", + "partTypeNChars": "", + "elementsLabel": "", + "elementsHelp": "", + "loadFromTemplateLabel": "", + "minLabel": "", + "maxLabel": "", + "maxLengthLabel": "", + "templateElementsLabel": "", + "templateElementsHelp": "", + "emptyElementsError": "", + "invalidBoundsError": "", + "deleteConstraintConfirm": "", + "deleteTemplateConfirm": "" + }, "OidcCallbackError": { "message": "En feil oppsto under autentiseringen...", "retryButtonText": "" @@ -357,6 +396,19 @@ "invitationViewDeclineError": "", "folderFetchFolder": "", "bcfDeleteError": "", + "namingConstraintsFetchError": "", + "namingConstraintFetchError": "", + "namingConstraintCreateError": "", + "namingConstraintUpdateError": "", + "namingConstraintDeleteError": "", + "namingPartsTemplatesFetchError": "", + "namingPartsTemplateCreateError": "", + "namingPartsTemplateUpdateError": "", + "namingPartsTemplateDeleteError": "", + "folderNamingConstraintFetchError": "", + "folderNamingConstraintSetError": "", + "folderNamingConstraintDeleteError": "", + "conflictingDocumentsFetchError": "", "groupImportError": "" }, "ProjectStatusBadge": { @@ -851,4 +903,4 @@ "FileTreePreviewModal": { "title": "" } -} \ No newline at end of file +} diff --git a/src/services/ErrorService.js b/src/services/ErrorService.js index 811667e99..1452e9390 100644 --- a/src/services/ErrorService.js +++ b/src/services/ErrorService.js @@ -87,6 +87,19 @@ const ERRORS = Object.freeze({ BCF_IMPORT_ERROR: "bcfImportError", BCF_EXPORT_ERROR: "bcfExportError", BCF_DELETE_ERROR: "bcfDeleteError", + NAMING_CONSTRAINTS_FETCH_ERROR: "namingConstraintsFetchError", + NAMING_CONSTRAINT_FETCH_ERROR: "namingConstraintFetchError", + NAMING_CONSTRAINT_CREATE_ERROR: "namingConstraintCreateError", + NAMING_CONSTRAINT_UPDATE_ERROR: "namingConstraintUpdateError", + NAMING_CONSTRAINT_DELETE_ERROR: "namingConstraintDeleteError", + NAMING_PARTS_TEMPLATES_FETCH_ERROR: "namingPartsTemplatesFetchError", + NAMING_PARTS_TEMPLATE_CREATE_ERROR: "namingPartsTemplateCreateError", + NAMING_PARTS_TEMPLATE_UPDATE_ERROR: "namingPartsTemplateUpdateError", + NAMING_PARTS_TEMPLATE_DELETE_ERROR: "namingPartsTemplateDeleteError", + FOLDER_NAMING_CONSTRAINT_FETCH_ERROR: "folderNamingConstraintFetchError", + FOLDER_NAMING_CONSTRAINT_SET_ERROR: "folderNamingConstraintSetError", + FOLDER_NAMING_CONSTRAINT_DELETE_ERROR: "folderNamingConstraintDeleteError", + CONFLICTING_DOCUMENTS_FETCH_ERROR: "conflictingDocumentsFetchError", }); class RuntimeError { diff --git a/src/services/NamingConstraintService.js b/src/services/NamingConstraintService.js new file mode 100644 index 000000000..55e7cb0bb --- /dev/null +++ b/src/services/NamingConstraintService.js @@ -0,0 +1,256 @@ +import apiClient from "./api-client.js"; +import { ERRORS, RuntimeError, ErrorService } from "./ErrorService.js"; + +/** + * Thrown when a strict folder naming constraint cannot be applied because + * existing documents in scope do not match the rule (API responds 409). + * `documents` holds the conflicting `LightDocument[]` returned by the API. + */ +class NamingConstraintConflictError { + constructor(documents) { + this.documents = documents; + } +} + +const isResponse = error => + typeof Response !== "undefined" && error instanceof Response; + +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); + } + } + + /** + * Updates a naming constraint. + * Resolves with the `NamingConstraint` (its `conflicting_documents` lists + * the non-blocking conflicts for non-strict rules). + * Throws `NamingConstraintConflictError` (with `documents`) when a strict + * rule conflicts with existing documents (API responds 409). + */ + async updateNamingConstraint(project, constraint, payload) { + try { + return await apiClient.collaborationApi.updateNamingConstraint( + project.cloud.id, + constraint.id, + project.id, + payload + ); + } catch (error) { + if (isResponse(error) && error.status === 409) { + const documents = await error.json(); + throw new NamingConstraintConflictError(documents); + } + throw new RuntimeError(ERRORS.NAMING_CONSTRAINT_UPDATE_ERROR, error); + } + } + + async deleteNamingConstraint(project, constraint) { + try { + return await apiClient.collaborationApi.deleteNamingConstraint( + project.cloud.id, + constraint.id, + project.id + ); + } catch (error) { + throw new RuntimeError(ERRORS.NAMING_CONSTRAINT_DELETE_ERROR, error); + } + } + + // --- Naming parts templates ---------------------------------------------- + + async fetchNamingPartsTemplates(project) { + try { + return await apiClient.collaborationApi.getNamingPartsTemplates( + project.cloud.id, + project.id + ); + } catch (error) { + ErrorService.handleError( + new RuntimeError(ERRORS.NAMING_PARTS_TEMPLATES_FETCH_ERROR, error) + ); + return []; + } + } + + async createNamingPartsTemplate(project, payload) { + try { + return await apiClient.collaborationApi.createNamingPartsTemplate( + project.cloud.id, + project.id, + payload + ); + } catch (error) { + throw new RuntimeError(ERRORS.NAMING_PARTS_TEMPLATE_CREATE_ERROR, error); + } + } + + async updateNamingPartsTemplate(project, template, payload) { + try { + return await apiClient.collaborationApi.updateNamingPartsTemplate( + project.cloud.id, + template.id, + project.id, + payload + ); + } catch (error) { + throw new RuntimeError(ERRORS.NAMING_PARTS_TEMPLATE_UPDATE_ERROR, error); + } + } + + async deleteNamingPartsTemplate(project, template) { + try { + return await apiClient.collaborationApi.deleteNamingPartsTemplate( + project.cloud.id, + template.id, + project.id + ); + } catch (error) { + throw new RuntimeError(ERRORS.NAMING_PARTS_TEMPLATE_DELETE_ERROR, error); + } + } + + // --- Folder naming constraint -------------------------------------------- + + /** + * Returns the effective `FolderNamingConstraint` for a folder (may be + * inherited from a recursive parent rule), or `null` when no rule applies + * (API responds 404). + */ + async fetchFolderNamingConstraint(project, folder) { + try { + return await apiClient.collaborationApi.getFolderNamingConstraint( + project.cloud.id, + folder.id, + project.id + ); + } catch (error) { + if (isResponse(error) && error.status === 404) { + return null; + } + throw new RuntimeError(ERRORS.FOLDER_NAMING_CONSTRAINT_FETCH_ERROR, error); + } + } + + /** + * Sets or replaces the naming constraint applied on a folder. + * Resolves with the `FolderNamingConstraint` (its `conflicting_documents` + * lists the non-blocking conflicts for non-strict rules). + * Throws `NamingConstraintConflictError` (with `documents`) when a strict + * rule conflicts with existing documents (API responds 409). + */ + async setFolderNamingConstraint(project, folder, { constraint_id, recursive }) { + try { + return await apiClient.collaborationApi.setFolderNamingConstraint( + project.cloud.id, + folder.id, + project.id, + { constraint_id, recursive } + ); + } 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 + ); + return documents ?? []; + } catch (error) { + 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 []; + } + } +} + +const service = new NamingConstraintService(); + +export { NamingConstraintConflictError }; + +export default service; diff --git a/src/state/naming-constraints.js b/src/state/naming-constraints.js new file mode 100644 index 000000000..333b23e31 --- /dev/null +++ b/src/state/naming-constraints.js @@ -0,0 +1,163 @@ +import { reactive, readonly, toRefs } from "vue"; +import NamingConstraintService, { + NamingConstraintConflictError +} from "../services/NamingConstraintService.js"; + +const state = reactive({ + namingConstraints: [], + namingPartsTemplates: [] +}); + +// 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(); + +// --- Naming constraints catalog -------------------------------------------- + +const loadNamingConstraints = async project => { + const constraints = await NamingConstraintService.fetchNamingConstraints( + project + ); + state.namingConstraints = constraints; + return constraints; +}; + +const createNamingConstraint = async (project, payload) => { + const constraint = await NamingConstraintService.createNamingConstraint( + project, + payload + ); + state.namingConstraints = [...state.namingConstraints, constraint]; + return constraint; +}; + +const updateNamingConstraint = async (project, constraint, payload) => { + const updated = await NamingConstraintService.updateNamingConstraint( + project, + constraint, + payload + ); + state.namingConstraints = state.namingConstraints.map(item => + item.id === updated.id ? updated : item + ); + return updated; +}; + +const deleteNamingConstraint = async (project, constraint) => { + await NamingConstraintService.deleteNamingConstraint(project, constraint); + state.namingConstraints = state.namingConstraints.filter( + item => item.id !== constraint.id + ); +}; + +// --- Naming parts templates ------------------------------------------------ + +const loadNamingPartsTemplates = async project => { + const templates = await NamingConstraintService.fetchNamingPartsTemplates( + project + ); + state.namingPartsTemplates = templates; + return templates; +}; + +const createNamingPartsTemplate = async (project, payload) => { + const template = await NamingConstraintService.createNamingPartsTemplate( + project, + payload + ); + state.namingPartsTemplates = [...state.namingPartsTemplates, template]; + return template; +}; + +const updateNamingPartsTemplate = async (project, template, payload) => { + const updated = await NamingConstraintService.updateNamingPartsTemplate( + project, + template, + payload + ); + state.namingPartsTemplates = state.namingPartsTemplates.map(item => + item.id === updated.id ? updated : item + ); + return updated; +}; + +const deleteNamingPartsTemplate = async (project, template) => { + await NamingConstraintService.deleteNamingPartsTemplate(project, template); + state.namingPartsTemplates = state.namingPartsTemplates.filter( + item => item.id !== template.id + ); +}; + +// --- Folder naming constraint ---------------------------------------------- + +const fetchFolderNamingConstraint = async (project, folder) => { + return NamingConstraintService.fetchFolderNamingConstraint(project, folder); +}; + +const setFolderNamingConstraint = async (project, folder, payload) => { + const result = await NamingConstraintService.setFolderNamingConstraint( + project, + folder, + payload + ); + folderRuleCache.clear(); + return result; +}; + +const deleteFolderNamingConstraint = async (project, folder) => { + const result = await NamingConstraintService.deleteFolderNamingConstraint( + project, + folder + ); + folderRuleCache.clear(); + return result; +}; + +// 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. +const getEffectiveFolderRule = async (project, folder) => { + if (!folder?.id) return null; + if (folderRuleCache.has(folder.id)) { + return folderRuleCache.get(folder.id); + } + const folderConstraint = await NamingConstraintService.fetchFolderNamingConstraint( + project, + folder + ); + const constraint = folderConstraint?.constraint ?? null; + const effective = constraint + ? { rule: constraint.rule, strict: constraint.strict, name: constraint.name } + : null; + folderRuleCache.set(folder.id, effective); + return effective; +}; + +// --- Conflicting documents ------------------------------------------------- + +const fetchConflictingDocuments = async project => { + return NamingConstraintService.fetchConflictingDocuments(project); +}; + +export { NamingConstraintConflictError }; + +export function useNamingConstraints() { + const readOnlyState = readonly(state); + return { + // References + ...toRefs(readOnlyState), + // Methods + loadNamingConstraints, + createNamingConstraint, + updateNamingConstraint, + deleteNamingConstraint, + loadNamingPartsTemplates, + createNamingPartsTemplate, + updateNamingPartsTemplate, + deleteNamingPartsTemplate, + fetchFolderNamingConstraint, + setFolderNamingConstraint, + deleteFolderNamingConstraint, + getEffectiveFolderRule, + fetchConflictingDocuments + }; +} diff --git a/src/utils/naming-constraint.js b/src/utils/naming-constraint.js new file mode 100644 index 000000000..abbcf2069 --- /dev/null +++ b/src/utils/naming-constraint.js @@ -0,0 +1,121 @@ +/** + * Client-side helpers to work with naming-constraint rules without round-trips + * to the API (e.g. validate a name before starting a large upload). + * + * A rule has the shape: + * { + * separator: "_", + * parts: [ + * { type: "values_in", elements: ["ARC", "STR"] }, + * { type: "bounded", min_value: 1, max_value: 99 }, + * { type: "n_chars", max_length: 12 } + * ] + * } + * + * Names are validated on the file name without its extension, the same way the + * backend evaluates them. + */ + +const PART_TYPES = Object.freeze({ + VALUES_IN: "values_in", + BOUNDED: "bounded", + N_CHARS: "n_chars" +}); + +/** + * Strip the extension from a file name (e.g. "ARC_01.ifc" -> "ARC_01"). + * Names without an extension are returned unchanged. + * + * @param {String} name + * @returns {String} + */ +function stripExtension(name) { + const dotIndex = name.lastIndexOf("."); + return dotIndex > 0 ? name.slice(0, dotIndex) : name; +} + +/** + * Check whether a single segment matches a rule part. + * + * @param {Object} part + * @param {String} segment + * @returns {Boolean} + */ +function matchPart(part, segment) { + switch (part?.type) { + case PART_TYPES.VALUES_IN: + return Array.isArray(part.elements) && part.elements.includes(segment); + case PART_TYPES.BOUNDED: { + if (!/^\d+$/.test(segment)) return false; + const value = Number(segment); + return value >= part.min_value && value <= part.max_value; + } + case PART_TYPES.N_CHARS: + return segment.length > 0 && segment.length <= part.max_length; + default: + return false; + } +} + +/** + * Check whether a file name matches a naming-constraint rule. + * Returns `true` when there is no rule (nothing to enforce). + * + * @param {String} name file name (with or without extension) + * @param {Object|null} rule naming-constraint rule + * @returns {Boolean} + */ +function matchName(name, rule) { + if (!rule || !Array.isArray(rule.parts) || rule.parts.length === 0) { + return true; + } + if (typeof name !== "string" || name.length === 0) return false; + + const baseName = stripExtension(name); + const separator = rule.separator ?? ""; + const segments = separator === "" ? [baseName] : baseName.split(separator); + + if (segments.length !== rule.parts.length) return false; + + return rule.parts.every((part, index) => matchPart(part, segments[index])); +} + +/** + * Build a human-friendly example segment for a single rule part. + * + * - values_in -> first element if defined, otherwise "..." + * - n_chars -> "XXX" + * - bounded -> "[MIN-MAX]" + * + * @param {Object} part + * @returns {String} + */ +function buildPartExample(part) { + switch (part?.type) { + case PART_TYPES.VALUES_IN: + return part.elements?.[0] ?? "..."; + case PART_TYPES.BOUNDED: + return `[${part.min_value}-${part.max_value}]`; + case PART_TYPES.N_CHARS: + return "XXX"; + default: + return ""; + } +} + +/** + * Build a human-friendly example name from a rule, to preview the expected + * format in the UI (e.g. "ARC_[1-99]_XXX"). + * + * @param {Object|null} rule + * @returns {String} + */ +function buildExample(rule) { + if (!rule || !Array.isArray(rule.parts) || rule.parts.length === 0) { + return ""; + } + const separator = rule.separator ?? ""; + return rule.parts.map(buildPartExample).join(separator) + ".ext"; +} + +export { PART_TYPES, matchName, matchPart, buildExample, buildPartExample, stripExtension }; diff --git a/src/views/project-board/project-files/ProjectFiles.vue b/src/views/project-board/project-files/ProjectFiles.vue index d1309c30b..3a47b9aa5 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, + }, }" > { await Promise.all([ loadSpaceSubInfo(currentSpace.value), loadProjectFileStructure(currentProject.value), - loadProjectModels(currentProject.value) + loadProjectModels(currentProject.value), ]); }, 1000); @@ -90,9 +93,9 @@ export default { isProjectAdmin, reloadData, // 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"); + }); +});