+
{{ formatTagName(tag) }}
@@ -61,10 +67,15 @@
-
+
{{ t('systemtags', 'Cancel') }}
-
+
{{ t('systemtags', 'Apply changes') }}
@@ -270,11 +281,11 @@ export default defineComponent({
},
formatTagName(tag: TagWithId): string {
- if (tag.userVisible) {
+ if (!tag.userVisible) {
return t('systemtags', '{displayName} (hidden)', { displayName: tag.displayName })
}
- if (tag.userAssignable) {
+ if (!tag.userAssignable) {
return t('systemtags', '{displayName} (restricted)', { displayName: tag.displayName })
}
@@ -317,6 +328,9 @@ export default defineComponent({
const tag = await fetchTag(id)
this.tags.push(tag)
this.input = ''
+
+ // Check the newly created tag
+ this.onCheckUpdate(tag, true)
} catch (error) {
showError((error as Error)?.message || t('systemtags', 'Failed to create tag'))
} finally {
diff --git a/apps/systemtags/src/event-bus.d.ts b/apps/systemtags/src/event-bus.d.ts
index 462e6d4885f53..7578d8e8113aa 100644
--- a/apps/systemtags/src/event-bus.d.ts
+++ b/apps/systemtags/src/event-bus.d.ts
@@ -1,3 +1,7 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
import type { Node } from '@nextcloud/files'
declare module '@nextcloud/event-bus' {
diff --git a/apps/systemtags/src/files_actions/bulkSystemTagsAction.ts b/apps/systemtags/src/files_actions/bulkSystemTagsAction.ts
index 529ba232b272c..7e7c29f64f01e 100644
--- a/apps/systemtags/src/files_actions/bulkSystemTagsAction.ts
+++ b/apps/systemtags/src/files_actions/bulkSystemTagsAction.ts
@@ -18,7 +18,7 @@ export const action = new FileAction({
// If the app is disabled, the action is not available anyway
enabled(nodes) {
- if (nodes.length > 0) {
+ if (nodes.length === 0) {
return false
}
diff --git a/cypress/e2e/files/FilesUtils.ts b/cypress/e2e/files/FilesUtils.ts
index 0f2b11542002c..182972ee44c13 100644
--- a/cypress/e2e/files/FilesUtils.ts
+++ b/cypress/e2e/files/FilesUtils.ts
@@ -14,11 +14,15 @@ export const getActionButtonForFile = (filename: string) => getActionsForFile(fi
export const triggerActionForFileId = (fileid: number, actionId: string) => {
getActionButtonForFileId(fileid).click()
- cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).should('exist').click()
+ // Getting the last button to avoid the one from popup fading out
+ cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).last()
+ .should('exist').click()
}
export const triggerActionForFile = (filename: string, actionId: string) => {
getActionButtonForFile(filename).click()
- cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).should('exist').click()
+ // Getting the last button to avoid the one from popup fading out
+ cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).last()
+ .should('exist').click()
}
export const triggerInlineActionForFileId = (fileid: number, actionId: string) => {
@@ -28,6 +32,25 @@ export const triggerInlineActionForFile = (filename: string, actionId: string) =
getActionsForFile(filename).get(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`).should('exist').click()
}
+export const selectAllFiles = () => {
+ cy.get('[data-cy-files-list-selection-checkbox]').findByRole('checkbox').click({ force: true })
+}
+export const selectRowForFile = (filename: string) => {
+ getRowForFile(filename)
+ .find('[data-cy-files-list-row-checkbox]')
+ .findByRole('checkbox')
+ .click({ force: true })
+ .should('be.checked')
+ cy.get('[data-cy-files-list-selection-checkbox]').findByRole('checkbox').should('satisfy', (elements) => {
+ return elements.length === 1 && (elements[0].checked === true || elements[0].indeterminate === true)
+ })
+
+}
+
+export const triggerSelectionAction = (actionId: string) => {
+ cy.get(`button[data-cy-files-list-selection-action="${CSS.escape(actionId)}"]`).should('exist').click()
+}
+
export const moveFile = (fileName: string, dirPath: string) => {
getRowForFile(fileName).should('be.visible')
triggerActionForFile(fileName, 'move-copy')
diff --git a/cypress/e2e/systemtags/files-bulk-action.cy.ts b/cypress/e2e/systemtags/files-bulk-action.cy.ts
new file mode 100644
index 0000000000000..66a36aa22e790
--- /dev/null
+++ b/cypress/e2e/systemtags/files-bulk-action.cy.ts
@@ -0,0 +1,354 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import { randomBytes } from 'crypto'
+import { getRowForFile, selectAllFiles, selectRowForFile, triggerSelectionAction } from '../files/FilesUtils'
+import { createShare } from '../files_sharing/FilesSharingUtils'
+
+let tags = {} as Record
+const files = [
+ 'file1.txt',
+ 'file2.txt',
+ 'file3.txt',
+ 'file4.txt',
+ 'file5.txt',
+]
+
+function resetTags() {
+ tags = {}
+ for (const tag in [0, 1, 2, 3, 4]) {
+ tags[randomBytes(8).toString('base64').slice(0, 6)] = 0
+ }
+
+ // delete any existing tags
+ cy.runOccCommand('tag:list --output=json').then((output) => {
+ Object.keys(JSON.parse(output.stdout)).forEach((id) => {
+ cy.runOccCommand(`tag:delete ${id}`)
+ })
+ })
+
+ // create tags
+ Object.keys(tags).forEach((tag) => {
+ cy.runOccCommand(`tag:add ${tag} public --output=json`).then((output) => {
+ tags[tag] = JSON.parse(output.stdout).id as number
+ })
+ })
+ cy.log('Using tags', tags)
+}
+
+function expectInlineTagForFile(file: string, tags: string[]) {
+ getRowForFile(file)
+ .find('[data-systemtags-fileid]')
+ .findAllByRole('listitem')
+ .should('have.length', tags.length)
+ .each(tag => {
+ expect(tag.text()).to.be.oneOf(tags)
+ })
+}
+
+function triggerTagManagementDialogAction() {
+ cy.intercept('PROPFIND', '/remote.php/dav/systemtags/').as('getTagsList')
+ triggerSelectionAction('systemtags:bulk')
+ cy.wait('@getTagsList')
+ cy.get('[data-cy-systemtags-picker]').should('be.visible')
+}
+
+describe('Systemtags: Files bulk action', { testIsolation: false }, () => {
+ let snapshot: string
+ let user1: User
+ let user2: User
+
+ before(() => {
+ cy.createRandomUser().then((_user1) => {
+ user1 = _user1
+ cy.createRandomUser().then((_user2) => {
+ user2 = _user2
+ })
+
+ files.forEach((file) => {
+ cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file)
+ })
+ })
+
+ resetTags()
+ })
+
+ it('Can assign tag to selection', () => {
+ cy.login(user1)
+ cy.visit('/apps/files')
+
+ files.forEach((file) => {
+ getRowForFile(file).should('be.visible')
+ })
+ selectRowForFile('file2.txt')
+ selectRowForFile('file4.txt')
+
+ triggerTagManagementDialogAction()
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
+
+ cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
+ cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
+
+ const tag = Object.keys(tags)[3]
+ cy.get(`[data-cy-systemtags-picker-tag=${tags[tag]}]`).should('be.visible')
+ .findByRole('checkbox').click({ force: true })
+ cy.get('[data-cy-systemtags-picker-button-submit]').click()
+
+ cy.wait('@getTagData')
+ cy.wait('@assignTagData')
+ cy.get('[data-cy-systemtags-picker]').should('not.exist')
+
+ expectInlineTagForFile('file2.txt', [tag])
+ expectInlineTagForFile('file4.txt', [tag])
+ })
+
+ it('Can assign multiple tags to selection', () => {
+ cy.login(user1)
+ cy.visit('/apps/files')
+
+ files.forEach((file) => {
+ getRowForFile(file).should('be.visible')
+ })
+ selectAllFiles()
+
+ triggerTagManagementDialogAction()
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
+
+ cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
+ cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
+
+ const prevTag = Object.keys(tags)[3]
+ const tag1 = Object.keys(tags)[1]
+ const tag2 = Object.keys(tags)[2]
+ cy.get(`[data-cy-systemtags-picker-tag=${tags[tag1]}]`).should('be.visible')
+ .findByRole('checkbox').click({ force: true })
+ cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible')
+ .findByRole('checkbox').click({ force: true })
+ cy.get('[data-cy-systemtags-picker-button-submit]').click()
+
+ cy.wait('@getTagData')
+ cy.wait('@assignTagData')
+ cy.get('@getTagData.all').should('have.length', 2)
+ cy.get('@assignTagData.all').should('have.length', 2)
+ cy.get('[data-cy-systemtags-picker]').should('not.exist')
+
+ expectInlineTagForFile('file1.txt', [tag1, tag2])
+ expectInlineTagForFile('file2.txt', [prevTag, tag1, tag2])
+ expectInlineTagForFile('file3.txt', [tag1, tag2])
+ expectInlineTagForFile('file4.txt', [prevTag, tag1, tag2])
+ expectInlineTagForFile('file5.txt', [tag1, tag2])
+ })
+
+ it('Can remove tag from selection', () => {
+ cy.login(user1)
+ cy.visit('/apps/files')
+
+ files.forEach((file) => {
+ getRowForFile(file).should('be.visible')
+ })
+ selectRowForFile('file1.txt')
+ selectRowForFile('file3.txt')
+ selectRowForFile('file4.txt')
+
+ triggerTagManagementDialogAction()
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
+
+ cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
+ cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
+
+ const firstTag = Object.keys(tags)[3]
+ const tag1 = Object.keys(tags)[1]
+ const tag2 = Object.keys(tags)[2]
+ cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible')
+ .findByRole('checkbox').click({ force: true })
+ cy.get('[data-cy-systemtags-picker-button-submit]').click()
+
+ cy.wait('@getTagData')
+ cy.wait('@assignTagData')
+ cy.get('[data-cy-systemtags-picker]').should('not.exist')
+
+ expectInlineTagForFile('file1.txt', [tag1])
+ expectInlineTagForFile('file2.txt', [firstTag, tag1, tag2])
+ expectInlineTagForFile('file3.txt', [tag1])
+ expectInlineTagForFile('file4.txt', [firstTag, tag1])
+ expectInlineTagForFile('file5.txt', [tag1, tag2])
+
+ })
+
+ it('Can remove multiple tags from selection', () => {
+ cy.login(user1)
+ cy.visit('/apps/files')
+
+ files.forEach((file) => {
+ getRowForFile(file).should('be.visible')
+ })
+ selectAllFiles()
+
+ triggerTagManagementDialogAction()
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
+
+ cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
+ cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
+
+ cy.get('[data-cy-systemtags-picker-tag] input:indeterminate').should('exist')
+ .click({ force: true, multiple: true })
+ // indeterminate became checked
+ cy.get('[data-cy-systemtags-picker-tag] input:checked').should('exist')
+ .click({ force: true, multiple: true })
+ // now all are unchecked
+ cy.get('[data-cy-systemtags-picker-button-submit]').click()
+
+ cy.wait('@getTagData')
+ cy.wait('@assignTagData')
+ cy.get('@getTagData.all').should('have.length', 3)
+ cy.get('@assignTagData.all').should('have.length', 3)
+ cy.get('[data-cy-systemtags-picker]').should('not.exist')
+
+ expectInlineTagForFile('file1.txt', [])
+ expectInlineTagForFile('file2.txt', [])
+ expectInlineTagForFile('file3.txt', [])
+ expectInlineTagForFile('file4.txt', [])
+ expectInlineTagForFile('file5.txt', [])
+ })
+
+ it('Can assign and remove multiple tags as a secondary user', () => {
+ // Create new users
+ cy.createRandomUser().then((_user1) => {
+ user1 = _user1
+ cy.createRandomUser().then((_user2) => {
+ user2 = _user2
+ })
+
+ files.forEach((file) => {
+ cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file)
+ })
+ })
+
+ cy.login(user1)
+ cy.visit('/apps/files')
+
+ files.forEach((file) => {
+ getRowForFile(file).should('be.visible')
+ })
+ selectAllFiles()
+
+ triggerTagManagementDialogAction()
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
+
+ cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
+ cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
+
+ const tag1 = Object.keys(tags)[0]
+ const tag2 = Object.keys(tags)[3]
+ cy.get(`[data-cy-systemtags-picker-tag=${tags[tag1]}]`).should('be.visible')
+ .findByRole('checkbox').click({ force: true })
+ cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible')
+ .findByRole('checkbox').click({ force: true })
+ cy.get('[data-cy-systemtags-picker-button-submit]').click()
+
+ cy.wait('@getTagData')
+ cy.wait('@assignTagData')
+ cy.get('@getTagData.all').should('have.length', 2)
+ cy.get('@assignTagData.all').should('have.length', 2)
+ cy.get('[data-cy-systemtags-picker]').should('not.exist')
+
+ expectInlineTagForFile('file1.txt', [tag1, tag2])
+ expectInlineTagForFile('file2.txt', [tag1, tag2])
+ expectInlineTagForFile('file3.txt', [tag1, tag2])
+ expectInlineTagForFile('file4.txt', [tag1, tag2])
+ expectInlineTagForFile('file5.txt', [tag1, tag2])
+
+ createShare('file1.txt', user2.userId)
+ createShare('file3.txt', user2.userId)
+
+ cy.login(user2)
+ cy.visit('/apps/files')
+
+ getRowForFile('file1.txt').should('be.visible')
+ getRowForFile('file3.txt').should('be.visible')
+
+ expectInlineTagForFile('file1.txt', [tag1, tag2])
+ expectInlineTagForFile('file3.txt', [tag1, tag2])
+
+ selectRowForFile('file1.txt')
+ selectRowForFile('file3.txt')
+ triggerTagManagementDialogAction()
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
+
+ cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
+ cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
+
+ cy.get(`[data-cy-systemtags-picker-tag=${tags[tag1]}]`).should('be.visible')
+ .findByRole('checkbox').click({ force: true })
+ cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible')
+ .findByRole('checkbox').click({ force: true })
+ cy.get('[data-cy-systemtags-picker-button-submit]').click()
+
+ cy.wait('@getTagData')
+ cy.wait('@assignTagData')
+ cy.get('@getTagData.all').should('have.length', 2)
+ cy.get('@assignTagData.all').should('have.length', 2)
+ cy.get('[data-cy-systemtags-picker]').should('not.exist')
+
+ expectInlineTagForFile('file1.txt', [])
+ expectInlineTagForFile('file3.txt', [])
+
+ cy.login(user1)
+ cy.visit('/apps/files')
+
+ expectInlineTagForFile('file1.txt', [])
+ expectInlineTagForFile('file3.txt', [])
+ })
+
+ it('Can create tag and assign files to it', () => {
+ cy.createRandomUser().then((user1) => {
+ files.forEach((file) => {
+ cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file)
+ })
+
+ cy.login(user1)
+ cy.visit('/apps/files')
+
+ files.forEach((file) => {
+ getRowForFile(file).should('be.visible')
+ })
+ selectAllFiles()
+
+ triggerTagManagementDialogAction()
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
+
+ cy.intercept('POST', '/remote.php/dav/systemtags').as('createTag')
+ cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
+ cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
+
+ const newTag = randomBytes(8).toString('base64').slice(0, 6)
+ cy.get('[data-cy-systemtags-picker-input]').type(newTag)
+ cy.get('[data-cy-systemtags-picker-input-submit]').click()
+
+ cy.wait('@createTag')
+ cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 6)
+ // Verify the new tag is selected by default
+ cy.get('[data-cy-systemtags-picker-tag]').contains(newTag)
+ .parents('[data-cy-systemtags-picker-tag]')
+ .findByRole('checkbox', { hidden: true }).should('be.checked')
+
+ // Apply changes
+ cy.get('[data-cy-systemtags-picker-button-submit]').click()
+
+ cy.wait('@getTagData')
+ cy.wait('@assignTagData')
+ cy.get('@getTagData.all').should('have.length', 1)
+ cy.get('@assignTagData.all').should('have.length', 1)
+ cy.get('[data-cy-systemtags-picker]').should('not.exist')
+
+ expectInlineTagForFile('file1.txt', [newTag])
+ expectInlineTagForFile('file2.txt', [newTag])
+ expectInlineTagForFile('file3.txt', [newTag])
+ expectInlineTagForFile('file4.txt', [newTag])
+ expectInlineTagForFile('file5.txt', [newTag])
+ })
+ })
+})
diff --git a/lib/private/SystemTag/SystemTagObjectMapper.php b/lib/private/SystemTag/SystemTagObjectMapper.php
index ddb04fa968d65..09a7ce1a6eda7 100644
--- a/lib/private/SystemTag/SystemTagObjectMapper.php
+++ b/lib/private/SystemTag/SystemTagObjectMapper.php
@@ -204,7 +204,7 @@ public function unassignTags(string $objId, string $objectType, $tagIds): void {
/**
* Update the etag for the given tags.
*
- * @param int[] $tagIds
+ * @param string[] $tagIds
*/
private function updateEtagForTags(array $tagIds): void {
// Update etag after assigning tags
diff --git a/lib/public/SystemTag/ISystemTagObjectMapper.php b/lib/public/SystemTag/ISystemTagObjectMapper.php
index 96e8c1e848a2f..c604fa93c586a 100644
--- a/lib/public/SystemTag/ISystemTagObjectMapper.php
+++ b/lib/public/SystemTag/ISystemTagObjectMapper.php
@@ -129,7 +129,7 @@ public function getAvailableObjectTypes(): array;
* @param string $tagId tag id
* @param string $objectType object type
* @param string[] $objectIds list of object ids
- *
+ *
* @throws TagNotFoundException if the tag does not exist
* @since 31.0.0
*/