diff options
author | skjnldsv <skjnldsv@protonmail.com> | 2024-10-23 08:15:40 +0200 |
---|---|---|
committer | skjnldsv <skjnldsv@protonmail.com> | 2024-10-29 09:08:31 +0100 |
commit | 2cc377147619fed9838bd016ccff6e4766dd4a44 (patch) | |
tree | 86f21b29d20559ae1ae6d181230b7db633a42263 | |
parent | 14e2a8d3f982656e2a7c23c006fd59fdeebc80bd (diff) | |
download | nextcloud-server-2cc377147619fed9838bd016ccff6e4766dd4a44.tar.gz nextcloud-server-2cc377147619fed9838bd016ccff6e4766dd4a44.zip |
feat(systemtags): emit tags changes and optimise tag updates performances
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
-rw-r--r-- | apps/systemtags/src/components/SystemTagPicker.vue | 81 | ||||
-rw-r--r-- | apps/systemtags/src/event-bus.d.ts | 9 | ||||
-rw-r--r-- | apps/systemtags/src/files_actions/inlineSystemTagsAction.spec.ts | 62 | ||||
-rw-r--r-- | apps/systemtags/src/files_actions/inlineSystemTagsAction.ts | 101 | ||||
-rw-r--r-- | apps/systemtags/src/services/api.ts | 4 | ||||
-rw-r--r-- | apps/systemtags/src/utils.ts | 7 |
6 files changed, 192 insertions, 72 deletions
diff --git a/apps/systemtags/src/components/SystemTagPicker.vue b/apps/systemtags/src/components/SystemTagPicker.vue index 55b664e3c9a..43e7e8fa682 100644 --- a/apps/systemtags/src/components/SystemTagPicker.vue +++ b/apps/systemtags/src/components/SystemTagPicker.vue @@ -11,7 +11,7 @@ close-on-click-outside out-transition @update:open="onCancel"> - <NcEmptyContent v-if="loading || done" :name="t('systemtags', 'Applying changes…')"> + <NcEmptyContent v-if="loading || done" :name="t('systemtags', 'Applying tags changes…')"> <template #icon> <NcLoadingIcon v-if="!done" /> <CheckIcon v-else fill-color="var(--color-success)" /> @@ -86,8 +86,8 @@ import type { TagWithId } from '../types' import { defineComponent } from 'vue' import { emit } from '@nextcloud/event-bus' import { sanitize } from 'dompurify' -import { showInfo } from '@nextcloud/dialogs' -import { t } from '@nextcloud/l10n' +import { showError, showInfo } from '@nextcloud/dialogs' +import { getLanguage, t } from '@nextcloud/l10n' import escapeHTML from 'escape-html' import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' @@ -101,7 +101,7 @@ import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' import TagIcon from 'vue-material-design-icons/Tag.vue' import CheckIcon from 'vue-material-design-icons/CheckCircle.vue' -import { getNodeSystemTags } from '../utils' +import { getNodeSystemTags, setNodeSystemTags } from '../utils' import { getTagObjects, setTagObjects } from '../services/api' import logger from '../services/logger' @@ -303,23 +303,66 @@ export default defineComponent({ toRemove: this.toRemove, }) - // Add tags - for (const tag of this.toAdd) { - const { etag, objects } = await getTagObjects(tag, 'files') - let ids = [...objects.map(obj => obj.id), ...this.nodes.map(node => node.fileid)] as number[] - // Remove duplicates and empty ids - ids = [...new Set(ids.filter(id => !!id))] - await setTagObjects(tag, 'files', ids.map(id => ({ id, type: 'files' })), etag) + try { + // Add tags + for (const tag of this.toAdd) { + const { etag, objects } = await getTagObjects(tag, 'files') + + // Create a new list of ids in one pass + const ids = [...new Set([ + ...objects.map(obj => obj.id).filter(Boolean), + ...this.nodes.map(node => node.fileid).filter(Boolean), + ])] as number[] + + // Set tags + await setTagObjects(tag, 'files', ids.map(id => ({ id, type: 'files' })), etag) + } + + // Remove tags + for (const tag of this.toRemove) { + const { etag, objects } = await getTagObjects(tag, 'files') + + // Get file IDs from the nodes array just once + const nodeFileIds = new Set(this.nodes.map(node => node.fileid)) + + // Create a filtered and deduplicated list of ids in one pass + const ids = objects + .map(obj => obj.id) + .filter((id, index, self) => !nodeFileIds.has(id) && self.indexOf(id) === index) + + // Set tags + await setTagObjects(tag, 'files', ids.map(id => ({ id, type: 'files' })), etag) + } + } catch (error) { + logger.error('Failed to apply tags', { error }) + showError(t('systemtags', 'Failed to apply tags changes')) + this.loading = false + return } - // Remove tags - for (const tag of this.toRemove) { - const { etag, objects } = await getTagObjects(tag, 'files') - let ids = objects.map(obj => obj.id) as number[] - // Remove the ids of the nodes and remove duplicates - ids = [...new Set(ids.filter(id => !this.nodes.map(node => node.fileid).includes(id)))] - await setTagObjects(tag, 'files', ids.map(id => ({ id, type: 'files' })), etag) - } + const nodes = [] as Node[] + + // Update nodes + this.toAdd.forEach(tag => { + this.nodes.forEach(node => { + const tags = [...(getNodeSystemTags(node) || []), tag.displayName] + .sort((a, b) => a.localeCompare(b, getLanguage(), { ignorePunctuation: true })) + setNodeSystemTags(node, tags) + nodes.push(node) + }) + }) + + this.toRemove.forEach(tag => { + this.nodes.forEach(node => { + const tags = [...(getNodeSystemTags(node) || [])].filter(t => t !== tag.displayName) + .sort((a, b) => a.localeCompare(b, getLanguage(), { ignorePunctuation: true })) + setNodeSystemTags(node, tags) + nodes.push(node) + }) + }) + + // trigger update event + nodes.forEach(node => emit('systemtags:node:updated', node)) this.done = true this.loading = false diff --git a/apps/systemtags/src/event-bus.d.ts b/apps/systemtags/src/event-bus.d.ts new file mode 100644 index 00000000000..368d0acaf0f --- /dev/null +++ b/apps/systemtags/src/event-bus.d.ts @@ -0,0 +1,9 @@ +import type { Node } from '@nextcloud/files' + +declare module '@nextcloud/event-bus' { + interface NextcloudEvents { + 'systemtags:node:updated': Node + } +} + +export {} diff --git a/apps/systemtags/src/files_actions/inlineSystemTagsAction.spec.ts b/apps/systemtags/src/files_actions/inlineSystemTagsAction.spec.ts index 9d25bfcd2ad..6861b5015a1 100644 --- a/apps/systemtags/src/files_actions/inlineSystemTagsAction.spec.ts +++ b/apps/systemtags/src/files_actions/inlineSystemTagsAction.spec.ts @@ -5,6 +5,8 @@ import { action } from './inlineSystemTagsAction' import { describe, expect, test } from 'vitest' import { File, Permission, View, FileAction } from '@nextcloud/files' +import { emit, subscribe } from '@nextcloud/event-bus' +import { setNodeSystemTags } from '../utils' const view = { id: 'files', @@ -28,7 +30,8 @@ describe('Inline system tags action conditions tests', () => { expect(action.default).toBeUndefined() expect(action.enabled).toBeDefined() expect(action.order).toBe(0) - expect(action.enabled!([file], view)).toBe(false) + // Always enabled + expect(action.enabled!([file], view)).toBe(true) }) test('Enabled with valid system tags', () => { @@ -50,7 +53,7 @@ describe('Inline system tags action conditions tests', () => { }) describe('Inline system tags action render tests', () => { - test('Render nothing when Node does not have system tags', async () => { + test('Render something even when Node does not have system tags', async () => { const file = new File({ id: 1, source: 'http://localhost/remote.php/dav/files/admin/foobar.txt', @@ -60,7 +63,10 @@ describe('Inline system tags action render tests', () => { }) const result = await action.renderInline!(file, view) - expect(result).toBeNull() + expect(result).toBeInstanceOf(HTMLElement) + expect(result!.outerHTML).toMatchInlineSnapshot( + '"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"></ul>"', + ) }) test('Render a single system tag', async () => { @@ -80,7 +86,7 @@ describe('Inline system tags action render tests', () => { const result = await action.renderInline!(file, view) expect(result).toBeInstanceOf(HTMLElement) expect(result!.outerHTML).toMatchInlineSnapshot( - '"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags"><li class="files-list__system-tag">Confidential</li></ul>"', + '"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"><li class="files-list__system-tag">Confidential</li></ul>"', ) }) @@ -101,7 +107,7 @@ describe('Inline system tags action render tests', () => { const result = await action.renderInline!(file, view) expect(result).toBeInstanceOf(HTMLElement) expect(result!.outerHTML).toMatchInlineSnapshot( - '"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags"><li class="files-list__system-tag">Important</li><li class="files-list__system-tag">Confidential</li></ul>"', + '"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"><li class="files-list__system-tag">Important</li><li class="files-list__system-tag">Confidential</li></ul>"', ) }) @@ -127,7 +133,51 @@ describe('Inline system tags action render tests', () => { const result = await action.renderInline!(file, view) expect(result).toBeInstanceOf(HTMLElement) expect(result!.outerHTML).toMatchInlineSnapshot( - '"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags"><li class="files-list__system-tag">Important</li><li class="files-list__system-tag files-list__system-tag--more" title="Confidential, Secret, Classified" aria-hidden="true" role="presentation">+3</li><li class="files-list__system-tag hidden-visually">Confidential</li><li class="files-list__system-tag hidden-visually">Secret</li><li class="files-list__system-tag hidden-visually">Classified</li></ul>"', + '"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"><li class="files-list__system-tag">Important</li><li class="files-list__system-tag files-list__system-tag--more" title="Confidential, Secret, Classified" aria-hidden="true" role="presentation">+3</li><li class="files-list__system-tag hidden-visually">Confidential</li><li class="files-list__system-tag hidden-visually">Secret</li><li class="files-list__system-tag hidden-visually">Classified</li></ul>"', + ) + }) + + test('Render gets updated on system tag change', async () => { + const file = new File({ + id: 1, + source: 'http://localhost/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + 'system-tags': { + 'system-tag': [ + 'Important', + 'Confidential', + 'Secret', + 'Classified', + ], + }, + }, + }) + + const result = await action.renderInline!(file, view) as HTMLElement + document.body.appendChild(result) + expect(result).toBeInstanceOf(HTMLElement) + expect(document.body.innerHTML).toMatchInlineSnapshot( + '"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"><li class="files-list__system-tag">Important</li><li class="files-list__system-tag files-list__system-tag--more" title="Confidential, Secret, Classified" aria-hidden="true" role="presentation">+3</li><li class="files-list__system-tag hidden-visually">Confidential</li><li class="files-list__system-tag hidden-visually">Secret</li><li class="files-list__system-tag hidden-visually">Classified</li></ul>"', + ) + + // Subscribe to the event + const eventPromise = new Promise((resolve) => { + subscribe('systemtags:node:updated', resolve) + }) + + // Change tags + setNodeSystemTags(file, ['Public']) + emit('systemtags:node:updated', file) + expect(file.attributes!['system-tags']!['system-tag']).toEqual(['Public']) + + // Wait for the event to be processed + await eventPromise + + expect(document.body.innerHTML).toMatchInlineSnapshot( + '"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"><li class="files-list__system-tag">Public</li></ul>"', ) }) }) diff --git a/apps/systemtags/src/files_actions/inlineSystemTagsAction.ts b/apps/systemtags/src/files_actions/inlineSystemTagsAction.ts index 2a7ee3e7eb2..afd54a4f7de 100644 --- a/apps/systemtags/src/files_actions/inlineSystemTagsAction.ts +++ b/apps/systemtags/src/files_actions/inlineSystemTagsAction.ts @@ -4,6 +4,7 @@ */ import type { Node } from '@nextcloud/files' import { FileAction } from '@nextcloud/files' +import { subscribe } from '@nextcloud/event-bus' import { t } from '@nextcloud/l10n' import '../css/fileEntryInlineSystemTags.scss' @@ -21,6 +22,46 @@ const renderTag = function(tag: string, isMore = false): HTMLElement { return tagElement } +const renderInline = async function(node: Node): Promise<HTMLElement> { + // Ensure we have the system tags as an array + const tags = getNodeSystemTags(node) + + const systemTagsElement = document.createElement('ul') + systemTagsElement.classList.add('files-list__system-tags') + systemTagsElement.setAttribute('aria-label', t('files', 'Assigned collaborative tags')) + systemTagsElement.setAttribute('data-systemtags-fileid', node.fileid?.toString() || '') + + if (tags.length === 0) { + return systemTagsElement + } + + systemTagsElement.append(renderTag(tags[0])) + if (tags.length === 2) { + // Special case only two tags: + // the overflow fake tag would take the same space as this, so render it + systemTagsElement.append(renderTag(tags[1])) + } else if (tags.length > 1) { + // More tags than the one we're showing + // So we add a overflow element indicating there are more tags + const moreTagElement = renderTag('+' + (tags.length - 1), true) + moreTagElement.setAttribute('title', tags.slice(1).join(', ')) + // because the title is not accessible we hide this element for screen readers (see alternative below) + moreTagElement.setAttribute('aria-hidden', 'true') + moreTagElement.setAttribute('role', 'presentation') + systemTagsElement.append(moreTagElement) + + // For accessibility the tags are listed, as the title is not accessible + // but those tags are visually hidden + for (const tag of tags.slice(1)) { + const tagElement = renderTag(tag) + tagElement.classList.add('hidden-visually') + systemTagsElement.append(tagElement) + } + } + + return systemTagsElement +} + export const action = new FileAction({ id: 'system-tags', displayName: () => '', @@ -32,57 +73,23 @@ export const action = new FileAction({ return false } - const node = nodes[0] - const tags = getNodeSystemTags(node) - - // Only show the action if the node has system tags - if (tags.length === 0) { - return false - } - + // Always show the action, even if there are no tags + // This will render an empty tag list and allow events to update it return true }, exec: async () => null, - - async renderInline(node: Node) { - // Ensure we have the system tags as an array - const tags = getNodeSystemTags(node) - - if (tags.length === 0) { - return null - } - - const systemTagsElement = document.createElement('ul') - systemTagsElement.classList.add('files-list__system-tags') - systemTagsElement.setAttribute('aria-label', t('files', 'Assigned collaborative tags')) - - systemTagsElement.append(renderTag(tags[0])) - if (tags.length === 2) { - // Special case only two tags: - // the overflow fake tag would take the same space as this, so render it - systemTagsElement.append(renderTag(tags[1])) - } else if (tags.length > 1) { - // More tags than the one we're showing - // So we add a overflow element indicating there are more tags - const moreTagElement = renderTag('+' + (tags.length - 1), true) - moreTagElement.setAttribute('title', tags.slice(1).join(', ')) - // because the title is not accessible we hide this element for screen readers (see alternative below) - moreTagElement.setAttribute('aria-hidden', 'true') - moreTagElement.setAttribute('role', 'presentation') - systemTagsElement.append(moreTagElement) - - // For accessibility the tags are listed, as the title is not accessible - // but those tags are visually hidden - for (const tag of tags.slice(1)) { - const tagElement = renderTag(tag) - tagElement.classList.add('hidden-visually') - systemTagsElement.append(tagElement) - } - } - - return systemTagsElement - }, + renderInline, order: 0, }) + +const updateSystemTagsHtml = function(node: Node) { + renderInline(node).then((systemTagsHtml) => { + document.querySelectorAll(`[data-systemtags-fileid="${node.fileid}"]`).forEach((element) => { + element.replaceWith(systemTagsHtml) + }) + }) +} + +subscribe('systemtags:node:updated', updateSystemTagsHtml) diff --git a/apps/systemtags/src/services/api.ts b/apps/systemtags/src/services/api.ts index c37b6e51c20..39059d18347 100644 --- a/apps/systemtags/src/services/api.ts +++ b/apps/systemtags/src/services/api.ts @@ -143,6 +143,10 @@ export const getTagObjects = async function(tag: TagWithId, type: string): Promi /** * Set the objects for a tag. * Warning: This will overwrite the existing objects. + * @param tag The tag to set the objects for + * @param type The type of the objects + * @param objectIds The objects to set + * @param etag Strongly recommended to avoid conflict and data loss. */ export const setTagObjects = async function(tag: TagWithId, type: string, objectIds: TagObject[], etag: string = ''): Promise<void> { const path = `/systemtags/${tag.id}/${type}` diff --git a/apps/systemtags/src/utils.ts b/apps/systemtags/src/utils.ts index 456dc52ca84..35fed6026f3 100644 --- a/apps/systemtags/src/utils.ts +++ b/apps/systemtags/src/utils.ts @@ -9,6 +9,7 @@ import type { DAVResultResponseProps } from 'webdav' import type { BaseTag, ServerTag, Tag, TagWithId } from './types.js' import type { Node } from '@nextcloud/files' +import Vue from 'vue' export const defaultBaseTag: BaseTag = { userVisible: true, @@ -66,3 +67,9 @@ export const getNodeSystemTags = function(node: Node): string[] { return [tags].flat() } + +export const setNodeSystemTags = function(node: Node, tags: string[]): void { + Vue.set(node.attributes, 'system-tags', { + 'system-tag': tags, + }) +} |