refactor(systemtags): migrate to Vue 3

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2026-01-27 17:06:23 +01:00
parent 2bc3af8b2a
commit 6ca11f73e3
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
23 changed files with 775 additions and 698 deletions

View file

@ -1,8 +1,9 @@
import type { Node } from '@nextcloud/files'
/**
/*!
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node } from '@nextcloud/files'
import type { FileStat, ResponseDataDetailed } from 'webdav'
import { getClient, getDefaultPropfind, getRootPath, resultToNode } from '@nextcloud/files/dav'

View file

@ -30,6 +30,7 @@ class BeforeTemplateRenderedListener implements IEventListener {
return;
}
Util::addInitScript(Application::APP_ID, 'init');
Util::addStyle(Application::APP_ID, 'init');
$restrictSystemTagsCreationToAdmin = $this->appConfig->getValueBool(Application::APP_ID, 'restrict_creation_to_admin', false);
$this->initialState->provideInitialState('restrictSystemTagsCreationToAdmin', $restrictSystemTagsCreationToAdmin);

View file

@ -30,6 +30,7 @@ class LoadAdditionalScriptsListener implements IEventListener {
return;
}
Util::addInitScript(Application::APP_ID, 'init');
Util::addStyle(Application::APP_ID, 'init');
$restrictSystemTagsCreationToAdmin = $this->appConfig->getValueBool(Application::APP_ID, 'restrict_creation_to_admin', false);
$this->initialState->provideInitialState('restrictSystemTagsCreationToAdmin', $restrictSystemTagsCreationToAdmin);

View file

@ -28,8 +28,9 @@ class Admin implements ISettings {
$restrictSystemTagsCreationToAdmin = $this->appConfig->getValueBool(Application::APP_ID, 'restrict_creation_to_admin', false);
$this->initialStateService->provideInitialState('restrictSystemTagsCreationToAdmin', $restrictSystemTagsCreationToAdmin);
Util::addScript('systemtags', 'admin');
return new TemplateResponse('systemtags', 'admin', [], '');
Util::addStyle(Application::APP_ID, 'admin');
Util::addScript(Application::APP_ID, 'admin');
return new TemplateResponse(Application::APP_ID, 'admin', [], '');
}
/**

View file

@ -1,13 +1,10 @@
/**
/*!
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getCSPNonce } from '@nextcloud/auth'
import Vue from 'vue'
import { createApp } from 'vue'
import SystemTagsSection from './views/SystemTagsSection.vue'
__webpack_nonce__ = getCSPNonce()
const SystemTagsSectionView = Vue.extend(SystemTagsSection)
new SystemTagsSectionView().$mount('#vue-admin-systemtags')
const app = createApp(SystemTagsSection)
app.mount('#vue-admin-systemtags')

View file

@ -3,6 +3,219 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { Tag, TagWithId } from '../types.ts'
import { showSuccess } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { computed, ref, useTemplateRef, watch } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcSelectTags from '@nextcloud/vue/components/NcSelectTags'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { createTag, deleteTag, updateTag } from '../services/api.ts'
import { defaultBaseTag } from '../utils.ts'
const props = defineProps<{
tags: TagWithId[]
}>()
const emit = defineEmits<{
'tag:created': [tag: TagWithId]
'tag:updated': [tag: TagWithId]
'tag:deleted': [tag: TagWithId]
}>()
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 tagNameInputElement = useTemplateRef('tagNameInput')
const loading = ref(false)
const errorMessage = ref('')
const tagName = ref('')
const tagLevel = ref(TagLevel.Public)
const selectedTag = ref<null | TagWithId>(null)
watch(selectedTag, (tag: null | TagWithId) => {
tagName.value = tag ? tag.displayName : ''
tagLevel.value = tag ? getTagLevel(tag.userVisible, tag.userAssignable) : TagLevel.Public
})
const isCreating = computed(() => selectedTag.value === null)
const isCreateDisabled = computed(() => tagName.value === '')
const isUpdateDisabled = computed(() => (
tagName.value === ''
|| (
selectedTag.value?.displayName === tagName.value
&& getTagLevel(selectedTag.value?.userVisible, selectedTag.value?.userAssignable) === tagLevel.value
)
))
const isResetDisabled = computed(() => {
if (isCreating.value) {
return tagName.value === '' && tagLevel.value === TagLevel.Public
}
return selectedTag.value === null
})
const userVisible = computed((): boolean => {
const matchLevel: Record<TagLevel, boolean> = {
[TagLevel.Public]: true,
[TagLevel.Restricted]: true,
[TagLevel.Invisible]: false,
}
return matchLevel[tagLevel.value]
})
const userAssignable = computed(() => {
const matchLevel: Record<TagLevel, boolean> = {
[TagLevel.Public]: true,
[TagLevel.Restricted]: false,
[TagLevel.Invisible]: false,
}
return matchLevel[tagLevel.value]
})
const tagProperties = computed((): Omit<Tag, 'id' | 'canAssign'> => {
return {
displayName: tagName.value,
userVisible: userVisible.value,
userAssignable: userAssignable.value,
}
})
/**
* Handle tag selection
*
* @param tagId - The selected tag ID
*/
function onSelectTag(tagId: number | null) {
const tag = props.tags.find((search) => search.id === tagId) || null
selectedTag.value = tag
}
/**
* Handle form submission
*/
async function handleSubmit() {
if (isCreating.value) {
await create()
return
}
await update()
}
/**
* Create a new tag
*/
async function create() {
const tag: Tag = { ...defaultBaseTag, ...tagProperties.value }
loading.value = true
try {
const id = await createTag(tag)
const createdTag: TagWithId = { ...tag, id }
emit('tag:created', createdTag)
showSuccess(t('systemtags', 'Created tag'))
reset()
} catch {
errorMessage.value = t('systemtags', 'Failed to create tag')
}
loading.value = false
}
/**
* Update the selected tag
*/
async function update() {
if (selectedTag.value === null) {
return
}
const tag: TagWithId = { ...selectedTag.value, ...tagProperties.value }
loading.value = true
try {
await updateTag(tag)
selectedTag.value = tag
emit('tag:updated', tag)
showSuccess(t('systemtags', 'Updated tag'))
tagNameInputElement.value?.focus()
} catch {
errorMessage.value = t('systemtags', 'Failed to update tag')
}
loading.value = false
}
/**
* Delete the selected tag
*/
async function handleDelete() {
if (selectedTag.value === null) {
return
}
loading.value = true
try {
await deleteTag(selectedTag.value)
emit('tag:deleted', selectedTag.value)
showSuccess(t('systemtags', 'Deleted tag'))
reset()
} catch {
errorMessage.value = t('systemtags', 'Failed to delete tag')
}
loading.value = false
}
/**
* Reset the form
*/
function reset() {
selectedTag.value = null
errorMessage.value = ''
tagName.value = ''
tagLevel.value = TagLevel.Public
tagNameInputElement.value?.focus()
}
/**
* Get tag level based on visibility and assignability
*
* @param userVisible - Whether the tag is visible to users
* @param userAssignable - Whether the tag is assignable by users
*/
function 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(',')]!
}
</script>
<template>
<form
class="system-tag-form"
@ -17,14 +230,14 @@
<div class="system-tag-form__group">
<label for="system-tags-input">{{ t('systemtags', 'Search for a tag to edit') }}</label>
<NcSelectTags
:model-value="selectedTag"
input-id="system-tags-input"
:modelValue="selectedTag"
inputId="system-tags-input"
:placeholder="t('systemtags', 'Collaborative tags …')"
:fetch-tags="false"
:fetchTags="false"
:options="tags"
:multiple="false"
label-outside
@update:model-value="onSelectTag">
labelOutside
@update:modelValue="onSelectTag">
<template #no-options>
{{ t('systemtags', 'No tags to select') }}
</template>
@ -38,20 +251,20 @@
ref="tagNameInput"
v-model="tagName"
:error="Boolean(errorMessage)"
:helper-text="errorMessage"
label-outside />
:helperText="errorMessage"
labelOutside />
</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"
inputId="system-tag-level"
:options="tagLevelOptions"
:reduce="level => level.id"
:clearable="false"
:disabled="loading"
label-outside />
labelOutside />
</div>
<div class="system-tag-form__row">
@ -86,232 +299,6 @@
</form>
</template>
<script lang="ts">
import type { PropType } from 'vue'
import type { Tag, TagWithId } from '../types.js'
import { showSuccess } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcSelectTags from '@nextcloud/vue/components/NcSelectTags'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { createTag, deleteTag, updateTag } from '../services/api.js'
import { defaultBaseTag } from '../utils.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'),
},
]
/**
*
* @param userVisible
* @param userAssignable
*/
function 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 defineComponent({
name: 'SystemTagForm',
components: {
NcButton,
NcLoadingIcon,
NcSelect,
NcSelectTags,
NcTextField,
},
props: {
tags: {
type: Array as PropType<TagWithId[]>,
required: true,
},
},
emits: [
'tag:created',
'tag:updated',
'tag:deleted',
],
data() {
return {
loading: false,
tagLevelOptions,
selectedTag: null as null | TagWithId,
errorMessage: '',
tagName: '',
tagLevel: 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,
}
},
},
watch: {
selectedTag(tag: null | TagWithId) {
this.tagName = tag ? tag.displayName : ''
this.tagLevel = tag ? getTagLevel(tag.userVisible, tag.userAssignable) : TagLevel.Public
},
},
methods: {
t,
onSelectTag(tagId: number | null) {
const tag = this.tags.find((search) => search.id === tagId) || null
this.selectedTag = tag
},
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 {
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 {
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 {
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;

View file

@ -6,20 +6,20 @@
<template>
<NcDialog
data-cy-systemtags-picker
:no-close="status === Status.LOADING"
:noClose="status === Status.LOADING"
:name="t('systemtags', 'Manage tags')"
:open="opened"
:class="'systemtags-picker--' + status"
class="systemtags-picker"
close-on-click-outside
out-transition
closeOnClickOutside
outTransition
@update:open="onCancel">
<NcEmptyContent
v-if="status === Status.LOADING || status === Status.DONE"
:name="t('systemtags', 'Applying tags changes…')">
<template #icon>
<NcLoadingIcon v-if="status === Status.LOADING" />
<CheckIcon v-else fill-color="var(--color-border-success)" />
<CheckIcon v-else fillColor="var(--color-border-success)" />
</template>
</NcEmptyContent>
@ -41,11 +41,12 @@
<li
v-for="tag in filteredTags"
:key="tag.id"
ref="tags"
:data-cy-systemtags-picker-tag="tag.id"
:style="tagListStyle(tag)"
class="systemtags-picker__tag">
<NcCheckboxRadioSwitch
:model-value="isChecked(tag)"
:modelValue="isChecked(tag)"
:disabled="!tag.canAssign"
:indeterminate="isIndeterminate(tag)"
:label="tag.displayName"
@ -58,23 +59,22 @@
<NcColorPicker
v-if="canEditOrCreateTag"
:data-cy-systemtags-picker-tag-color="tag.id"
:model-value="`#${tag.color || '000000'}`"
:modelValue="`#${tag.color || '000000'}`"
:shown="openedPicker === tag.id"
class="systemtags-picker__tag-color"
@update:value="onColorChange(tag, $event)"
@update:shown="openedPicker = $event ? tag.id : false"
@submit="openedPicker = false">
@submit="onColorChange(tag, $event)">
<NcButton :aria-label="t('systemtags', 'Change tag color')" variant="tertiary">
<template #icon>
<CircleIcon
v-if="tag.color"
:size="24"
fill-color="var(--color-circle-icon)"
fillColor="var(--color-circle-icon)"
class="button-color-circle" />
<CircleOutlineIcon
v-else
:size="24"
fill-color="var(--color-circle-icon)"
fillColor="var(--color-circle-icon)"
class="button-color-empty" />
<PencilIcon class="button-color-pencil" />
</template>
@ -108,6 +108,7 @@
{{ t('systemtags', 'Choose tags for the selected files') }}
</NcNoteCard>
<NcNoteCard v-else type="info">
<!-- eslint-disable-next-line vue/no-v-html -- we use this to format the message with chips -->
<span v-html="statusMessage" />
</NcNoteCard>
</div>
@ -134,8 +135,8 @@
<NcChip
ref="chip"
text="%s"
variant="primary"
no-close />
noClose
variant="primary" />
</div>
</NcDialog>
</template>
@ -221,7 +222,11 @@ export default defineComponent({
},
},
emits: ['close'],
emits: {
close(status: null | boolean) {
return status === null || typeof status === 'boolean'
},
},
setup() {
return {
@ -399,7 +404,7 @@ export default defineComponent({
methods: {
// Format & sanitize a tag chip for v-html tag rendering
formatTagChip(tag: TagWithId): string {
const chip = this.$refs.chip as NcChip
const chip = this.$refs.chip as InstanceType<typeof NcChip>
const chipCloneEl = chip.$el.cloneNode(true) as HTMLElement
if (tag.color) {
const style = this.tagListStyle(tag)
@ -476,12 +481,15 @@ export default defineComponent({
// Scroll to the newly created tag
await this.$nextTick()
const newTagEl = this.$el.querySelector(`input[type="checkbox"][label="${tag.displayName}"]`)
newTagEl?.scrollIntoView({
behavior: 'instant',
block: 'center',
inline: 'center',
})
if (Array.isArray(this.$refs.tags)) {
const newTagEl = this.$refs.tags
.find((el: HTMLElement) => el.dataset.cySystemtagsPickerTag === id.toString())
newTagEl?.scrollIntoView({
behavior: 'instant',
block: 'center',
inline: 'center',
})
}
} catch (error) {
showError((error as Error)?.message || t('systemtags', 'Failed to create tag'))
} finally {

View file

@ -3,6 +3,235 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { INode } from '@nextcloud/files'
import type { Tag, TagWithId } from '../types.ts'
import { showError } from '@nextcloud/dialogs'
import { emit, subscribe } from '@nextcloud/event-bus'
import { getSidebar } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { onBeforeMount, onMounted, ref, watch } from 'vue'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcSelectTags from '@nextcloud/vue/components/NcSelectTags'
import logger from '../logger.ts'
import { fetchLastUsedTagIds, fetchTags } from '../services/api.ts'
import { fetchNode } from '../services/davClient.ts'
import {
createTagForFile,
deleteTagForFile,
fetchTagsForFile,
setTagForFile,
} from '../services/files.ts'
import { defaultBaseTag } from '../utils.ts'
const props = defineProps<{
fileId: number
disabled?: boolean
}>()
const sortedTags = ref<TagWithId[]>([])
const selectedTags = ref<TagWithId[]>([])
const loadingTags = ref(false)
const loading = ref(false)
watch(() => props.fileId, async () => {
loadingTags.value = true
try {
selectedTags.value = await fetchTagsForFile(props.fileId)
} catch (error) {
showError(t('systemtags', 'Failed to load selected tags'))
logger.error('Failed to load selected tags', { error })
} finally {
loadingTags.value = false
}
}, { immediate: true })
onBeforeMount(async () => {
try {
const tags = await fetchTags()
const lastUsedOrder = await fetchLastUsedTagIds()
const lastUsedTags: TagWithId[] = []
const remainingTags: TagWithId[] = []
for (const tag of tags) {
if (lastUsedOrder.includes(tag.id)) {
lastUsedTags.push(tag)
continue
}
remainingTags.push(tag)
}
const sortByLastUsed = (a: TagWithId, b: TagWithId) => {
return lastUsedOrder.indexOf(a.id) - lastUsedOrder.indexOf(b.id)
}
lastUsedTags.sort(sortByLastUsed)
sortedTags.value = [...lastUsedTags, ...remainingTags]
} catch (error) {
showError(t('systemtags', 'Failed to load tags'))
logger.error('Failed to load tags', { error })
}
})
onMounted(() => {
subscribe('systemtags:node:updated', onTagUpdated)
})
/**
* Create a new tag
*
* @param newDisplayName - The display name of the tag to create
*/
function createOption(newDisplayName: string): Tag {
for (const tag of sortedTags.value) {
const { displayName, ...baseTag } = tag
if (
displayName === newDisplayName
&& Object.entries(baseTag)
.every(([key, value]) => defaultBaseTag[key] === value)
) {
// Return existing tag to prevent vue-select from thinking the tags are different and showing duplicate options
return tag
}
}
return {
...defaultBaseTag,
displayName: newDisplayName,
}
}
/**
* Filter out tags with no id to prevent duplicate selected options
*
* Created tags are added programmatically by `handleCreate()` with
* their respective ids returned from the server.
*
* @param currentTags - The selected tags
*/
function handleInput(currentTags: Tag[]) {
selectedTags.value = currentTags.filter((selectedTag) => Boolean(selectedTag.id)) as TagWithId[]
}
/**
* Handle tag selection
*
* @param tags - The selected tags
*/
async function handleSelect(tags: Tag[]) {
const lastTag = tags[tags.length - 1]!
if (!lastTag.id) {
// Ignore created tags handled by `handleCreate()`
return
}
const selectedTag = lastTag as TagWithId
loading.value = true
try {
await setTagForFile(selectedTag, props.fileId)
const sortToFront = (a: TagWithId, b: TagWithId) => {
if (a.id === selectedTag.id) {
return -1
} else if (b.id === selectedTag.id) {
return 1
}
return 0
}
sortedTags.value.sort(sortToFront)
} catch (error) {
showError(t('systemtags', 'Failed to select tag'))
logger.error('Failed to select tag', { error })
}
loading.value = false
updateAndDispatchNodeTagsEvent(props.fileId)
}
/**
* Handle tag creation
*
* @param tag - The created tag
*/
async function handleCreate(tag: Tag) {
loading.value = true
try {
const id = await createTagForFile(tag, props.fileId)
const createdTag = { ...tag, id }
sortedTags.value.unshift(createdTag)
selectedTags.value.push(createdTag)
} catch (error) {
const systemTagsCreationRestrictedToAdmin = loadState<true | false>('settings', 'restrictSystemTagsCreationToAdmin', false) === true
logger.error('Failed to create tag', { error })
if (systemTagsCreationRestrictedToAdmin) {
showError(t('systemtags', 'System admin disabled tag creation. You can only use existing ones.'))
return
}
showError(t('systemtags', 'Failed to create tag'))
}
loading.value = false
updateAndDispatchNodeTagsEvent(props.fileId)
}
/**
* Handle tag deselection
*
* @param tag - The deselected tag
*/
async function handleDeselect(tag: TagWithId) {
loading.value = true
try {
await deleteTagForFile(tag, props.fileId)
} catch (error) {
showError(t('systemtags', 'Failed to delete tag'))
logger.error('Failed to delete tag', { error })
}
loading.value = false
updateAndDispatchNodeTagsEvent(props.fileId)
}
/**
* Handle node updated event
*
* @param node - The updated node
*/
async function onTagUpdated(node: INode) {
if (node.fileid !== props.fileId) {
return
}
loadingTags.value = true
try {
selectedTags.value = await fetchTagsForFile(props.fileId)
} catch (error) {
showError(t('systemtags', 'Failed to load selected tags'))
logger.error('Failed to load selected tags', { error })
}
loadingTags.value = false
}
/**
* Update and dispatch system tags node updated event
*
* @param fileId - The file ID
*/
async function updateAndDispatchNodeTagsEvent(fileId: number) {
const sidebar = getSidebar()
const path = sidebar.node?.path ?? ''
try {
const node = await fetchNode(path)
if (node) {
emit('systemtags:node:updated', node)
}
} catch (error) {
logger.error('Failed to fetch node for system tags update', { error, fileId })
}
}
</script>
<template>
<div class="system-tags">
<NcLoadingIcon
@ -13,15 +242,15 @@
<NcSelectTags
v-show="!loadingTags"
class="system-tags__select"
:input-label="t('systemtags', 'Search or create collaborative tags')"
:inputLabel="t('systemtags', 'Search or create collaborative tags')"
:placeholder="t('systemtags', 'Collaborative tags …')"
:options="sortedTags"
:model-value="selectedTags"
:create-option="createOption"
:modelValue="selectedTags"
:createOption="createOption"
:disabled="disabled"
:taggable="true"
:passthru="true"
:fetch-tags="false"
:fetchTags="false"
:loading="loading"
@input="handleInput"
@option:selected="handleSelect"
@ -34,231 +263,6 @@
</div>
</template>
<script lang="ts">
import type { INode } from '@nextcloud/files'
import type { Tag, TagWithId } from '../types.js'
import { showError } from '@nextcloud/dialogs'
import { emit, subscribe } from '@nextcloud/event-bus'
import { getSidebar } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import Vue from 'vue'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcSelectTags from '@nextcloud/vue/components/NcSelectTags'
import { fetchNode } from '../../../files/src/services/WebdavClient.js'
import logger from '../logger.js'
import { fetchLastUsedTagIds, fetchTags } from '../services/api.js'
import {
createTagForFile,
deleteTagForFile,
fetchTagsForFile,
setTagForFile,
} from '../services/files.js'
import { defaultBaseTag } from '../utils.js'
export default Vue.extend({
name: 'SystemTags',
components: {
NcLoadingIcon,
NcSelectTags,
},
props: {
fileId: {
type: Number,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
data() {
return {
sortedTags: [] as TagWithId[],
selectedTags: [] as TagWithId[],
loadingTags: false,
loading: false,
}
},
watch: {
fileId: {
immediate: true,
async handler() {
this.loadingTags = true
try {
this.selectedTags = await fetchTagsForFile(this.fileId)
} catch (error) {
showError(t('systemtags', 'Failed to load selected tags'))
logger.error('Failed to load selected tags', { error })
}
this.loadingTags = false
},
},
},
async created() {
try {
const tags = await fetchTags()
const lastUsedOrder = await fetchLastUsedTagIds()
const lastUsedTags: TagWithId[] = []
const remainingTags: TagWithId[] = []
for (const tag of tags) {
if (lastUsedOrder.includes(tag.id)) {
lastUsedTags.push(tag)
continue
}
remainingTags.push(tag)
}
const sortByLastUsed = (a: TagWithId, b: TagWithId) => {
return lastUsedOrder.indexOf(a.id) - lastUsedOrder.indexOf(b.id)
}
lastUsedTags.sort(sortByLastUsed)
this.sortedTags = [...lastUsedTags, ...remainingTags]
} catch (error) {
showError(t('systemtags', 'Failed to load tags'))
logger.error('Failed to load tags', { error })
}
},
mounted() {
subscribe('systemtags:node:updated', this.onTagUpdated)
},
methods: {
t,
createOption(newDisplayName: string): Tag {
for (const tag of this.sortedTags) {
const { displayName, ...baseTag } = tag
if (
displayName === newDisplayName
&& Object.entries(baseTag)
.every(([key, value]) => defaultBaseTag[key] === value)
) {
// Return existing tag to prevent vue-select from thinking the tags are different and showing duplicate options
return tag
}
}
return {
...defaultBaseTag,
displayName: newDisplayName,
}
},
handleInput(selectedTags: Tag[]) {
/**
* Filter out tags with no id to prevent duplicate selected options
*
* Created tags are added programmatically by `handleCreate()` with
* their respective ids returned from the server
*/
this.selectedTags = selectedTags.filter((selectedTag) => Boolean(selectedTag.id)) as TagWithId[]
},
async handleSelect(tags: Tag[]) {
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 setTagForFile(selectedTag, this.fileId)
const sortToFront = (a: TagWithId, b: TagWithId) => {
if (a.id === selectedTag.id) {
return -1
} else if (b.id === selectedTag.id) {
return 1
}
return 0
}
this.sortedTags.sort(sortToFront)
} catch (error) {
showError(t('systemtags', 'Failed to select tag'))
logger.error('Failed to select tag', { error })
}
this.loading = false
this.updateAndDispatchNodeTagsEvent(this.fileId)
},
async handleCreate(tag: Tag) {
this.loading = true
try {
const id = await createTagForFile(tag, this.fileId)
const createdTag = { ...tag, id }
this.sortedTags.unshift(createdTag)
this.selectedTags.push(createdTag)
} catch (error) {
const systemTagsCreationRestrictedToAdmin = loadState<true | false>('settings', 'restrictSystemTagsCreationToAdmin', false) === true
logger.error('Failed to create tag', { error })
if (systemTagsCreationRestrictedToAdmin) {
showError(t('systemtags', 'System admin disabled tag creation. You can only use existing ones.'))
return
}
showError(t('systemtags', 'Failed to create tag'))
}
this.loading = false
this.updateAndDispatchNodeTagsEvent(this.fileId)
},
async handleDeselect(tag: TagWithId) {
this.loading = true
try {
await deleteTagForFile(tag, this.fileId)
} catch (error) {
showError(t('systemtags', 'Failed to delete tag'))
logger.error('Failed to delete tag', { error })
}
this.loading = false
this.updateAndDispatchNodeTagsEvent(this.fileId)
},
async onTagUpdated(node: INode) {
if (node.fileid !== this.fileId) {
return
}
this.loadingTags = true
try {
this.selectedTags = await fetchTagsForFile(this.fileId)
} catch (error) {
showError(t('systemtags', 'Failed to load selected tags'))
logger.error('Failed to load selected tags', { error })
}
this.loadingTags = false
},
async updateAndDispatchNodeTagsEvent(fileId: number) {
const sidebar = getSidebar()
const path = sidebar.node?.path ?? ''
try {
const node = await fetchNode(path)
if (node) {
emit('systemtags:node:updated', node)
}
} catch (error) {
logger.error('Failed to fetch node for system tags update', { error, fileId })
}
},
},
})
</script>
<style lang="scss" scoped>
.system-tags {
display: flex;

View file

@ -3,6 +3,69 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { showError, showSuccess } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { ref } from 'vue'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import logger from '../logger.ts'
import { updateSystemTagsAdminRestriction } from '../services/api.ts'
// By default, system tags creation is not restricted to admins
const systemTagsCreationRestrictedToAdmin = ref(loadState('systemtags', 'restrictSystemTagsCreationToAdmin', false))
/**
* Update system tags admin restriction setting
*
* @param isRestricted - True if system tags creation should be restricted to admins
*/
async function updateSystemTagsDefault(isRestricted: boolean) {
try {
const responseData = await updateSystemTagsAdminRestriction(isRestricted)
logger.debug('updateSystemTagsDefault', { responseData })
handleResponse({
isRestricted,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
handleResponse({
errorMessage: t('systemtags', 'Unable to update setting'),
error: e,
})
}
}
/**
* Handle response from updating system tags admin restriction
*
* @param context - The response context
* @param context.isRestricted - Whether system tags creation is restricted to admins
* @param context.status - The response status
* @param context.errorMessage - The error message, if any
* @param context.error - The error object, if any
*/
function handleResponse({ isRestricted, status, errorMessage, error }: {
isRestricted?: boolean
status?: string
errorMessage?: string
error?: unknown
}) {
if (status === 'ok') {
systemTagsCreationRestrictedToAdmin.value = !!isRestricted
showSuccess(isRestricted
? t('systemtags', 'System tag creation is now restricted to administrators')
: t('systemtags', 'System tag creation is now allowed for everybody'))
return
}
if (errorMessage) {
showError(errorMessage)
logger.error(errorMessage, { error })
}
}
</script>
<template>
<div id="system-tags-creation-control">
<h4 class="inlineblock">
@ -21,66 +84,3 @@
</NcCheckboxRadioSwitch>
</div>
</template>
<script lang="ts">
import { showError, showSuccess } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import logger from '../logger.ts'
import { updateSystemTagsAdminRestriction } from '../services/api.js'
export default {
name: 'SystemTagsCreationControl',
components: {
NcCheckboxRadioSwitch,
},
setup() {
return {
t,
}
},
data() {
return {
// By default, system tags creation is not restricted to admins
systemTagsCreationRestrictedToAdmin: loadState('systemtags', 'restrictSystemTagsCreationToAdmin', false),
}
},
methods: {
async updateSystemTagsDefault(isRestricted: boolean) {
try {
const responseData = await updateSystemTagsAdminRestriction(isRestricted)
logger.debug('updateSystemTagsDefault', responseData)
this.handleResponse({
isRestricted,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: t('systemtags', 'Unable to update setting'),
error: e,
})
}
},
handleResponse({ isRestricted, status, errorMessage, error }) {
if (status === 'ok') {
this.systemTagsCreationRestrictedToAdmin = isRestricted
showSuccess(isRestricted
? t('systemtags', 'System tag creation is now restricted to administrators')
: t('systemtags', 'System tag creation is now allowed for everybody'))
return
}
if (errorMessage) {
showError(errorMessage)
logger.error(errorMessage, error)
}
},
},
}
</script>

View file

@ -19,14 +19,14 @@ import { defineAsyncComponent } from 'vue'
* @param context.nodes - Nodes to modify tags for
*/
async function execBatch({ nodes }: ActionContext | ActionContextSingle): Promise<(null | boolean)[]> {
const response = await new Promise<null | boolean>((resolve) => {
spawnDialog(defineAsyncComponent(() => import('../components/SystemTagPicker.vue')), {
const response = await spawnDialog(
defineAsyncComponent(() => import('../components/SystemTagPicker.vue')),
{
nodes,
}, (status) => {
resolve(status as null | boolean)
})
})
return Array(nodes.length).fill(response)
},
)
return Array(nodes.length)
.fill(response)
}
export const action = new FileAction({
@ -55,7 +55,7 @@ export const action = new FileAction({
async exec(context: ActionContextSingle) {
const [result] = await execBatch(context)
return result
return result!
},
execBatch,

View file

@ -6,7 +6,7 @@
import svgTagMultiple from '@mdi/svg/svg/tag-multiple-outline.svg?raw'
import { getNavigation, View } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { getContents } from '../services/systemtags.js'
import { getContents } from '../services/systemtags.ts'
export const systemTagsViewId = 'tags'

View file

@ -3,8 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { OCSResponse } from '@nextcloud/typings/ocs'
import type { FileStat, ResponseDataDetailed, WebDAVClientError } from 'webdav'
import type { ServerTag, Tag, TagWithId } from '../types.js'
import type { ServerTag, Tag, TagWithId } from '../types.ts'
import axios from '@nextcloud/axios'
import { emit } from '@nextcloud/event-bus'
@ -13,7 +14,7 @@ import { confirmPassword } from '@nextcloud/password-confirmation'
import { generateOcsUrl, generateUrl } from '@nextcloud/router'
import logger from '../logger.ts'
import { formatTag, parseIdFromLocation, parseTags } from '../utils.ts'
import { davClient } from './davClient.js'
import { davClient } from './davClient.ts'
export const fetchTagsPayload = `<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
@ -29,7 +30,7 @@ export const fetchTagsPayload = `<?xml version="1.0"?>
</d:propfind>`
/**
*
* Fetch all tags.
*/
export async function fetchTags(): Promise<TagWithId[]> {
const path = '/systemtags'
@ -47,8 +48,9 @@ export async function fetchTags(): Promise<TagWithId[]> {
}
/**
* Fetch a single tag by its ID.
*
* @param tagId
* @param tagId - The ID of the tag to fetch
*/
export async function fetchTag(tagId: number): Promise<TagWithId> {
const path = '/systemtags/' + tagId
@ -57,7 +59,7 @@ export async function fetchTag(tagId: number): Promise<TagWithId> {
data: fetchTagsPayload,
details: true,
}) as ResponseDataDetailed<Required<FileStat>>
return parseTags([tag])[0]
return parseTags([tag])[0]!
} catch (error) {
logger.error(t('systemtags', 'Failed to load tag'), { error })
throw new Error(t('systemtags', 'Failed to load tag'))
@ -65,7 +67,7 @@ export async function fetchTag(tagId: number): Promise<TagWithId> {
}
/**
*
* Get the last used tag IDs.
*/
export async function fetchLastUsedTagIds(): Promise<number[]> {
const url = generateUrl('/apps/systemtags/lastused')
@ -109,8 +111,9 @@ export async function createTag(tag: Tag | ServerTag): Promise<number> {
}
/**
* Update a tag on the server.
*
* @param tag
* @param tag - The tag to update
*/
export async function updateTag(tag: TagWithId): Promise<void> {
const path = '/systemtags/' + tag.id
@ -139,8 +142,9 @@ export async function updateTag(tag: TagWithId): Promise<void> {
}
/**
* Delete a tag.
*
* @param tag
* @param tag - The tag to delete
*/
export async function deleteTag(tag: TagWithId): Promise<void> {
const path = '/systemtags/' + tag.id
@ -164,9 +168,10 @@ type TagObjectResponse = {
}
/**
* Get the objects for a tag.
*
* @param tag
* @param type
* @param tag - The tag to get the objects for
* @param type - The type of the objects
*/
export async function getTagObjects(tag: TagWithId, type: string): Promise<TagObjectResponse> {
const path = `/systemtags/${tag.id}/${type}`
@ -178,7 +183,7 @@ export async function getTagObjects(tag: TagWithId, type: string): Promise<TagOb
</d:prop>
</d:propfind>`
const response = await davClient.stat(path, { data, details: true })
const response = await davClient.stat(path, { data, details: true }) as ResponseDataDetailed<FileStat>
const etag = response?.data?.props?.getetag || '""'
const objects = Object.values(response?.data?.props?.['object-ids'] || []).flat() as TagObject[]
@ -228,15 +233,12 @@ export async function setTagObjects(tag: TagWithId, type: string, objectIds: Tag
})
}
type OcsResponse = {
ocs: NonNullable<unknown>
}
/**
* Update the system tags admin restriction setting.
*
* @param isAllowed
* @param isAllowed - True if system tags creation is allowed for non-admins
*/
export async function updateSystemTagsAdminRestriction(isAllowed: boolean): Promise<OcsResponse> {
export async function updateSystemTagsAdminRestriction(isAllowed: boolean): Promise<OCSResponse> {
// Convert to string for compatibility
const isAllowedString = isAllowed ? '1' : '0'
@ -247,9 +249,9 @@ export async function updateSystemTagsAdminRestriction(isAllowed: boolean): Prom
await confirmPassword()
const res = await axios.post(url, {
const { data } = await axios.post(url, {
value: isAllowedString,
})
return res.data
return data
}

View file

@ -1,30 +1,25 @@
/**
/*!
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getRequestToken, onRequestTokenUpdate } from '@nextcloud/auth'
import { generateRemoteUrl } from '@nextcloud/router'
import { createClient } from 'webdav'
import type { Node } from '@nextcloud/files'
import type { FileStat, ResponseDataDetailed } from 'webdav'
// init webdav client
const rootUrl = generateRemoteUrl('dav')
export const davClient = createClient(rootUrl)
import { getClient, getDefaultPropfind, getRootPath, resultToNode } from '@nextcloud/files/dav'
export const davClient = getClient()
// set CSRF token header
/**
* Fetches a node from the given path
*
* @param token
* @param path - The path to fetch the node from
*/
function setHeaders(token: string | null) {
davClient.setHeaders({
// Add this so the server knows it is an request from the browser
'X-Requested-With': 'XMLHttpRequest',
// Inject user auth
requesttoken: token ?? '',
})
export async function fetchNode(path: string): Promise<Node> {
const propfindPayload = getDefaultPropfind()
const result = await davClient.stat(`${getRootPath()}${path}`, {
details: true,
data: propfindPayload,
}) as ResponseDataDetailed<FileStat>
return resultToNode(result.data)
}
// refresh headers when request token changes
onRequestTokenUpdate(setHeaders)
setHeaders(getRequestToken())

View file

@ -4,17 +4,18 @@
*/
import type { FileStat, ResponseDataDetailed } from 'webdav'
import type { ServerTagWithId, Tag, TagWithId } from '../types.js'
import type { ServerTagWithId, Tag, TagWithId } from '../types.ts'
import { t } from '@nextcloud/l10n'
import logger from '../logger.ts'
import { formatTag, parseTags } from '../utils.js'
import { createTag, fetchTagsPayload } from './api.js'
import { davClient } from './davClient.js'
import { formatTag, parseTags } from '../utils.ts'
import { createTag, fetchTagsPayload } from './api.ts'
import { davClient } from './davClient.ts'
/**
* Fetch all tags for a given file (by id).
*
* @param fileId
* @param fileId - The id of the file to fetch tags for
*/
export async function fetchTagsForFile(fileId: number): Promise<TagWithId[]> {
const path = '/systemtags-relations/files/' + fileId
@ -50,9 +51,10 @@ export async function createTagForFile(tag: Tag, fileId: number): Promise<number
}
/**
* Set a tag for a given file (by id).
*
* @param tag
* @param fileId
* @param tag - The tag to set
* @param fileId - The id of the file to set the tag for
*/
export async function setTagForFile(tag: TagWithId | ServerTagWithId, fileId: number): Promise<void> {
const path = '/systemtags-relations/files/' + fileId + '/' + tag.id
@ -69,9 +71,10 @@ export async function setTagForFile(tag: TagWithId | ServerTagWithId, fileId: nu
}
/**
* Delete a tag for a given file (by id).
*
* @param tag
* @param fileId
* @param tag - The tag to delete
* @param fileId - The id of the file to delete the tag for
*/
export async function deleteTagForFile(tag: TagWithId, fileId: number): Promise<void> {
const path = '/systemtags-relations/files/' + fileId + '/' + tag.id

View file

@ -1,7 +1,8 @@
/**
/*!
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { ContentsWithRoot } from '@nextcloud/files'
import type { FileStat, ResponseDataDetailed } from 'webdav'
import type { TagWithId } from '../types.ts'
@ -16,8 +17,9 @@ const rootPath = '/systemtags'
const client = getClient()
/**
* Format the REPORT payload to filter files by tag
*
* @param tagId
* @param tagId - The tag ID
*/
function formatReportPayload(tagId: number) {
return `<?xml version="1.0"?>
@ -32,8 +34,9 @@ function formatReportPayload(tagId: number) {
}
/**
* Convert a tag to a Folder node
*
* @param tag
* @param tag - The tag
*/
function tagToNode(tag: TagWithId): Folder {
return new Folder({
@ -51,8 +54,9 @@ function tagToNode(tag: TagWithId): Folder {
}
/**
* Get the contents of a folder or tag
*
* @param path
* @param path - The path to the folder or tag
*/
export async function getContents(path = '/'): Promise<ContentsWithRoot> {
// List tags in the root
@ -71,9 +75,13 @@ export async function getContents(path = '/'): Promise<ContentsWithRoot> {
}
}
const tagId = parseInt(path.split('/', 2)[1])
const tag = tagsCache.find((tag) => tag.id === tagId)
const tagIdStr = path.split('/', 2)[1]
if (!tagIdStr || isNaN(parseInt(tagIdStr))) {
throw new Error('Invalid tag ID')
}
const tagId = parseInt(tagIdStr)
const tag = tagsCache.find((tag) => tag.id === tagId)
if (!tag) {
throw new Error('Tag not found')
}

View file

@ -5,10 +5,9 @@
import type { INode } from '@nextcloud/files'
import type { DAVResultResponseProps } from 'webdav'
import type { BaseTag, ServerTag, Tag, TagWithId } from './types.js'
import type { BaseTag, ServerTag, Tag, TagWithId } from './types.ts'
import camelCase from 'camelcase'
import Vue from 'vue'
import { emit } from '@nextcloud/event-bus'
export const defaultBaseTag: BaseTag = {
userVisible: true,
@ -16,13 +15,25 @@ export const defaultBaseTag: BaseTag = {
canAssign: true,
}
const propertyMappings = Object.freeze({
'display-name': 'displayName',
'user-visible': 'userVisible',
'user-assignable': 'userAssignable',
'can-assign': 'canAssign',
})
/**
* Parse tags from WebDAV response
*
* @param tags
* @param tags - Array of tags from WebDAV response
*/
export function parseTags(tags: { props: DAVResultResponseProps }[]): TagWithId[] {
return tags.map(({ props }) => Object.fromEntries(Object.entries(props)
.map(([key, value]) => [camelCase(key), camelCase(key) === 'displayName' ? String(value) : value]))) as TagWithId[]
.map(([key, value]) => {
key = propertyMappings[key] ?? key
value = key === 'displayName' ? String(value) : value
return [key, value]
})) as unknown as TagWithId)
}
/**
@ -49,8 +60,9 @@ export function parseIdFromLocation(url: string): number {
}
/**
* Format a tag for WebDAV operations
*
* @param initialTag
* @param initialTag - Tag to format
*/
export function formatTag(initialTag: Tag | ServerTag): ServerTag {
if ('name' in initialTag && !('displayName' in initialTag)) {
@ -65,8 +77,9 @@ export function formatTag(initialTag: Tag | ServerTag): ServerTag {
}
/**
* Get system tags from a node
*
* @param node
* @param node - The node to get tags from
*/
export function getNodeSystemTags(node: INode): string[] {
const attribute = node.attributes?.['system-tags']?.['system-tag']
@ -88,12 +101,14 @@ export function getNodeSystemTags(node: INode): string[] {
}
/**
* Set system tags on a node
*
* @param node
* @param tags
* @param node - The node to set tags on
* @param tags - The tags to set
*/
export function setNodeSystemTags(node: INode, tags: string[]): void {
Vue.set(node.attributes, 'system-tags', {
node.attributes['system-tags'] = {
'system-tag': tags,
})
}
emit('files:node:updated', node)
}

View file

@ -2,6 +2,7 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import Color from 'color'
type hexColor = `#${string & (

View file

@ -3,6 +3,64 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { TagWithId } from '../types.ts'
import { showError } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { onBeforeMount, ref } from 'vue'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import SystemTagForm from '../components/SystemTagForm.vue'
import SystemTagsCreationControl from '../components/SystemTagsCreationControl.vue'
import logger from '../logger.ts'
import { fetchTags } from '../services/api.ts'
const loadingTags = ref(false)
const tags = ref<TagWithId[]>([])
onBeforeMount(async () => {
loadingTags.value = true
try {
tags.value = await fetchTags()
} catch (error) {
showError(t('systemtags', 'Failed to load tags'))
logger.error('Failed to load tags', { error })
}
loadingTags.value = false
})
/**
* Handle tag creation
*
* @param tag - The created tag
*/
function handleCreate(tag: TagWithId) {
tags.value.unshift(tag)
}
/**
* Handle tag update
*
* @param tag - The updated tag
*/
function handleUpdate(tag: TagWithId) {
const tagIndex = tags.value.findIndex((currTag) => currTag.id === tag.id)
tags.value.splice(tagIndex, 1)
tags.value.unshift(tag)
}
/**
* Handle tag deletion
*
* @param tag - The deleted tag
*/
function handleDelete(tag: TagWithId) {
const tagIndex = tags.value.findIndex((currTag) => currTag.id === tag.id)
tags.value.splice(tagIndex, 1)
}
</script>
<template>
<NcSettingsSection
:name="t('systemtags', 'Collaborative tags')"
@ -20,65 +78,3 @@
@tag:deleted="handleDelete" />
</NcSettingsSection>
</template>
<script lang="ts">
import type { TagWithId } from '../types.js'
import { showError } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import Vue from 'vue'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import SystemTagForm from '../components/SystemTagForm.vue'
import SystemTagsCreationControl from '../components/SystemTagsCreationControl.vue'
import logger from '../logger.js'
import { fetchTags } from '../services/api.js'
export default Vue.extend({
name: 'SystemTagsSection',
components: {
NcLoadingIcon,
NcSettingsSection,
SystemTagForm,
SystemTagsCreationControl,
},
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'))
logger.error('Failed to load tags', { error })
}
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>

View file

@ -70,10 +70,6 @@ module.exports = {
'vue-settings-personal-webauthn': path.join(__dirname, 'apps/settings/src', 'main-personal-webauth.js'),
'declarative-settings-forms': path.join(__dirname, 'apps/settings/src', 'main-declarative-settings-forms.ts'),
},
systemtags: {
init: path.join(__dirname, 'apps/systemtags/src', 'init.ts'),
admin: path.join(__dirname, 'apps/systemtags/src', 'admin.ts'),
},
updatenotification: {
init: path.join(__dirname, 'apps/updatenotification/src', 'init.ts'),
'view-changelog-page': path.join(__dirname, 'apps/updatenotification/src', 'view-changelog-page.ts'),

View file

@ -50,6 +50,10 @@ const modules = {
sharebymail: {
'admin-settings': resolve(import.meta.dirname, 'apps/sharebymail/src', 'settings-admin.ts'),
},
systemtags: {
init: resolve(import.meta.dirname, 'apps/systemtags/src', 'init.ts'),
admin: resolve(import.meta.dirname, 'apps/systemtags/src', 'admin.ts'),
},
theming: {
'settings-personal': resolve(import.meta.dirname, 'apps/theming/src', 'settings-personal.ts'),
'settings-admin': resolve(import.meta.dirname, 'apps/theming/src', 'settings-admin.ts'),

56
package-lock.json generated
View file

@ -29,6 +29,7 @@
"@nextcloud/vue": "^9.4.0",
"@vueuse/core": "^14.1.0",
"@vueuse/integrations": "^14.1.0",
"color": "^5.0.3",
"debounce": "^3.0.0",
"pinia": "^3.0.4",
"sortablejs": "^1.15.6",
@ -6219,6 +6220,19 @@
"node": ">=0.8"
}
},
"node_modules/color": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
"integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==",
"license": "MIT",
"dependencies": {
"color-convert": "^3.1.3",
"color-string": "^2.1.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -6239,6 +6253,48 @@
"dev": true,
"license": "MIT"
},
"node_modules/color-string": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
"license": "MIT",
"dependencies": {
"color-name": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/color-string/node_modules/color-name": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
"license": "MIT",
"engines": {
"node": ">=12.20"
}
},
"node_modules/color/node_modules/color-convert": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz",
"integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==",
"license": "MIT",
"dependencies": {
"color-name": "^2.0.0"
},
"engines": {
"node": ">=14.6"
}
},
"node_modules/color/node_modules/color-name": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
"license": "MIT",
"engines": {
"node": ">=12.20"
}
},
"node_modules/colord": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",

View file

@ -58,6 +58,7 @@
"@nextcloud/vue": "^9.4.0",
"@vueuse/core": "^14.1.0",
"@vueuse/integrations": "^14.1.0",
"color": "^5.0.3",
"debounce": "^3.0.0",
"pinia": "^3.0.4",
"sortablejs": "^1.15.6",