mirror of
https://github.com/nextcloud/server.git
synced 2026-02-03 20:41:22 -05:00
refactor(systemtags): migrate to Vue 3
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
2bc3af8b2a
commit
6ca11f73e3
23 changed files with 775 additions and 698 deletions
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', [], '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
56
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue