Merge pull request #41335 from nextcloud/enh/a11y/admin-collab-tags

enh(systemtags): Add accessible system tags form
pull/41526/head
Pytal 2 years ago committed by GitHub
commit 50f8d6c129
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 29
      apps/systemtags/css/settings.css
  2. 193
      apps/systemtags/js/admin.js
  3. 1
      apps/systemtags/lib/Settings/Admin.php
  4. 32
      apps/systemtags/src/admin.ts
  5. 326
      apps/systemtags/src/components/SystemTagForm.vue
  6. 39
      apps/systemtags/src/components/SystemTags.vue
  7. 71
      apps/systemtags/src/services/api.ts
  8. 82
      apps/systemtags/src/services/files.ts
  9. 2
      apps/systemtags/src/types.ts
  10. 10
      apps/systemtags/src/utils.ts
  11. 99
      apps/systemtags/src/views/SystemTagsSection.vue
  12. 39
      apps/systemtags/templates/admin.php
  13. 138
      cypress/e2e/settings/systemtags.cy.ts
  14. 4
      dist/core-common.js
  15. 2
      dist/core-common.js.map
  16. 4
      dist/files-sidebar.js
  17. 2
      dist/files-sidebar.js.map
  18. 4
      dist/files_sharing-personal-settings.js
  19. 2
      dist/files_sharing-personal-settings.js.map
  20. 4
      dist/settings-vue-settings-admin-security.js
  21. 2
      dist/settings-vue-settings-admin-security.js.map
  22. 4
      dist/sharebymail-vue-settings-admin-sharebymail.js
  23. 2
      dist/sharebymail-vue-settings-admin-sharebymail.js.map
  24. 3
      dist/systemtags-admin.js
  25. 21
      dist/systemtags-admin.js.LICENSE.txt
  26. 1
      dist/systemtags-admin.js.map
  27. 4
      dist/systemtags-init.js
  28. 2
      dist/systemtags-init.js.map
  29. 1
      webpack.modules.js

@ -1,29 +0,0 @@
.systemtag-input {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.systemtag-input--name {
margin-right: 3px;
}
.systemtag-input--name,
.systemtag-input--level {
display: flex;
flex-direction: column;
}
.systemtag-input--actions {
margin-top: 25px;
display: flex;
flex-direction: row;
}
#systemtags .select2-container {
width: 100%;
max-width: 400px;
}
#systemtags .select2-container .select2-choice {
height: auto;
}
#systemtag_name {
width: 100%;
max-width: 400px;
}

@ -1,193 +0,0 @@
/**
* @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com>
* @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
(function() {
if (!OCA.SystemTags) {
/**
* @namespace
*/
OCA.SystemTags = {};
}
OCA.SystemTags.Admin = {
collection: null,
init: function() {
var self = this;
this.collection = OC.SystemTags.collection;
this.collection.fetch({
success: function() {
$('#systemtag').select2(_.extend(self.select2));
$('#systemtag').parent().children('.select2-container').attr('aria-expanded', 'false')
}
});
var self = this;
$('#systemtag_name').on('keyup', function(e) {
if (e.which === 13) {
_.bind(self._onClickSubmit, self)();
}
});
$('#systemtag_submit').on('click', _.bind(this._onClickSubmit, this));
$('#systemtag_delete').on('click', _.bind(this._onClickDelete, this));
$('#systemtag_reset').on('click', _.bind(this._onClickReset, this));
$('#systemtag').select2(_.extend(self.select2)).on('select2-open', () => {
$('.select2-container').attr('aria-expanded', 'true')
});
$('#systemtag').select2(_.extend(self.select2)).on('select2-close', () => {
$('.select2-container').attr('aria-expanded', 'false')
});
},
/**
* Selecting a systemtag in select2
*
* @param {OC.SystemTags.SystemTagModel} tag
*/
onSelectTag: function (tag) {
var level = 0;
if (tag.get('userVisible')) {
level += 2;
if (tag.get('userAssignable')) {
level += 1;
}
}
$('#systemtag_name').val(tag.get('name'));
$('#systemtag_level').val(level);
this._prepareForm(tag.get('id'));
},
/**
* Clicking the "Create"/"Update" button
*/
_onClickSubmit: function () {
var level = parseInt($('#systemtag_level').val(), 10),
tagId = $('#systemtags').attr('data-systemtag-id');
var data = {
name: $('#systemtag_name').val(),
userVisible: level === 2 || level === 3,
userAssignable: level === 3
};
if (!data.name) {
OCP.Toast.error(t('systemtags_manager', 'Tag name is empty'));
return;
}
if (tagId) {
var model = this.collection.get(tagId);
model.save(data);
} else {
this.collection.create(data);
}
this._onClickReset();
},
/**
* Clicking the "Delete" button
*/
_onClickDelete: function () {
var tagId = $('#systemtags').attr('data-systemtag-id');
var model = this.collection.get(tagId);
model.destroy();
this._onClickReset();
},
/**
* Clicking the "Reset" button
*/
_onClickReset: function () {
$('#systemtag_name').val('');
$('#systemtag_level').val(3);
this._prepareForm(0);
},
/**
* Prepare the form for create/update
*
* @param {number} tagId
*/
_prepareForm: function (tagId) {
if (tagId > 0) {
$('#systemtags').attr('data-systemtag-id', tagId);
$('#systemtag_delete').removeClass('hidden');
$('#systemtag_submit span').text(t('systemtags_manager', 'Update'));
$('#systemtag_create').addClass('hidden');
} else {
$('#systemtag').select2('val', '');
$('#systemtags').attr('data-systemtag-id', '');
$('#systemtag_delete').addClass('hidden');
$('#systemtag_submit span').text(t('systemtags_manager', 'Create'));
$('#systemtag_create').removeClass('hidden');
}
},
/**
* Select2 options for the SystemTag dropdown
*/
select2: {
allowClear: false,
multiple: false,
placeholder: t('systemtags_manager', 'Select tag …'),
query: _.debounce(function(query) {
query.callback({
results: OCA.SystemTags.Admin.collection.filterByName(query.term)
});
}, 100, true),
id: function(element) {
return element;
},
initSelection: function(element, callback) {
var selection = ($(element).val() || []).split('|').sort();
callback(selection);
},
formatResult: function (tag) {
return OC.SystemTags.getDescriptiveTag(tag);
},
formatSelection: function (tag) {
OCA.SystemTags.Admin.onSelectTag(tag);
return OC.SystemTags.getDescriptiveTag(tag);
},
escapeMarkup: function(m) {
return m;
},
sortResults: function(results) {
results.sort(function(a, b) {
return OC.Util.naturalSortCompare(a.get('name'), b.get('name'));
});
return results;
}
}
};
})();
window.addEventListener('DOMContentLoaded', function() {
if (!window.TESTING) {
OCA.SystemTags.Admin.init();
}
});

@ -32,6 +32,7 @@ class Admin implements ISettings {
* @return TemplateResponse
*/
public function getForm() {
\OCP\Util::addScript('systemtags', 'admin');
return new TemplateResponse('systemtags', 'admin', [], '');
}

@ -0,0 +1,32 @@
/**
* @copyright 2023 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import Vue from 'vue'
import { getRequestToken } from '@nextcloud/auth'
import SystemTagsSection from './views/SystemTagsSection.vue'
// @ts-expect-error __webpack_nonce__ is injected by webpack
__webpack_nonce__ = btoa(getRequestToken())
const SystemTagsSectionView = Vue.extend(SystemTagsSection)
new SystemTagsSectionView().$mount('#vue-admin-systemtags')

@ -0,0 +1,326 @@
<!--
- @copyright 2023 Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<form class="system-tag-form"
:disabled="loading"
aria-labelledby="system-tag-form-heading"
@submit.prevent="handleSubmit"
@reset="reset">
<h3 id="system-tag-form-heading">
{{ t('systemtags', 'Create or edit tags') }}
</h3>
<div class="system-tag-form__group">
<label for="system-tags-input">{{ t('systemtags', 'Search for a tag to edit') }}</label>
<NcSelectTags v-model="selectedTag"
input-id="system-tags-input"
:placeholder="t('systemtags', 'Collaborative tags …')"
:fetch-tags="false"
:options="tags"
:multiple="false"
passthru>
<template #no-options>
{{ t('systemtags', 'No tags to select') }}
</template>
</NcSelectTags>
</div>
<div class="system-tag-form__group">
<label for="system-tag-name">{{ t('systemtags', 'Tag name') }}</label>
<NcTextField id="system-tag-name"
ref="tagNameInput"
:value.sync="tagName"
:error="Boolean(errorMessage)"
:helper-text="errorMessage"
label-outside />
</div>
<div class="system-tag-form__group">
<label for="system-tag-level">{{ t('systemtags', 'Tag level') }}</label>
<NcSelect v-model="tagLevel"
input-id="system-tag-level"
:options="tagLevelOptions"
:reduce="level => level.id"
:clearable="false"
:disabled="loading" />
</div>
<div class="system-tag-form__row">
<NcButton v-if="isCreating"
native-type="submit"
:disabled="isCreateDisabled || loading">
{{ t('systemtags', 'Create') }}
</NcButton>
<template v-else>
<NcButton native-type="submit"
:disabled="isUpdateDisabled || loading">
{{ t('systemtags', 'Update') }}
</NcButton>
<NcButton :disabled="loading"
@click="handleDelete">
{{ t('systemtags', 'Delete') }}
</NcButton>
</template>
<NcButton native-type="reset"
:disabled="isResetDisabled || loading">
{{ t('systemtags', 'Reset') }}
</NcButton>
<NcLoadingIcon v-if="loading"
:name="t('systemtags', 'Loading …')"
:size="32" />
</div>
</form>
</template>
<script lang="ts">
/* eslint-disable */
import Vue, { type PropType } from 'vue'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
import NcSelectTags from '@nextcloud/vue/dist/Components/NcSelectTags.js'
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import { translate as t } from '@nextcloud/l10n'
import { showSuccess } from '@nextcloud/dialogs'
import { defaultBaseTag } from '../utils.js'
import { createTag, deleteTag, updateTag } from '../services/api.js'
import type { Tag, TagWithId } from '../types.js'
enum TagLevel {
Public = 'Public',
Restricted = 'Restricted',
Invisible = 'Invisible',
}
interface TagLevelOption {
id: TagLevel
label: string
}
const tagLevelOptions: TagLevelOption[] = [
{
id: TagLevel.Public,
label: t('systemtags', 'Public'),
},
{
id: TagLevel.Restricted,
label: t('systemtags', 'Restricted'),
},
{
id: TagLevel.Invisible,
label: t('systemtags', 'Invisible'),
},
]
const getTagLevel = (userVisible: boolean, userAssignable: boolean): TagLevel => {
const matchLevel: Record<string, TagLevel> = {
[[true, true].join(',')]: TagLevel.Public,
[[true, false].join(',')]: TagLevel.Restricted,
[[false, false].join(',')]: TagLevel.Invisible,
}
return matchLevel[[userVisible, userAssignable].join(',')]
}
export default Vue.extend({
name: 'SystemTagForm',
components: {
NcButton,
NcLoadingIcon,
NcSelect,
NcSelectTags,
NcTextField,
},
props: {
tags: {
type: Array as PropType<TagWithId[]>,
required: true,
},
},
data() {
return {
loading: false,
tagLevelOptions,
selectedTag: null as null | TagWithId,
errorMessage: '',
tagName: '',
tagLevel: TagLevel.Public,
}
},
watch: {
selectedTag(tag: null | TagWithId) {
this.tagName = tag ? tag.displayName : ''
this.tagLevel = tag ? getTagLevel(tag.userVisible, tag.userAssignable) : TagLevel.Public
},
},
computed: {
isCreating(): boolean {
return this.selectedTag === null
},
isCreateDisabled(): boolean {
return this.tagName === ''
},
isUpdateDisabled(): boolean {
return (
this.tagName === ''
|| (
this.selectedTag?.displayName === this.tagName
&& getTagLevel(this.selectedTag?.userVisible, this.selectedTag?.userAssignable) === this.tagLevel
)
)
},
isResetDisabled(): boolean {
if (this.isCreating) {
return this.tagName === '' && this.tagLevel === TagLevel.Public
}
return this.selectedTag === null
},
userVisible(): boolean {
const matchLevel: Record<TagLevel, boolean> = {
[TagLevel.Public]: true,
[TagLevel.Restricted]: true,
[TagLevel.Invisible]: false,
}
return matchLevel[this.tagLevel]
},
userAssignable(): boolean {
const matchLevel: Record<TagLevel, boolean> = {
[TagLevel.Public]: true,
[TagLevel.Restricted]: false,
[TagLevel.Invisible]: false,
}
return matchLevel[this.tagLevel]
},
tagProperties(): Omit<Tag, 'id' | 'canAssign'> {
return {
displayName: this.tagName,
userVisible: this.userVisible,
userAssignable: this.userAssignable,
}
},
},
methods: {
t,
async handleSubmit() {
if (this.isCreating) {
await this.create()
return
}
await this.update()
},
async create() {
const tag: Tag = { ...defaultBaseTag, ...this.tagProperties }
this.loading = true
try {
const id = await createTag(tag)
const createdTag: TagWithId = { ...tag, id }
this.$emit('tag:created', createdTag)
showSuccess(t('systemtags', 'Created tag'))
this.reset()
} catch (error) {
this.errorMessage = t('systemtags', 'Failed to create tag')
}
this.loading = false
},
async update() {
if (this.selectedTag === null) {
return
}
const tag: TagWithId = { ...this.selectedTag, ...this.tagProperties }
this.loading = true
try {
await updateTag(tag)
this.selectedTag = tag
this.$emit('tag:updated', tag)
showSuccess(t('systemtags', 'Updated tag'))
this.$refs.tagNameInput?.focus()
} catch (error) {
this.errorMessage = t('systemtags', 'Failed to update tag')
}
this.loading = false
},
async handleDelete() {
if (this.selectedTag === null) {
return
}
this.loading = true
try {
await deleteTag(this.selectedTag)
this.$emit('tag:deleted', this.selectedTag)
showSuccess(t('systemtags', 'Deleted tag'))
this.reset()
} catch (error) {
this.errorMessage = t('systemtags', 'Failed to delete tag')
}
this.loading = false
},
reset() {
this.selectedTag = null
this.errorMessage = ''
this.tagName = ''
this.tagLevel = TagLevel.Public
this.$refs.tagNameInput?.focus()
},
},
})
</script>
<style lang="scss" scoped>
.system-tag-form {
display: flex;
flex-direction: column;
max-width: 400px;
gap: 8px 0;
&__group {
display: flex;
flex-direction: column;
}
&__row {
margin-top: 8px;
display: flex;
gap: 0 4px;
}
}
</style>

@ -59,22 +59,16 @@ import NcSelectTags from '@nextcloud/vue/dist/Components/NcSelectTags.js'
import { translate as t } from '@nextcloud/l10n'
import { showError } from '@nextcloud/dialogs'
import { defaultBaseTag } from '../utils.js'
import { fetchLastUsedTagIds, fetchTags } from '../services/api.js'
import {
createTag,
deleteTag,
fetchLastUsedTagIds,
fetchSelectedTags,
fetchTags,
selectTag,
} from '../services/api.js'
import type { BaseTag, Tag, TagWithId } from '../types.js'
const defaultBaseTag: BaseTag = {
userVisible: true,
userAssignable: true,
canAssign: true,
}
createTagForFile,
deleteTagForFile,
fetchTagsForFile,
setTagForFile,
} from '../services/files.js'
import type { Tag, TagWithId } from '../types.js'
export default Vue.extend({
name: 'SystemTags',
@ -133,7 +127,7 @@ export default Vue.extend({
async handler() {
this.loadingTags = true
try {
this.selectedTags = await fetchSelectedTags(this.fileId)
this.selectedTags = await fetchTagsForFile(this.fileId)
this.$emit('has-tags', this.selectedTags.length > 0)
} catch (error) {
showError(t('systemtags', 'Failed to load selected tags'))
@ -175,14 +169,15 @@ export default Vue.extend({
},
async handleSelect(tags: Tag[]) {
const selectedTag = tags[tags.length - 1]
if (!selectedTag.id) {
const lastTag = tags[tags.length - 1]
if (!lastTag.id) {
// Ignore created tags handled by `handleCreate()`
return
}
const selectedTag = lastTag as TagWithId
this.loading = true
try {
await selectTag(this.fileId, selectedTag)
await setTagForFile(selectedTag, this.fileId)
const sortToFront = (a: TagWithId, b: TagWithId) => {
if (a.id === selectedTag.id) {
return -1
@ -201,7 +196,7 @@ export default Vue.extend({
async handleCreate(tag: Tag) {
this.loading = true
try {
const id = await createTag(this.fileId, tag)
const id = await createTagForFile(tag, this.fileId)
const createdTag = { ...tag, id }
this.sortedTags.unshift(createdTag)
this.selectedTags.push(createdTag)
@ -211,10 +206,10 @@ export default Vue.extend({
this.loading = false
},
async handleDeselect(tag: Tag) {
async handleDeselect(tag: TagWithId) {
this.loading = true
try {
await deleteTag(this.fileId, tag)
await deleteTagForFile(tag, this.fileId)
} catch (error) {
showError(t('systemtags', 'Failed to delete tag'))
}

@ -19,6 +19,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type { FileStat, ResponseDataDetailed } from 'webdav'
import type { ServerTag, Tag, TagWithId } from '../types.js'
@ -30,7 +31,7 @@ import { davClient } from './davClient.js'
import { formatTag, parseIdFromLocation, parseTags } from '../utils'
import { logger } from '../logger.js'
const fetchTagsBody = `<?xml version="1.0"?>
export const fetchTagsPayload = `<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop>
<oc:id />
@ -45,7 +46,7 @@ export const fetchTags = async (): Promise<TagWithId[]> => {
const path = '/systemtags'
try {
const { data: tags } = await davClient.getDirectoryContents(path, {
data: fetchTagsBody,
data: fetchTagsPayload,
details: true,
glob: '/systemtags/*', // Filter out first empty tag
}) as ResponseDataDetailed<Required<FileStat>[]>
@ -67,39 +68,10 @@ export const fetchLastUsedTagIds = async (): Promise<number[]> => {
}
}
export const fetchSelectedTags = async (fileId: number): Promise<TagWithId[]> => {
const path = '/systemtags-relations/files/' + fileId
try {
const { data: tags } = await davClient.getDirectoryContents(path, {
data: fetchTagsBody,
details: true,
glob: '/systemtags-relations/files/*/*', // Filter out first empty tag
}) as ResponseDataDetailed<Required<FileStat>[]>
return parseTags(tags)
} catch (error) {
logger.error(t('systemtags', 'Failed to load selected tags'), { error })
throw new Error(t('systemtags', 'Failed to load selected tags'))
}
}
export const selectTag = async (fileId: number, tag: Tag | ServerTag): Promise<void> => {
const path = '/systemtags-relations/files/' + fileId + '/' + tag.id
const tagToPut = formatTag(tag)
try {
await davClient.customRequest(path, {
method: 'PUT',
data: tagToPut,
})
} catch (error) {
logger.error(t('systemtags', 'Failed to select tag'), { error })
throw new Error(t('systemtags', 'Failed to select tag'))
}
}
/**
* @return created tag id
*/
export const createTag = async (fileId: number, tag: Tag): Promise<number> => {
export const createTag = async (tag: Tag | ServerTag): Promise<number> => {
const path = '/systemtags'
const tagToPost = formatTag(tag)
try {
@ -109,12 +81,7 @@ export const createTag = async (fileId: number, tag: Tag): Promise<number> => {
})
const contentLocation = headers.get('content-location')
if (contentLocation) {
const tagToPut = {
...tagToPost,
id: parseIdFromLocation(contentLocation),
}
await selectTag(fileId, tagToPut)
return tagToPut.id
return parseIdFromLocation(contentLocation)
}
logger.error(t('systemtags', 'Missing "Content-Location" header'))
throw new Error(t('systemtags', 'Missing "Content-Location" header'))
@ -124,8 +91,32 @@ export const createTag = async (fileId: number, tag: Tag): Promise<number> => {
}
}
export const deleteTag = async (fileId: number, tag: Tag): Promise<void> => {
const path = '/systemtags-relations/files/' + fileId + '/' + tag.id
export const updateTag = async (tag: TagWithId): Promise<void> => {
const path = '/systemtags/' + tag.id
const data = `<?xml version="1.0"?>
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:set>
<d:prop>
<oc:display-name>${tag.displayName}</oc:display-name>
<oc:user-visible>${tag.userVisible}</oc:user-visible>
<oc:user-assignable>${tag.userAssignable}</oc:user-assignable>
</d:prop>
</d:set>
</d:propertyupdate>`
try {
await davClient.customRequest(path, {
method: 'PROPPATCH',
data,
})
} catch (error) {
logger.error(t('systemtags', 'Failed to update tag'), { error })
throw new Error(t('systemtags', 'Failed to update tag'))
}
}
export const deleteTag = async (tag: TagWithId): Promise<void> => {
const path = '/systemtags/' + tag.id
try {
await davClient.deleteFile(path)
} catch (error) {

@ -0,0 +1,82 @@
/**
* @copyright 2023 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type { FileStat, ResponseDataDetailed } from 'webdav'
import type { ServerTagWithId, Tag, TagWithId } from '../types.js'
import { davClient } from './davClient.js'
import { createTag, fetchTagsPayload } from './api.js'
import { formatTag, parseTags } from '../utils.js'
import { logger } from '../logger.js'
export const fetchTagsForFile = async (fileId: number): Promise<TagWithId[]> => {
const path = '/systemtags-relations/files/' + fileId
try {
const { data: tags } = await davClient.getDirectoryContents(path, {
data: fetchTagsPayload,
details: true,
glob: '/systemtags-relations/files/*/*', // Filter out first empty tag
}) as ResponseDataDetailed<Required<FileStat>[]>
return parseTags(tags)
} catch (error) {
logger.error(t('systemtags', 'Failed to load tags for file'), { error })
throw new Error(t('systemtags', 'Failed to load tags for file'))
}
}
/**
* @return created tag id
*/
export const createTagForFile = async (tag: Tag, fileId: number): Promise<number> => {
const tagToCreate = formatTag(tag)
const tagId = await createTag(tagToCreate)
const tagToSet: ServerTagWithId = {
...tagToCreate,
id: tagId,
}
await setTagForFile(tagToSet, fileId)
return tagToSet.id
}
export const setTagForFile = async (tag: TagWithId | ServerTagWithId, fileId: number): Promise<void> => {
const path = '/systemtags-relations/files/' + fileId + '/' + tag.id
const tagToPut = formatTag(tag)
try {
await davClient.customRequest(path, {
method: 'PUT',
data: tagToPut,
})
} catch (error) {
logger.error(t('systemtags', 'Failed to set tag for file'), { error })
throw new Error(t('systemtags', 'Failed to set tag for file'))
}
}
export const deleteTagForFile = async (tag: TagWithId, fileId: number): Promise<void> => {
const path = '/systemtags-relations/files/' + fileId + '/' + tag.id
try {
await davClient.deleteFile(path)
} catch (error) {
logger.error(t('systemtags', 'Failed to delete tag for file'), { error })
throw new Error(t('systemtags', 'Failed to delete tag for file'))
}
}

@ -36,3 +36,5 @@ export type TagWithId = Required<Tag>
export type ServerTag = BaseTag & {
name: string
}
export type ServerTagWithId = Required<ServerTag>

@ -24,12 +24,18 @@ import camelCase from 'camelcase'
import type { DAVResultResponseProps } from 'webdav'
import type { ServerTag, Tag, TagWithId } from './types.js'
import type { BaseTag, ServerTag, Tag, TagWithId } from './types.js'
export const defaultBaseTag: BaseTag = {
userVisible: true,
userAssignable: true,
canAssign: true,
}
export const parseTags = (tags: { props: DAVResultResponseProps }[]): TagWithId[] => {
return tags.map(({ props }) => Object.fromEntries(
Object.entries(props)
.map(([key, value]) => [camelCase(key), value]),
.map(([key, value]) => [camelCase(key), camelCase(key) === 'displayName' ? String(value) : value]),
)) as TagWithId[]
}

@ -0,0 +1,99 @@
<!--
- @copyright 2023 Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<NcSettingsSection :name="t('systemtags', 'Collaborative tags')"
:description="t('systemtags', 'Collaborative tags are available for all users. Restricted tags are visible to users but cannot be assigned by them. Invisible tags are for internal use, since users cannot see or assign them.')">
<NcLoadingIcon v-if="loadingTags"
:name="t('systemtags', 'Loading collaborative tags …')"
:size="32" />
<SystemTagForm v-else
:tags="tags"
@tag:created="handleCreate"
@tag:updated="handleUpdate"
@tag:deleted="handleDelete" />
</NcSettingsSection>
</template>
<script lang="ts">
/* eslint-disable */
import Vue from 'vue'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
import { translate as t } from '@nextcloud/l10n'
import { showError } from '@nextcloud/dialogs'
import SystemTagForm from '../components/SystemTagForm.vue'
import { fetchTags } from '../services/api.js'
import type { TagWithId } from '../types.js'
export default Vue.extend({
name: 'SystemTagsSection',
components: {
NcLoadingIcon,
NcSettingsSection,
SystemTagForm,
},
data() {
return {
loadingTags: false,
tags: [] as TagWithId[],
}
},
async created() {
this.loadingTags = true
try {
this.tags = await fetchTags()
} catch (error) {
showError(t('systemtags', 'Failed to load tags'))
}
this.loadingTags = false
},
methods: {
t,
handleCreate(tag: TagWithId) {
this.tags.unshift(tag)
},
handleUpdate(tag: TagWithId) {
const tagIndex = this.tags.findIndex(currTag => currTag.id === tag.id)
this.tags.splice(tagIndex, 1)
this.tags.unshift(tag)
},
handleDelete(tag: TagWithId) {
const tagIndex = this.tags.findIndex(currTag => currTag.id === tag.id)
this.tags.splice(tagIndex, 1)
},
},
})
</script>

@ -18,43 +18,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
script('core', 'systemtags');
script('systemtags', 'admin');
style('systemtags', 'settings');
/** @var \OCP\IL10N $l */
?>
<form id="systemtags" class="section" data-systemtag-id="">
<h2><?php p($l->t('Collaborative tags')); ?></h2>
<p class="settings-hint"><?php p($l->t('Collaborative tags are available for all users. Restricted tags are visible to users but cannot be assigned by them. Invisible tags are for internal use, since users cannot see or assign them.')); ?></p>
<input type="hidden" name="systemtag" id="systemtag" placeholder="<?php p($l->t('Select tag …')); ?>" />
<h3 id="systemtag_create"><?php p($l->t('Create a new tag')); ?></h3>
<div class="systemtag-input">
<div class="systemtag-input--name">
<label for="systemtag_name"><?php p($l->t('Tag name')); ?></label>
<input type="text" id="systemtag_name" name="systemtag_name" placeholder="<?php p($l->t('Name')); ?>">
</div>
<div class="systemtag-input--level">
<label for="systemtag_level"><?php p($l->t('Tag level')); ?></label>
<select id="systemtag_level">
<option value="3"><?php p($l->t('Public')); ?></option>
<option value="2"><?php p($l->t('Restricted')); ?></option>
<option value="0"><?php p($l->t('Invisible')); ?></option>
</select>
</div>
<div class="systemtag-input--actions">
<a id="systemtag_delete" class="hidden button systemtag-input--actions-button"><span><?php p($l->t('Delete')); ?></span></a>
<a id="systemtag_reset" class="button systemtag-input--actions-button"><span><?php p($l->t('Reset')); ?></span></a>
<a id="systemtag_submit" class="button systemtag-input--actions-button"><span><?php p($l->t('Create')); ?></span></a>
</div>
</div>
</form>
<div id="vue-admin-systemtags"></div>

@ -0,0 +1,138 @@
/**
* @copyright 2023 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { User } from '@nextcloud/cypress'
const admin = new User('admin', 'admin')
const tagName = 'foo'
const updatedTagName = 'bar'
describe('Create system tags', () => {
before(() => {
cy.login(admin)
cy.visit('/settings/admin')
})
it('Can create a tag', () => {
cy.get('input#system-tag-name').should('exist').and('have.value', '')
cy.get('input#system-tag-name').type(tagName)
cy.get('input#system-tag-name').should('have.value', tagName)
// submit the form
cy.get('input#system-tag-name').type('{enter}')
// see that the created tag is in the list
cy.get('input#system-tags-input').focus()
cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then(id => {
cy.get(`ul#${id}`).within(() => {
cy.contains('li', tagName).should('exist')
// ensure only one tag exists
cy.get('li').should('have.length', 1)
})
})
})
})
describe('Update system tags', { testIsolation: false }, () => {
before(() => {
cy.login(admin)
cy.visit('/settings/admin')
})
it('select the tag', () => {
// select the tag to edit
cy.get('input#system-tags-input').focus()
cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then(id => {
cy.get(`ul#${id}`).within(() => {
cy.contains('li', tagName).should('exist').click()
})
})
// see that the tag name matches the selected tag
cy.get('input#system-tag-name').should('exist').and('have.value', tagName)
// see that the tag level matches the selected tag
cy.get('input#system-tag-level').click()
cy.get('input#system-tag-level').siblings('.vs__selected').contains('Public').should('exist')
})
it('update the tag name and level', () => {
cy.get('input#system-tag-name').clear()
cy.get('input#system-tag-name').type(updatedTagName)
cy.get('input#system-tag-name').should('have.value', updatedTagName)
// select the new tag level
cy.get('input#system-tag-level').focus()
cy.get('input#system-tag-level').invoke('attr', 'aria-controls').then(id => {
cy.get(`ul#${id}`).within(() => {
cy.contains('li', 'Invisible').should('exist').click()
})
})
// submit the form
cy.get('input#system-tag-name').type('{enter}')
})
it('see the tag was successfully updated', () => {
cy.get('input#system-tags-input').focus()
cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then(id => {
cy.get(`ul#${id}`).within(() => {
cy.contains('li', `${updatedTagName} (invisible)`).should('exist')
// ensure only one tag exists
cy.get('li').should('have.length', 1)
})
})
})
})
describe('Delete system tags', { testIsolation: false }, () => {
before(() => {
cy.login(admin)
cy.visit('/settings/admin')
})
it('select the tag', () => {
// select the tag to edit
cy.get('input#system-tags-input').focus()
cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then(id => {
cy.get(`ul#${id}`).within(() => {
cy.contains('li', `${updatedTagName} (invisible)`).should('exist').click()
})
})
// see that the tag name matches the selected tag
cy.get('input#system-tag-name').should('exist').and('have.value', updatedTagName)
// see that the tag level matches the selected tag
cy.get('input#system-tag-level').focus()
cy.get('input#system-tag-level').siblings('.vs__selected').contains('Invisible').should('exist')
})
it('can delete the tag', () => {
cy.get('.system-tag-form__row').within(() => {
cy.contains('button', 'Delete').should('be.enabled').click()
})
})
it('see that the deleted tag is not present', () => {
cy.get('input#system-tags-input').focus()
cy.get('input#system-tags-input').invoke('attr', 'aria-controls').then(id => {
cy.get(`ul#${id}`).within(() => {
cy.contains('li', updatedTagName).should('not.exist')
})
})
})
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -0,0 +1,21 @@
/**
* @copyright 2023 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -103,6 +103,7 @@ module.exports = {
},
systemtags: {
init: path.join(__dirname, 'apps/systemtags/src', 'init.ts'),
admin: path.join(__dirname, 'apps/systemtags/src', 'admin.ts'),
},
theming: {
'personal-theming': path.join(__dirname, 'apps/theming/src', 'personal-settings.js'),

Loading…
Cancel
Save