summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristopher Ng <chrng8@gmail.com>2023-11-07 18:20:49 -0800
committerChristopher Ng <chrng8@gmail.com>2023-11-15 17:13:14 -0800
commitb1a3c4df07351706c75c31b7fe0d90cce77db080 (patch)
tree29dc866dee24c57d454ffa7751c4ab1fe3b11147
parentc2780796c0fb1f9cf2a8b535d689a4b5743a09cc (diff)
downloadnextcloud-server-b1a3c4df07351706c75c31b7fe0d90cce77db080.tar.gz
nextcloud-server-b1a3c4df07351706c75c31b7fe0d90cce77db080.zip
enh(systemtags): Add accessible system tags form
Signed-off-by: Christopher Ng <chrng8@gmail.com>
-rw-r--r--apps/systemtags/src/components/SystemTagForm.vue326
-rw-r--r--apps/systemtags/src/views/SystemTagsSection.vue99
2 files changed, 425 insertions, 0 deletions
diff --git a/apps/systemtags/src/components/SystemTagForm.vue b/apps/systemtags/src/components/SystemTagForm.vue
new file mode 100644
index 00000000000..201e1fa9ed3
--- /dev/null
+++ b/apps/systemtags/src/components/SystemTagForm.vue
@@ -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>
diff --git a/apps/systemtags/src/views/SystemTagsSection.vue b/apps/systemtags/src/views/SystemTagsSection.vue
new file mode 100644
index 00000000000..235a9283e6f
--- /dev/null
+++ b/apps/systemtags/src/views/SystemTagsSection.vue
@@ -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>