From f58e705e87b5aa797fa4b9d5c70f6eca4c69482f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 19 Mar 2026 17:36:26 +0100 Subject: [PATCH 01/12] fix: Add naam to delete dialog fallback chain The delete confirmation dialog showed raw UUIDs instead of object names for Dutch-language apps that use 'naam' instead of 'name'/'title'. --- src/components/CnDeleteDialog/CnDeleteDialog.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CnDeleteDialog/CnDeleteDialog.vue b/src/components/CnDeleteDialog/CnDeleteDialog.vue index 3484c0e..8994a98 100644 --- a/src/components/CnDeleteDialog/CnDeleteDialog.vue +++ b/src/components/CnDeleteDialog/CnDeleteDialog.vue @@ -128,7 +128,7 @@ export default { computed: { itemName() { if (this.nameFormatter) return this.nameFormatter(this.item) - return this.item[this.nameField] || this.item.name || this.item.title || this.item.id + return this.item[this.nameField] || this.item.name || this.item.naam || this.item.title || this.item.id }, resolvedWarningText() { return this.warningText.replace('{name}', this.itemName) From 192b7e57b6f45ca667611c32e4377545873f6419 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 19 Mar 2026 20:08:03 +0100 Subject: [PATCH 02/12] fix: escape curly braces in MDX to prevent JSX parsing --- docs/components/cn-advanced-form-dialog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/components/cn-advanced-form-dialog.md b/docs/components/cn-advanced-form-dialog.md index 207eef8..349d3e3 100644 --- a/docs/components/cn-advanced-form-dialog.md +++ b/docs/components/cn-advanced-form-dialog.md @@ -35,7 +35,7 @@ The dialog uses the same **two-phase pattern** as other CRUD dialogs: after the |------|------|---------|-------------| | `schema` | Object | `null` | JSON Schema used to derive property list, types, and labels (e.g. `schema.properties`, `schema.required`, `schema.title`) | | `item` | Object | `null` | Existing object for edit mode; `null` for create mode | -| `dialogTitle` | String | `''` | Override dialog title; default is "Create {schemaTitle}" or "Edit {schemaTitle}" | +| `dialogTitle` | String | `''` | Override dialog title; default is "Create \{schemaTitle\}" or "Edit \{schemaTitle\}" | | `nameField` | String | `'title'` | Property used as display name (e.g. in breadcrumbs or other UI) | | `successText` | String | `''` | Message shown in result phase on success; default "\{schemaTitle\} saved successfully." | | `cancelLabel` | String | `'Cancel'` | Label for cancel button in form phase | From 30f2142f150a9814bbe032086db842fb6230e1a1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 19 Mar 2026 22:34:57 +0100 Subject: [PATCH 03/12] feat: add Dutch (nl) locale support for documentation --- docs/i18n/nl/code.json | 329 ++++++++++++++++++ .../current.json | 30 ++ .../nl/docusaurus-theme-classic/footer.json | 50 +++ .../nl/docusaurus-theme-classic/navbar.json | 18 + docusaurus/docusaurus.config.js | 10 +- 5 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 docs/i18n/nl/code.json create mode 100644 docs/i18n/nl/docusaurus-plugin-content-docs/current.json create mode 100644 docs/i18n/nl/docusaurus-theme-classic/footer.json create mode 100644 docs/i18n/nl/docusaurus-theme-classic/navbar.json diff --git a/docs/i18n/nl/code.json b/docs/i18n/nl/code.json new file mode 100644 index 0000000..5aaed38 --- /dev/null +++ b/docs/i18n/nl/code.json @@ -0,0 +1,329 @@ +{ + "theme.ErrorPageContent.title": { + "message": "Deze pagina is gecrasht.", + "description": "The title of the fallback page when the page crashed" + }, + "theme.BackToTopButton.buttonAriaLabel": { + "message": "Scroll naar boven", + "description": "The ARIA label for the back to top button" + }, + "theme.blog.archive.title": { + "message": "Archief", + "description": "The page & hero title of the blog archive page" + }, + "theme.blog.archive.description": { + "message": "Archief", + "description": "The page & hero description of the blog archive page" + }, + "theme.blog.paginator.navAriaLabel": { + "message": "Paginanavigatie blog", + "description": "The ARIA label for the blog pagination" + }, + "theme.blog.paginator.newerEntries": { + "message": "Nieuwere items", + "description": "The label used to navigate to the newer blog posts page (previous page)" + }, + "theme.blog.paginator.olderEntries": { + "message": "Oudere items", + "description": "The label used to navigate to the older blog posts page (next page)" + }, + "theme.blog.post.paginator.navAriaLabel": { + "message": "Paginanavigatie blog", + "description": "The ARIA label for the blog posts pagination" + }, + "theme.blog.post.paginator.newerPost": { + "message": "Nieuwer bericht", + "description": "The blog post button label to navigate to the newer/previous post" + }, + "theme.blog.post.paginator.olderPost": { + "message": "Ouder bericht", + "description": "The blog post button label to navigate to the older/next post" + }, + "theme.tags.tagsPageLink": { + "message": "Laat alle tags zien", + "description": "The label of the link targeting the tag list page" + }, + "theme.colorToggle.ariaLabel.mode.system": { + "message": "system mode", + "description": "The name for the system color mode" + }, + "theme.colorToggle.ariaLabel.mode.light": { + "message": "lichte modus", + "description": "The name for the light color mode" + }, + "theme.colorToggle.ariaLabel.mode.dark": { + "message": "donkere modus", + "description": "The name for the dark color mode" + }, + "theme.colorToggle.ariaLabel": { + "message": "Schakel tussen donkere en lichte modus (momenteel {mode})", + "description": "The ARIA label for the color mode toggle" + }, + "theme.docs.breadcrumbs.navAriaLabel": { + "message": "Broodkruimels", + "description": "The ARIA label for the breadcrumbs" + }, + "theme.docs.DocCard.categoryDescription.plurals": { + "message": "1 artikel|{count} artikelen", + "description": "The default description for a category card in the generated index about how many items this category includes" + }, + "theme.docs.paginator.navAriaLabel": { + "message": "Documentatie pagina", + "description": "The ARIA label for the docs pagination" + }, + "theme.docs.paginator.previous": { + "message": "Vorige", + "description": "The label used to navigate to the previous doc" + }, + "theme.docs.paginator.next": { + "message": "Volgende", + "description": "The label used to navigate to the next doc" + }, + "theme.docs.tagDocListPageTitle.nDocsTagged": { + "message": "Een artikel getagd|{count} artikelen getagd", + "description": "Pluralized label for \"{count} docs tagged\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)" + }, + "theme.docs.tagDocListPageTitle": { + "message": "{nDocsTagged} met \"{tagName}\"", + "description": "The title of the page for a docs tag" + }, + "theme.docs.versionBadge.label": { + "message": "Versie: {versionLabel}" + }, + "theme.docs.versions.unreleasedVersionLabel": { + "message": "Dit is nog niet uitgegeven documentatie voor {siteTitle}, versie {versionLabel}", + "description": "The label used to tell the user that he's browsing an unreleased doc version" + }, + "theme.docs.versions.unmaintainedVersionLabel": { + "message": "Dit is de documentatie voor {siteTitle} {versionLabel}, welke niet langer actief wordt onderhouden.", + "description": "The label used to tell the user that he's browsing an unmaintained doc version" + }, + "theme.docs.versions.latestVersionSuggestionLabel": { + "message": "Voor de huidige documentatie, zie de {latestVersionLink} ({versionLabel}).", + "description": "The label used to tell the user to check the latest version" + }, + "theme.docs.versions.latestVersionLinkLabel": { + "message": "laatste versie", + "description": "The label used for the latest version suggestion link label" + }, + "theme.common.editThisPage": { + "message": "Bewerk deze pagina", + "description": "The link label to edit the current page" + }, + "theme.common.headingLinkTitle": { + "message": "Direct link naar {heading}", + "description": "Title for link to heading" + }, + "theme.lastUpdated.atDate": { + "message": " op {date}", + "description": "The words used to describe on which date a page has been last updated" + }, + "theme.lastUpdated.byUser": { + "message": " door {user}", + "description": "The words used to describe by who the page has been last updated" + }, + "theme.lastUpdated.lastUpdatedAtBy": { + "message": "Laatst bijgewerkt{atDate}{byUser}", + "description": "The sentence used to display when a page has been last updated, and by who" + }, + "theme.navbar.mobileVersionsDropdown.label": { + "message": "Versies", + "description": "The label for the navbar versions dropdown on mobile view" + }, + "theme.NotFound.title": { + "message": "Pagina niet gevonden", + "description": "The title of the 404 page" + }, + "theme.tags.tagsListLabel": { + "message": "Tags:", + "description": "The label alongside a tag list" + }, + "theme.admonition.caution": { + "message": "pas op", + "description": "The default label used for the Caution admonition (:::caution)" + }, + "theme.admonition.danger": { + "message": "gevaar", + "description": "The default label used for the Danger admonition (:::danger)" + }, + "theme.admonition.info": { + "message": "info", + "description": "The default label used for the Info admonition (:::info)" + }, + "theme.admonition.note": { + "message": "notitie", + "description": "The default label used for the Note admonition (:::note)" + }, + "theme.admonition.tip": { + "message": "tip", + "description": "The default label used for the Tip admonition (:::tip)" + }, + "theme.admonition.warning": { + "message": "waarschuwing", + "description": "The default label used for the Warning admonition (:::warning)" + }, + "theme.AnnouncementBar.closeButtonAriaLabel": { + "message": "Sluiten", + "description": "The ARIA label for close button of announcement bar" + }, + "theme.blog.sidebar.navAriaLabel": { + "message": "Navigatie recente blogitems", + "description": "The ARIA label for recent posts in the blog sidebar" + }, + "theme.DocSidebarItem.expandCategoryAriaLabel": { + "message": "Categorie zijbalk uitklappen '{label}'", + "description": "The ARIA label to expand the sidebar category" + }, + "theme.DocSidebarItem.collapseCategoryAriaLabel": { + "message": "Categorie zijbalk inklappen '{label}'", + "description": "The ARIA label to collapse the sidebar category" + }, + "theme.IconExternalLink.ariaLabel": { + "message": "(opens in new tab)", + "description": "The ARIA label for the external link icon" + }, + "theme.NavBar.navAriaLabel": { + "message": "Main", + "description": "The ARIA label for the main navigation" + }, + "theme.navbar.mobileLanguageDropdown.label": { + "message": "Talen", + "description": "The label for the mobile language switcher dropdown" + }, + "theme.NotFound.p1": { + "message": "We kunnen niet vinden waar je naar op zoek bent.", + "description": "The first paragraph of the 404 page" + }, + "theme.NotFound.p2": { + "message": "Neem contact op met de eigenaar van de website die naar de originele URL heeft geleid en laat weten dat de link niet meer werkt.", + "description": "The 2nd paragraph of the 404 page" + }, + "theme.TOCCollapsible.toggleButtonLabel": { + "message": "Op deze pagina", + "description": "The label used by the button on the collapsible TOC component" + }, + "theme.blog.post.readMore": { + "message": "Lees meer", + "description": "The label used in blog post item excerpts to link to full blog posts" + }, + "theme.blog.post.readMoreLabel": { + "message": "Lees meer over {title}", + "description": "The ARIA label for the link to full blog posts from excerpts" + }, + "theme.blog.post.readingTime.plurals": { + "message": "Een minuut leestijd|{readingTime} minuten leestijd", + "description": "Pluralized label for \"{readingTime} min read\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)" + }, + "theme.CodeBlock.copy": { + "message": "Kopieer", + "description": "The copy button label on code blocks" + }, + "theme.CodeBlock.copied": { + "message": "Gekopieerd", + "description": "The copied button label on code blocks" + }, + "theme.CodeBlock.copyButtonAriaLabel": { + "message": "Kopieer code naar klembord", + "description": "The ARIA label for copy code blocks button" + }, + "theme.CodeBlock.wordWrapToggle": { + "message": "Tekstterugloop in-/uitschakelen", + "description": "The title attribute for toggle word wrapping button of code block lines" + }, + "theme.docs.breadcrumbs.home": { + "message": "Homepagina", + "description": "The ARIA label for the home page in the breadcrumbs" + }, + "theme.docs.sidebar.collapseButtonTitle": { + "message": "Zijbalk inklappen", + "description": "The title attribute for collapse button of doc sidebar" + }, + "theme.docs.sidebar.collapseButtonAriaLabel": { + "message": "Zijbalk inklappen", + "description": "The title attribute for collapse button of doc sidebar" + }, + "theme.docs.sidebar.navAriaLabel": { + "message": "Docs zijbalk", + "description": "The ARIA label for the sidebar navigation" + }, + "theme.docs.sidebar.closeSidebarButtonAriaLabel": { + "message": "Sluit navigatiebalk", + "description": "The ARIA label for close button of mobile sidebar" + }, + "theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel": { + "message": "← Terug naar het hoofdmenu", + "description": "The label of the back button to return to main menu, inside the mobile navbar sidebar secondary menu (notably used to display the docs sidebar)" + }, + "theme.docs.sidebar.toggleSidebarButtonAriaLabel": { + "message": "Navigatiebalk schakelen", + "description": "The ARIA label for hamburger menu button of mobile navigation" + }, + "theme.navbar.mobileDropdown.collapseButton.expandAriaLabel": { + "message": "Expand the dropdown", + "description": "The ARIA label of the button to expand the mobile dropdown navbar item" + }, + "theme.navbar.mobileDropdown.collapseButton.collapseAriaLabel": { + "message": "Collapse the dropdown", + "description": "The ARIA label of the button to collapse the mobile dropdown navbar item" + }, + "theme.docs.sidebar.expandButtonTitle": { + "message": "Zijbalk uitklappen", + "description": "The ARIA label and title attribute for expand button of doc sidebar" + }, + "theme.docs.sidebar.expandButtonAriaLabel": { + "message": "Zijbalk uitklappen", + "description": "The ARIA label and title attribute for expand button of doc sidebar" + }, + "theme.blog.post.plurals": { + "message": "Een bericht|{count} berichten", + "description": "Pluralized label for \"{count} posts\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)" + }, + "theme.blog.tagTitle": { + "message": "{nPosts} getagd met \"{tagName}\"", + "description": "The title of the page for a blog tag" + }, + "theme.blog.author.pageTitle": { + "message": "{authorName} - {nPosts}", + "description": "The title of the page for a blog author" + }, + "theme.blog.authorsList.pageTitle": { + "message": "Auteurs", + "description": "The title of the authors page" + }, + "theme.blog.authorsList.viewAll": { + "message": "Bekijk alle auteurs", + "description": "The label of the link targeting the blog authors page" + }, + "theme.blog.author.noPosts": { + "message": "Deze auteur heeft nog geen berichten geschreven.", + "description": "The text for authors with 0 blog post" + }, + "theme.contentVisibility.unlistedBanner.title": { + "message": "Verborgen page", + "description": "The unlisted content banner title" + }, + "theme.contentVisibility.unlistedBanner.message": { + "message": "Deze pagina is verborgen. Zoekmachines indexeren deze niet en alleen gebruikers met een directe link kunnen deze openen.", + "description": "The unlisted content banner message" + }, + "theme.contentVisibility.draftBanner.title": { + "message": "Concept pagina", + "description": "The draft content banner title" + }, + "theme.contentVisibility.draftBanner.message": { + "message": "Deze pagina is een concept. Deze zal alleen zichtbaar zijn in de ontwikkelomgeving en uitgesloten worden van de productie build.", + "description": "The draft content banner message" + }, + "theme.ErrorPageContent.tryAgain": { + "message": "Probeer opnieuw", + "description": "The label of the button to try again rendering when the React error boundary captures an error" + }, + "theme.common.skipToMainContent": { + "message": "Ga naar hoofdinhoud", + "description": "The skip to content label used for accessibility, allowing to rapidly navigate to main content with keyboard tab/enter navigation" + }, + "theme.tags.tagsPageTitle": { + "message": "Tags", + "description": "The title of the tag list page" + } +} diff --git a/docs/i18n/nl/docusaurus-plugin-content-docs/current.json b/docs/i18n/nl/docusaurus-plugin-content-docs/current.json new file mode 100644 index 0000000..c2c971e --- /dev/null +++ b/docs/i18n/nl/docusaurus-plugin-content-docs/current.json @@ -0,0 +1,30 @@ +{ + "version.label": { + "message": "Volgende", + "description": "The label for version current" + }, + "sidebar.tutorialSidebar.category.Layouts": { + "message": "Layouts", + "description": "The label for category 'Layouts' in sidebar 'tutorialSidebar'" + }, + "sidebar.tutorialSidebar.category.Component Reference": { + "message": "Componentreferentie", + "description": "The label for category 'Component Reference' in sidebar 'tutorialSidebar'" + }, + "sidebar.tutorialSidebar.category.Architecture": { + "message": "Architectuur", + "description": "The label for category 'Architecture' in sidebar 'tutorialSidebar'" + }, + "sidebar.tutorialSidebar.category.Store": { + "message": "Opslag", + "description": "The label for category 'Store' in sidebar 'tutorialSidebar'" + }, + "sidebar.tutorialSidebar.category.Integrations": { + "message": "Integraties", + "description": "The label for category 'Integrations' in sidebar 'tutorialSidebar'" + }, + "sidebar.tutorialSidebar.category.Utilities": { + "message": "Hulpmiddelen", + "description": "The label for category 'Utilities' in sidebar 'tutorialSidebar'" + } +} diff --git a/docs/i18n/nl/docusaurus-theme-classic/footer.json b/docs/i18n/nl/docusaurus-theme-classic/footer.json new file mode 100644 index 0000000..24b76f3 --- /dev/null +++ b/docs/i18n/nl/docusaurus-theme-classic/footer.json @@ -0,0 +1,50 @@ +{ + "link.title.Docs": { + "message": "Documentatie", + "description": "The title of the footer links column with title=Docs in the footer" + }, + "link.title.Community": { + "message": "Community", + "description": "The title of the footer links column with title=Community in the footer" + }, + "link.title.Related": { + "message": "Gerelateerd", + "description": "The title of the footer links column with title=Related in the footer" + }, + "link.item.label.Getting Started": { + "message": "Aan de slag", + "description": "The label of footer link with label=Getting Started linking to /docs/getting-started" + }, + "link.item.label.Components": { + "message": "Componenten", + "description": "The label of footer link with label=Components linking to /docs/components/" + }, + "link.item.label.Architecture": { + "message": "Architectuur", + "description": "The label of footer link with label=Architecture linking to /docs/architecture/overview" + }, + "link.item.label.GitHub": { + "message": "GitHub", + "description": "The label of footer link with label=GitHub linking to https://github.com/ConductionNL/nextcloud-vue" + }, + "link.item.label.Conduction": { + "message": "Conduction", + "description": "The label of footer link with label=Conduction linking to https://conduction.nl" + }, + "link.item.label.Nextcloud Vue Components": { + "message": "Nextcloud Vue Componenten", + "description": "The label of footer link with label=Nextcloud Vue Components linking to https://nextcloud-vue-components.netlify.app/" + }, + "link.item.label.Nextcloud Layout Components": { + "message": "Nextcloud Layout Componenten", + "description": "The label of footer link with label=Nextcloud Layout Components linking to https://docs.nextcloud.com/server/stable/developer_manual/design/layoutcomponents.html" + }, + "link.item.label.Nextcloud App Development": { + "message": "Nextcloud App Ontwikkeling", + "description": "The label of footer link with label=Nextcloud App Development linking to https://docs.nextcloud.com/server/stable/developer_manual/app_development/index.html" + }, + "copyright": { + "message": "Copyright © 2026 for Open Webconcept by Conduction B.V.", + "description": "The footer copyright" + } +} diff --git a/docs/i18n/nl/docusaurus-theme-classic/navbar.json b/docs/i18n/nl/docusaurus-theme-classic/navbar.json new file mode 100644 index 0000000..52e8bb1 --- /dev/null +++ b/docs/i18n/nl/docusaurus-theme-classic/navbar.json @@ -0,0 +1,18 @@ +{ + "title": { + "message": "@conduction/nextcloud-vue", + "description": "The title in the navbar" + }, + "logo.alt": { + "message": "Conduction Logo", + "description": "The alt text of navbar logo" + }, + "item.label.Documentation": { + "message": "Documentatie", + "description": "Navbar item with label Documentation" + }, + "item.label.GitHub": { + "message": "GitHub", + "description": "Navbar item with label GitHub" + } +} diff --git a/docusaurus/docusaurus.config.js b/docusaurus/docusaurus.config.js index 4b5042f..8b26c9d 100644 --- a/docusaurus/docusaurus.config.js +++ b/docusaurus/docusaurus.config.js @@ -17,7 +17,11 @@ const config = { i18n: { defaultLocale: 'en', - locales: ['en'], + locales: ['en', 'nl'], + localeConfigs: { + en: { label: 'English' }, + nl: { label: 'Nederlands' }, + }, }, presets: [ @@ -60,6 +64,10 @@ const config = { label: 'GitHub', position: 'right', }, + { + type: 'localeDropdown', + position: 'right', + }, ], }, footer: { From a43900ba1ab678cb83eff985cc7718c350d85121 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 2 May 2026 17:54:26 +0200 Subject: [PATCH 04/12] feat(files): batchFiles action for one-shot publish/depublish/delete/label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `batchFiles(type, objectId, action, fileIds, params?)` to the files plugin. Replaces the N-sequential-calls pattern (loop calling publishFile/unpublishFile/deleteFile per id) with a single POST to `/files/batch`. The backend returns 200 when every operation succeeds, or 207 (multi-status) when some fail; per-file outcomes live in `data.results` and aggregate counts in `data.summary` ({ succeeded, failed, total }). Both 200 and 207 are treated as valid responses — the caller inspects data.summary to decide whether to surface a partial-failure UI. Backwards compatible: existing publishFile / unpublishFile / deleteFile actions are unchanged. Consumers that haven't migrated continue to work. Closes OpenRegister file-actions task 136 (ViewObject.vue migration to the batch endpoint, paired in a separate openregister commit). --- docs/store/plugins/files.md | 1 + src/store/plugins/files.js | 54 ++++++++++++++++++++++++- tests/store/useObjectStore.spec.js | 64 ++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/docs/store/plugins/files.md b/docs/store/plugins/files.md index 453705e..fd62637 100644 --- a/docs/store/plugins/files.md +++ b/docs/store/plugins/files.md @@ -70,6 +70,7 @@ Tags: | `publishFile` | `(type, objectId, fileId) => Promise` | POST `/files//publish`. Re-fetches on success. | | `unpublishFile` | `(type, objectId, fileId) => Promise` | POST `/files//depublish`. Re-fetches on success. | | `deleteFile` | `(type, objectId, fileId) => Promise` | DELETE `/files/`. Re-fetches on success. | +| `batchFiles` | `(type, objectId, action, fileIds, params?) => Promise` | POST `/files/batch` to apply `publish` / `depublish` / `delete` / `label` across many files in ONE round-trip. Returns `{ results, summary: { succeeded, failed, total } }` for both 200 (all OK) and 207 (partial). Re-fetches on success. | | `fetchTags` | `() => Promise` | GET the tags endpoint (derived from `baseUrl` by replacing `/objects` with `/tags`). Returns the array (also stored in `state.tags`). | | `clearFiles` | `() => void` | From `createSubResourcePlugin`. Reset files state. | diff --git a/src/store/plugins/files.js b/src/store/plugins/files.js index b31bafb..8373f12 100644 --- a/src/store/plugins/files.js +++ b/src/store/plugins/files.js @@ -9,7 +9,7 @@ import { parseResponseError, networkError } from '../../utils/errors.js' * upload (multipart), publish, unpublish, and delete. * * State: files, filesLoading, filesError, tags, tagsLoading, tagsError - * Actions: fetchFiles, uploadFiles, publishFile, unpublishFile, deleteFile, clearFiles, fetchTags + * Actions: fetchFiles, uploadFiles, publishFile, unpublishFile, deleteFile, batchFiles, clearFiles, fetchTags * Getters: getFiles, isFilesLoading, getFilesError, getTags, isTagsLoading, getTagsError * * @param {object} [options={}] Plugin options @@ -245,6 +245,58 @@ export function filesPlugin(options = {}) { this.filesLoading = false } }, + + /** + * Apply a batch action across multiple files in ONE request. + * + * Replaces the N-sequential-call pattern (loop calling + * publishFile/unpublishFile/deleteFile per id) with a single POST + * to /files/batch. The backend returns 200 when every operation + * succeeds, or 207 (multi-status) when some fail; the per-file + * outcomes live in `data.results` and the aggregate counts in + * `data.summary` (`{ succeeded, failed, total }`). + * + * @param {string} type The registered object type slug + * @param {string} objectId The parent object ID + * @param {('publish'|'depublish'|'delete'|'label')} action The batch action to apply + * @param {(string|number)[]} fileIds File IDs to act on (max 100, validated server-side) + * @param {object} [params={}] Action-specific parameters (e.g. labels for the 'label' action) + * @return {Promise} Response body `{ results, summary }`, or null on transport error + */ + async batchFiles(type, objectId, action, fileIds, params = {}) { + this.filesLoading = true + this.filesError = null + + try { + const url = this._buildUrl(type, objectId) + '/files/batch' + + const response = await fetch(url, { + method: 'POST', + headers: buildHeaders(), + body: JSON.stringify({ + action, + fileIds, + ...params, + }), + }) + + // 200 = all succeeded, 207 = partial success — both are + // valid responses; the caller inspects data.summary. + if (!response.ok && response.status !== 207) { + this.filesError = await parseResponseError(response, 'files') + return null + } + + const data = await response.json() + await this.fetchFiles(type, objectId) + return data + } catch (error) { + this.filesError = networkError(error) + return null + } finally { + this.filesLoading = false + } + }, }, } } diff --git a/tests/store/useObjectStore.spec.js b/tests/store/useObjectStore.spec.js index e115a14..8c70086 100644 --- a/tests/store/useObjectStore.spec.js +++ b/tests/store/useObjectStore.spec.js @@ -303,6 +303,7 @@ describe('createObjectStore with plugins', () => { expect(typeof store.uploadFiles).toBe('function') expect(typeof store.publishFile).toBe('function') expect(typeof store.deleteFile).toBe('function') + expect(typeof store.batchFiles).toBe('function') expect(typeof store.fetchTags).toBe('function') expect(typeof store.lockObject).toBe('function') expect(typeof store.unlockObject).toBe('function') @@ -324,6 +325,69 @@ describe('createObjectStore with plugins', () => { expect(store.getTagsError).toBeNull() }) + it('batchFiles posts to /files/batch with one round-trip for many ids', async () => { + const useStore = createObjectStore('test-batch-files', { + plugins: [filesPlugin()], + }) + store = useStore() + store.registerObjectType('case', '28', '5') + + // Two responses: the batch POST + the follow-up fetchFiles refresh. + global.fetch = jest.fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ + results: [{ fileId: 1, ok: true }, { fileId: 2, ok: true }], + summary: { succeeded: 2, failed: 0, total: 2 }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ results: [], total: 0, page: 1, pages: 1, limit: 20, offset: 0 }), + }) + + const result = await store.batchFiles('case', 'obj-uuid', 'publish', [1, 2]) + + expect(global.fetch).toHaveBeenCalledTimes(2) + const [url, opts] = global.fetch.mock.calls[0] + expect(url).toContain('/files/batch') + expect(opts.method).toBe('POST') + expect(JSON.parse(opts.body)).toEqual({ + action: 'publish', + fileIds: [1, 2], + }) + expect(result.summary).toEqual({ succeeded: 2, failed: 0, total: 2 }) + }) + + it('batchFiles accepts 207 partial-success without flagging error', async () => { + const useStore = createObjectStore('test-batch-207', { + plugins: [filesPlugin()], + }) + store = useStore() + store.registerObjectType('case', '28', '5') + + global.fetch = jest.fn() + .mockResolvedValueOnce({ + ok: false, + status: 207, + json: () => Promise.resolve({ + results: [{ fileId: 1, ok: true }, { fileId: 2, ok: false, error: 'locked' }], + summary: { succeeded: 1, failed: 1, total: 2 }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ results: [], total: 0, page: 1, pages: 1, limit: 20, offset: 0 }), + }) + + const result = await store.batchFiles('case', 'obj-uuid', 'delete', [1, 2]) + + expect(result).not.toBeNull() + expect(result.summary.failed).toBe(1) + expect(store.filesError).toBeNull() + }) + it('fetchTags calls tags API and stores array of strings', async () => { const useStore = createObjectStore('test-fetch-tags', { plugins: [filesPlugin()], From 5bf89e078089cdc1aa54b2b7c968856e83f91be3 Mon Sep 17 00:00:00 2001 From: Remko Date: Mon, 4 May 2026 10:20:03 +0200 Subject: [PATCH 05/12] Updated dashboard to allow icons --- .../CnDashboardPage/CnDashboardPage.vue | 18 ++++++++++- .../CnWidgetWrapper/CnWidgetWrapper.vue | 32 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/components/CnDashboardPage/CnDashboardPage.vue b/src/components/CnDashboardPage/CnDashboardPage.vue index 6214f42..a1d72ed 100644 --- a/src/components/CnDashboardPage/CnDashboardPage.vue +++ b/src/components/CnDashboardPage/CnDashboardPage.vue @@ -83,7 +83,13 @@ :borderless="item.showTitle === false" :flush="item.flush === true" :buttons="getWidgetButtons(item)" - :style-config="item.styleConfig || {}"> + :style-config="item.styleConfig || {}" + :title-icon-position="getWidgetTitleIconPosition(item)" + :title-icon-color="getWidgetTitleIconColor(item)"> + +