Merge pull request #37065 from nextcloud/enh/a11y-system-tags
Add accessible system tags selectpull/37834/head
commit
09cb9c78e7
@ -0,0 +1,235 @@
|
||||
<!--
|
||||
- @copyright 2023 Christopher Ng <chrng8@gmail.com>
|
||||
-
|
||||
- @author Christopher Ng <chrng8@gmail.com>
|
||||
-
|
||||
- @license AGPL-3.0-or-later
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as
|
||||
- published by the Free Software Foundation, either version 3 of the
|
||||
- License, or (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="system-tags">
|
||||
<label for="system-tags-input">{{ t('systemtags', 'Search or create collaborative tags') }}</label>
|
||||
<NcSelectTags class="system-tags__select"
|
||||
input-id="system-tags-input"
|
||||
:placeholder="t('systemtags', 'Collaborative tags …')"
|
||||
:options="sortedTags"
|
||||
:value="selectedTags"
|
||||
:create-option="createOption"
|
||||
:taggable="true"
|
||||
:passthru="true"
|
||||
:fetch-tags="false"
|
||||
:loading="loading"
|
||||
@input="handleInput"
|
||||
@option:selected="handleSelect"
|
||||
@option:created="handleCreate"
|
||||
@option:deselected="handleDeselect">
|
||||
<template #no-options>
|
||||
{{ t('systemtags', 'No tags to select, type to create a new tag') }}
|
||||
</template>
|
||||
</NcSelectTags>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// FIXME Vue TypeScript ESLint errors
|
||||
/* eslint-disable */
|
||||
import Vue from 'vue'
|
||||
import NcSelectTags from '@nextcloud/vue/dist/Components/NcSelectTags.js'
|
||||
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
|
||||
import {
|
||||
createTag,
|
||||
deleteTag,
|
||||
fetchLastUsedTagIds,
|
||||
fetchSelectedTags,
|
||||
fetchTags,
|
||||
selectTag,
|
||||
} from '../services/api.js'
|
||||
|
||||
import type { BaseTag, Tag, TagWithId } from '../types.js'
|
||||
|
||||
const defaultBaseTag: BaseTag = {
|
||||
userVisible: true,
|
||||
userAssignable: true,
|
||||
canAssign: true,
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'SystemTags',
|
||||
|
||||
components: {
|
||||
NcSelectTags,
|
||||
},
|
||||
|
||||
props: {
|
||||
fileId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
sortedTags: [] as TagWithId[],
|
||||
selectedTags: [] as TagWithId[],
|
||||
loading: 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'))
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
fileId: {
|
||||
immediate: true,
|
||||
async handler() {
|
||||
try {
|
||||
this.selectedTags = await fetchSelectedTags(this.fileId)
|
||||
this.$emit('has-tags', this.selectedTags.length > 0)
|
||||
} catch (error) {
|
||||
showError(t('systemtags', 'Failed to load selected tags'))
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
t,
|
||||
|
||||
createOption(newDisplayName: string): Tag {
|
||||
for (const tag of this.sortedTags) {
|
||||
const { id, 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 selectedTag = tags[tags.length - 1]
|
||||
if (!selectedTag.id) {
|
||||
// Ignore created tags handled by `handleCreate()`
|
||||
return
|
||||
}
|
||||
this.loading = true
|
||||
try {
|
||||
await selectTag(this.fileId, selectedTag)
|
||||
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'))
|
||||
}
|
||||
this.loading = false
|
||||
},
|
||||
|
||||
async handleCreate(tag: Tag) {
|
||||
this.loading = true
|
||||
try {
|
||||
const id = await createTag(this.fileId, tag)
|
||||
const createdTag = { ...tag, id }
|
||||
this.sortedTags.unshift(createdTag)
|
||||
this.selectedTags.push(createdTag)
|
||||
} catch (error) {
|
||||
showError(t('systemtags', 'Failed to create tag'))
|
||||
}
|
||||
this.loading = false
|
||||
},
|
||||
|
||||
async handleDeselect(tag: Tag) {
|
||||
this.loading = true
|
||||
try {
|
||||
await deleteTag(this.fileId, tag)
|
||||
} catch (error) {
|
||||
showError(t('systemtags', 'Failed to delete tag'))
|
||||
}
|
||||
this.loading = false
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.system-tags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
label[for="system-tags-input"] {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&__select {
|
||||
width: 100%;
|
||||
:deep {
|
||||
.vs__deselect {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2015 Vincent Petry <pvince81@owncloud.com>
|
||||
*
|
||||
* @author Joas Schilling <coding@schilljs.com>
|
||||
* @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
* @author Vincent Petry <vincent@nextcloud.com>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
(function() {
|
||||
OCA.SystemTags = _.extend({}, OCA.SystemTags)
|
||||
if (!OCA.SystemTags) {
|
||||
/**
|
||||
* @namespace
|
||||
*/
|
||||
OCA.SystemTags = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @namespace
|
||||
*/
|
||||
OCA.SystemTags.FilesPlugin = {
|
||||
ignoreLists: [
|
||||
'trashbin',
|
||||
'files.public',
|
||||
],
|
||||
|
||||
attach(fileList) {
|
||||
if (this.ignoreLists.indexOf(fileList.id) >= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// only create and attach once
|
||||
// FIXME: this should likely be done on a different code path now
|
||||
// for the sidebar to only have it registered once
|
||||
if (!OCA.SystemTags.View) {
|
||||
const systemTagsInfoView = new OCA.SystemTags.SystemTagsInfoView()
|
||||
fileList.registerDetailView(systemTagsInfoView)
|
||||
OCA.SystemTags.View = systemTagsInfoView
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
})()
|
||||
|
||||
OC.Plugins.register('OCA.Files.FileList', OCA.SystemTags.FilesPlugin)
|
||||
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @copyright 2023 Christopher Ng <chrng8@gmail.com>
|
||||
*
|
||||
* @author Christopher Ng <chrng8@gmail.com>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
import { getLoggerBuilder } from '@nextcloud/logger'
|
||||
|
||||
export const logger = getLoggerBuilder()
|
||||
.setApp('systemtags')
|
||||
.detectUser()
|
||||
.build()
|
||||
@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @copyright 2023 Christopher Ng <chrng8@gmail.com>
|
||||
*
|
||||
* @author Christopher Ng <chrng8@gmail.com>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
|
||||
import { davClient } from './davClient.js'
|
||||
import { formatTag, parseIdFromLocation, parseTags } from '../utils.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
import type { FileStat, ResponseDataDetailed } from 'webdav'
|
||||
|
||||
import type { ServerTag, Tag, TagWithId } from '../types.js'
|
||||
|
||||
const fetchTagsBody = `<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:prop>
|
||||
<oc:id />
|
||||
<oc:display-name />
|
||||
<oc:user-visible />
|
||||
<oc:user-assignable />
|
||||
<oc:can-assign />
|
||||
</d:prop>
|
||||
</d:propfind>`
|
||||
|
||||
export const fetchTags = async (): Promise<TagWithId[]> => {
|
||||
const path = '/systemtags'
|
||||
try {
|
||||
const { data: tags } = await davClient.getDirectoryContents(path, {
|
||||
data: fetchTagsBody,
|
||||
details: true,
|
||||
glob: '/systemtags/*', // Filter out first empty tag
|
||||
}) as ResponseDataDetailed<Required<FileStat>[]>
|
||||
return parseTags(tags)
|
||||
} catch (error) {
|
||||
logger.error(t('systemtags', 'Failed to load tags'), { error })
|
||||
throw new Error(t('systemtags', 'Failed to load tags'))
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchLastUsedTagIds = async (): Promise<number[]> => {
|
||||
const url = generateUrl('/apps/systemtags/lastused')
|
||||
try {
|
||||
const { data: lastUsedTagIds } = await axios.get<string[]>(url)
|
||||
return lastUsedTagIds.map(Number)
|
||||
} catch (error) {
|
||||
logger.error(t('systemtags', 'Failed to load last used tags'), { error })
|
||||
throw new Error(t('systemtags', 'Failed to load last used tags'))
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchSelectedTags = async (fileId: number): Promise<TagWithId[]> => {
|
||||
const path = '/systemtags-relations/files/' + fileId
|
||||
try {
|
||||
const { data: tags } = await davClient.getDirectoryContents(path, {
|
||||
data: fetchTagsBody,
|
||||
details: true,
|
||||
glob: '/systemtags-relations/files/*/*', // Filter out first empty tag
|
||||
}) as ResponseDataDetailed<Required<FileStat>[]>
|
||||
return parseTags(tags)
|
||||
} catch (error) {
|
||||
logger.error(t('systemtags', 'Failed to load selected tags'), { error })
|
||||
throw new Error(t('systemtags', 'Failed to load selected tags'))
|
||||
}
|
||||
}
|
||||
|
||||
export const selectTag = async (fileId: number, tag: Tag | ServerTag): Promise<void> => {
|
||||
const path = '/systemtags-relations/files/' + fileId + '/' + tag.id
|
||||
const tagToPut = formatTag(tag)
|
||||
try {
|
||||
await davClient.customRequest(path, {
|
||||
method: 'PUT',
|
||||
data: tagToPut,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(t('systemtags', 'Failed to select tag'), { error })
|
||||
throw new Error(t('systemtags', 'Failed to select tag'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return created tag id
|
||||
*/
|
||||
export const createTag = async (fileId: number, tag: Tag): Promise<number> => {
|
||||
const path = '/systemtags'
|
||||
const tagToPost = formatTag(tag)
|
||||
try {
|
||||
const { headers } = await davClient.customRequest(path, {
|
||||
method: 'POST',
|
||||
data: tagToPost,
|
||||
})
|
||||
const contentLocation = headers.get('content-location')
|
||||
if (contentLocation) {
|
||||
const tagToPut = {
|
||||
...tagToPost,
|
||||
id: parseIdFromLocation(contentLocation),
|
||||
}
|
||||
await selectTag(fileId, tagToPut)
|
||||
return tagToPut.id
|
||||
}
|
||||
logger.error(t('systemtags', 'Missing "Content-Location" header'))
|
||||
throw new Error(t('systemtags', 'Missing "Content-Location" header'))
|
||||
} catch (error) {
|
||||
logger.error(t('systemtags', 'Failed to create tag'), { error })
|
||||
throw new Error(t('systemtags', 'Failed to create tag'))
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteTag = async (fileId: number, tag: Tag): Promise<void> => {
|
||||
const path = '/systemtags-relations/files/' + fileId + '/' + tag.id
|
||||
try {
|
||||
await davClient.deleteFile(path)
|
||||
} catch (error) {
|
||||
logger.error(t('systemtags', 'Failed to delete tag'), { error })
|
||||
throw new Error(t('systemtags', 'Failed to delete tag'))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @copyright 2023 Christopher Ng <chrng8@gmail.com>
|
||||
*
|
||||
* @author Christopher Ng <chrng8@gmail.com>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
import { createClient } from 'webdav'
|
||||
import { generateRemoteUrl } from '@nextcloud/router'
|
||||
import { getRequestToken } from '@nextcloud/auth'
|
||||
|
||||
const rootUrl = generateRemoteUrl('dav')
|
||||
|
||||
export const davClient = createClient(rootUrl, {
|
||||
headers: {
|
||||
requesttoken: getRequestToken() ?? '',
|
||||
},
|
||||
})
|
||||
@ -1,194 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2015
|
||||
*
|
||||
* @author Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* @author Joas Schilling <coding@schilljs.com>
|
||||
* @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
* @author Julius Härtl <jus@bitgrid.net>
|
||||
* @author Vincent Petry <vincent@nextcloud.com>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
(function(OCA) {
|
||||
|
||||
/**
|
||||
* @param {any} model -
|
||||
*/
|
||||
function modelToSelection(model) {
|
||||
const data = model.toJSON()
|
||||
if (!OC.isUserAdmin() && !data.canAssign) {
|
||||
data.locked = true
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* @class OCA.SystemTags.SystemTagsInfoView
|
||||
* @classdesc
|
||||
*
|
||||
* Displays a file's system tags
|
||||
*
|
||||
*/
|
||||
const SystemTagsInfoView = OCA.Files.DetailFileInfoView.extend(
|
||||
/** @lends OCA.SystemTags.SystemTagsInfoView.prototype */ {
|
||||
|
||||
_rendered: false,
|
||||
|
||||
className: 'systemTagsInfoView',
|
||||
name: 'systemTags',
|
||||
|
||||
/* required by the new files sidebar to check if the view is unique */
|
||||
id: 'systemTagsInfoView',
|
||||
|
||||
/**
|
||||
* @type {OC.SystemTags.SystemTagsInputField}
|
||||
*/
|
||||
_inputView: null,
|
||||
|
||||
initialize(options) {
|
||||
const self = this
|
||||
options = options || {}
|
||||
|
||||
this._inputView = new OC.SystemTags.SystemTagsInputField({
|
||||
multiple: true,
|
||||
allowActions: true,
|
||||
allowCreate: true,
|
||||
isAdmin: OC.isUserAdmin(),
|
||||
initSelection(element, callback) {
|
||||
callback(self.selectedTagsCollection.map(modelToSelection))
|
||||
},
|
||||
})
|
||||
|
||||
this.selectedTagsCollection = new OC.SystemTags.SystemTagsMappingCollection([], { objectType: 'files' })
|
||||
|
||||
this._inputView.collection.on('change:name', this._onTagRenamedGlobally, this)
|
||||
this._inputView.collection.on('remove', this._onTagDeletedGlobally, this)
|
||||
|
||||
this._inputView.on('select', this._onSelectTag, this)
|
||||
this._inputView.on('deselect', this._onDeselectTag, this)
|
||||
},
|
||||
|
||||
/**
|
||||
* Event handler whenever a tag was selected
|
||||
*
|
||||
* @param {object} tag the tag to create
|
||||
*/
|
||||
_onSelectTag(tag) {
|
||||
// create a mapping entry for this tag
|
||||
this.selectedTagsCollection.create(tag.toJSON())
|
||||
},
|
||||
|
||||
/**
|
||||
* Event handler whenever a tag gets deselected.
|
||||
* Removes the selected tag from the mapping collection.
|
||||
*
|
||||
* @param {string} tagId tag id
|
||||
*/
|
||||
_onDeselectTag(tagId) {
|
||||
this.selectedTagsCollection.get(tagId).destroy()
|
||||
},
|
||||
|
||||
/**
|
||||
* Event handler whenever a tag was renamed globally.
|
||||
*
|
||||
* This will automatically adjust the tag mapping collection to
|
||||
* container the new name.
|
||||
*
|
||||
* @param {OC.Backbone.Model} changedTag tag model that has changed
|
||||
*/
|
||||
_onTagRenamedGlobally(changedTag) {
|
||||
// also rename it in the selection, if applicable
|
||||
const selectedTagMapping = this.selectedTagsCollection.get(changedTag.id)
|
||||
if (selectedTagMapping) {
|
||||
selectedTagMapping.set(changedTag.toJSON())
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Event handler whenever a tag was deleted globally.
|
||||
*
|
||||
* This will automatically adjust the tag mapping collection to
|
||||
* container the new name.
|
||||
*
|
||||
* @param {OC.Backbone.Model} tagId tag model that has changed
|
||||
*/
|
||||
_onTagDeletedGlobally(tagId) {
|
||||
// also rename it in the selection, if applicable
|
||||
this.selectedTagsCollection.remove(tagId)
|
||||
},
|
||||
|
||||
setFileInfo(fileInfo) {
|
||||
const self = this
|
||||
if (!this._rendered) {
|
||||
this.render()
|
||||
}
|
||||
|
||||
if (fileInfo) {
|
||||
this.selectedTagsCollection.setObjectId(fileInfo.id)
|
||||
this.selectedTagsCollection.fetch({
|
||||
success(collection) {
|
||||
collection.fetched = true
|
||||
|
||||
const appliedTags = collection.map(modelToSelection)
|
||||
self._inputView.setData(appliedTags)
|
||||
if (appliedTags.length > 0) {
|
||||
self.show()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
this.hide()
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders this details view
|
||||
*/
|
||||
render() {
|
||||
this.$el.append(this._inputView.$el)
|
||||
this._inputView.render()
|
||||
},
|
||||
|
||||
isVisible() {
|
||||
return !this.$el.hasClass('hidden')
|
||||
},
|
||||
|
||||
show() {
|
||||
this.$el.removeClass('hidden')
|
||||
},
|
||||
|
||||
hide() {
|
||||
this.$el.addClass('hidden')
|
||||
},
|
||||
|
||||
toggle() {
|
||||
this.$el.toggleClass('hidden')
|
||||
},
|
||||
|
||||
openDropdown() {
|
||||
this.$el.find('.systemTagsInputField').select2('open')
|
||||
},
|
||||
|
||||
remove() {
|
||||
this._inputView.remove()
|
||||
},
|
||||
})
|
||||
|
||||
OCA.SystemTags.SystemTagsInfoView = SystemTagsInfoView
|
||||
|
||||
})(OCA)
|
||||
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @copyright 2023 Christopher Ng <chrng8@gmail.com>
|
||||
*
|
||||
* @author Christopher Ng <chrng8@gmail.com>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
export interface BaseTag {
|
||||
id?: number
|
||||
userVisible: boolean
|
||||
userAssignable: boolean
|
||||
readonly canAssign: boolean // Computed server-side
|
||||
}
|
||||
|
||||
export type Tag = BaseTag & {
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export type TagWithId = Required<Tag>
|
||||
|
||||
export type ServerTag = BaseTag & {
|
||||
name: string
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @copyright 2023 Christopher Ng <chrng8@gmail.com>
|
||||
*
|
||||
* @author Christopher Ng <chrng8@gmail.com>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
import camelCase from 'camelcase'
|
||||
|
||||
import type { FileStat } from 'webdav'
|
||||
|
||||
import type { ServerTag, Tag, TagWithId } from './types.js'
|
||||
|
||||
export const parseTags = (tags: Required<FileStat>[]): TagWithId[] => {
|
||||
return tags.map(({ props }) => Object.fromEntries(
|
||||
Object.entries(props)
|
||||
.map(([key, value]) => [camelCase(key), value])
|
||||
)) as TagWithId[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse id from `Content-Location` header
|
||||
*/
|
||||
export const parseIdFromLocation = (url: string): number => {
|
||||
const queryPos = url.indexOf('?')
|
||||
if (queryPos > 0) {
|
||||
url = url.substring(0, queryPos)
|
||||
}
|
||||
|
||||
const parts = url.split('/')
|
||||
let result
|
||||
do {
|
||||
result = parts[parts.length - 1]
|
||||
parts.pop()
|
||||
// note: first result can be empty when there is a trailing slash,
|
||||
// so we take the part before that
|
||||
} while (!result && parts.length > 0)
|
||||
|
||||
return Number(result)
|
||||
}
|
||||
|
||||
export const formatTag = (initialTag: Tag | ServerTag): ServerTag => {
|
||||
const tag: any = { ...initialTag }
|
||||
if (tag.name && !tag.displayName) {
|
||||
return tag
|
||||
}
|
||||
tag.name = tag.displayName
|
||||
delete tag.displayName
|
||||
|
||||
return tag
|
||||
}
|
||||
@ -1,252 +0,0 @@
|
||||
/**
|
||||
* @copyright 2016 Vincent Petry <pvince81@owncloud.com>
|
||||
*
|
||||
* @author Daniel Calviño Sánchez <danxuliu@gmail.com>
|
||||
* @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
* @author Vincent Petry <vincent@nextcloud.com>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
describe('OCA.SystemTags.SystemTagsInfoView tests', function() {
|
||||
var isAdminStub;
|
||||
var view;
|
||||
var clock;
|
||||
|
||||
beforeEach(function() {
|
||||
clock = sinon.useFakeTimers();
|
||||
view = new OCA.SystemTags.SystemTagsInfoView();
|
||||
$('#testArea').append(view.$el);
|
||||
isAdminStub = sinon.stub(OC, 'isUserAdmin').returns(true);
|
||||
});
|
||||
afterEach(function() {
|
||||
isAdminStub.restore();
|
||||
clock.restore();
|
||||
view.remove();
|
||||
view = undefined;
|
||||
});
|
||||
describe('rendering', function() {
|
||||
it('renders input field view', function() {
|
||||
view.render();
|
||||
expect(view.$el.find('input[name=tags]').length).toEqual(1);
|
||||
});
|
||||
it('fetches selected tags then renders when setting file info', function() {
|
||||
var fetchStub = sinon.stub(OC.SystemTags.SystemTagsMappingCollection.prototype, 'fetch');
|
||||
var setDataStub = sinon.stub(OC.SystemTags.SystemTagsInputField.prototype, 'setData');
|
||||
|
||||
expect(view.$el.hasClass('hidden')).toEqual(false);
|
||||
|
||||
view.setFileInfo({id: '123'});
|
||||
expect(view.$el.find('input[name=tags]').length).toEqual(1);
|
||||
|
||||
expect(fetchStub.calledOnce).toEqual(true);
|
||||
expect(view.selectedTagsCollection.url())
|
||||
.toEqual(OC.linkToRemote('dav') + '/systemtags-relations/files/123');
|
||||
|
||||
view.selectedTagsCollection.add([
|
||||
{id: '1', name: 'test1'},
|
||||
{id: '3', name: 'test3'}
|
||||
]);
|
||||
|
||||
fetchStub.yieldTo('success', view.selectedTagsCollection);
|
||||
expect(setDataStub.calledOnce).toEqual(true);
|
||||
expect(setDataStub.getCall(0).args[0]).toEqual([{
|
||||
id: '1', name: 'test1', userVisible: true, userAssignable: true, canAssign: true
|
||||
}, {
|
||||
id: '3', name: 'test3', userVisible: true, userAssignable: true, canAssign: true
|
||||
}]);
|
||||
|
||||
expect(view.$el.hasClass('hidden')).toEqual(false);
|
||||
|
||||
fetchStub.restore();
|
||||
setDataStub.restore();
|
||||
});
|
||||
it('overrides initSelection to use the local collection', function() {
|
||||
var inputViewSpy = sinon.spy(OC.SystemTags, 'SystemTagsInputField');
|
||||
var element = $('<input type="hidden" val="1,3"/>');
|
||||
view.remove();
|
||||
view = new OCA.SystemTags.SystemTagsInfoView();
|
||||
view.selectedTagsCollection.add([
|
||||
{id: '1', name: 'test1'},
|
||||
{id: '3', name: 'test3', userVisible: false, userAssignable: false, canAssign: false}
|
||||
]);
|
||||
|
||||
var callback = sinon.stub();
|
||||
inputViewSpy.getCall(0).args[0].initSelection(element, callback);
|
||||
|
||||
expect(callback.calledOnce).toEqual(true);
|
||||
expect(callback.getCall(0).args[0]).toEqual([{
|
||||
id: '1', name: 'test1', userVisible: true, userAssignable: true, canAssign: true
|
||||
}, {
|
||||
id: '3', name: 'test3', userVisible: false, userAssignable: false, canAssign: false
|
||||
}]);
|
||||
|
||||
inputViewSpy.restore();
|
||||
});
|
||||
it('sets locked flag on non-assignable tags when user is not an admin', function() {
|
||||
isAdminStub.returns(false);
|
||||
|
||||
var inputViewSpy = sinon.spy(OC.SystemTags, 'SystemTagsInputField');
|
||||
var element = $('<input type="hidden" val="1,3"/>');
|
||||
view.remove();
|
||||
view = new OCA.SystemTags.SystemTagsInfoView();
|
||||
view.selectedTagsCollection.add([
|
||||
{id: '1', name: 'test1'},
|
||||
{id: '3', name: 'test3', userAssignable: false, canAssign: false}
|
||||
]);
|
||||
|
||||
var callback = sinon.stub();
|
||||
inputViewSpy.getCall(0).args[0].initSelection(element, callback);
|
||||
|
||||
expect(callback.calledOnce).toEqual(true);
|
||||
expect(callback.getCall(0).args[0]).toEqual([{
|
||||
id: '1', name: 'test1', userVisible: true, userAssignable: true, canAssign: true
|
||||
}, {
|
||||
id: '3', name: 'test3', userVisible: true, userAssignable: false, canAssign: false, locked: true
|
||||
}]);
|
||||
|
||||
inputViewSpy.restore();
|
||||
});
|
||||
it('does not set locked flag on non-assignable tags when canAssign overrides it with true', function() {
|
||||
isAdminStub.returns(false);
|
||||
|
||||
var inputViewSpy = sinon.spy(OC.SystemTags, 'SystemTagsInputField');
|
||||
var element = $('<input type="hidden" val="1,4"/>');
|
||||
view.remove();
|
||||
view = new OCA.SystemTags.SystemTagsInfoView();
|
||||
view.selectedTagsCollection.add([
|
||||
{id: '1', name: 'test1'},
|
||||
{id: '4', name: 'test4', userAssignable: false, canAssign: true}
|
||||
]);
|
||||
|
||||
var callback = sinon.stub();
|
||||
inputViewSpy.getCall(0).args[0].initSelection(element, callback);
|
||||
|
||||
expect(callback.calledOnce).toEqual(true);
|
||||
expect(callback.getCall(0).args[0]).toEqual([{
|
||||
id: '1', name: 'test1', userVisible: true, userAssignable: true, canAssign: true
|
||||
}, {
|
||||
id: '4', name: 'test4', userVisible: true, userAssignable: false, canAssign: true
|
||||
}]);
|
||||
|
||||
inputViewSpy.restore();
|
||||
});
|
||||
});
|
||||
describe('events', function() {
|
||||
var allTagsCollection;
|
||||
beforeEach(function() {
|
||||
allTagsCollection = view._inputView.collection;
|
||||
|
||||
allTagsCollection.add([
|
||||
{id: '1', name: 'test1'},
|
||||
{id: '2', name: 'test2'},
|
||||
{id: '3', name: 'test3'}
|
||||
]);
|
||||
|
||||
view.selectedTagsCollection.add([
|
||||
{id: '1', name: 'test1'},
|
||||
{id: '3', name: 'test3'}
|
||||
]);
|
||||
view.render();
|
||||
});
|
||||
|
||||
it('renames model in selection collection on rename', function() {
|
||||
allTagsCollection.get('3').set('name', 'test3_renamed');
|
||||
|
||||
expect(view.selectedTagsCollection.get('3').get('name')).toEqual('test3_renamed');
|
||||
});
|
||||
|
||||
it('adds tag to selection collection when selected by input', function() {
|
||||
var createStub = sinon.stub(OC.SystemTags.SystemTagsMappingCollection.prototype, 'create');
|
||||
view._inputView.trigger('select', allTagsCollection.get('2'));
|
||||
|
||||
expect(createStub.calledOnce).toEqual(true);
|
||||
expect(createStub.getCall(0).args[0]).toEqual({
|
||||
id: '2',
|
||||
name: 'test2',
|
||||
userVisible: true,
|
||||
userAssignable: true,
|
||||
canAssign: true
|
||||
});
|
||||
|
||||
createStub.restore();
|
||||
});
|
||||
it('removes tag from selection collection when deselected by input', function() {
|
||||
var destroyStub = sinon.stub(OC.SystemTags.SystemTagModel.prototype, 'destroy');
|
||||
view._inputView.trigger('deselect', '3');
|
||||
|
||||
expect(destroyStub.calledOnce).toEqual(true);
|
||||
expect(destroyStub.calledOn(view.selectedTagsCollection.get('3'))).toEqual(true);
|
||||
|
||||
destroyStub.restore();
|
||||
});
|
||||
|
||||
it('removes tag from selection whenever the tag was deleted globally', function() {
|
||||
expect(view.selectedTagsCollection.get('3')).not.toBeFalsy();
|
||||
|
||||
allTagsCollection.remove('3');
|
||||
|
||||
expect(view.selectedTagsCollection.get('3')).toBeFalsy();
|
||||
|
||||
});
|
||||
});
|
||||
describe('visibility', function() {
|
||||
it('reports visibility based on the "hidden" class name', function() {
|
||||
view.$el.addClass('hidden');
|
||||
|
||||
expect(view.isVisible()).toBeFalsy();
|
||||
|
||||
view.$el.removeClass('hidden');
|
||||
|
||||
expect(view.isVisible()).toBeTruthy();
|
||||
});
|
||||
it('is visible after rendering', function() {
|
||||
view.render();
|
||||
|
||||
expect(view.isVisible()).toBeTruthy();
|
||||
});
|
||||
it('shows and hides the element', function() {
|
||||
view.show();
|
||||
|
||||
expect(view.isVisible()).toBeTruthy();
|
||||
|
||||
view.hide();
|
||||
|
||||
expect(view.isVisible()).toBeFalsy();
|
||||
|
||||
view.show();
|
||||
|
||||
expect(view.isVisible()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
describe('select2', function() {
|
||||
var select2Stub;
|
||||
|
||||
beforeEach(function() {
|
||||
select2Stub = sinon.stub($.fn, 'select2');
|
||||
});
|
||||
afterEach(function() {
|
||||
select2Stub.restore();
|
||||
});
|
||||
it('opens dropdown', function() {
|
||||
view.openDropdown();
|
||||
|
||||
expect(select2Stub.calledOnce).toBeTruthy();
|
||||
expect(select2Stub.withArgs('open')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1 +1 @@
|
||||
.systemtags-select2-dropdown .select2-result-label .checkmark{visibility:hidden;margin-left:-5px;margin-right:5px;padding:4px}.systemtags-select2-dropdown .select2-result-label .new-item .systemtags-actions{display:none}.systemtags-select2-dropdown .select2-selected .select2-result-label .checkmark{visibility:visible}.systemtags-select2-dropdown .select2-result-label .icon{display:inline-block;opacity:.5}.systemtags-select2-dropdown .select2-result-label .icon.rename{padding:4px}.systemtags-select2-dropdown .systemtags-actions{position:absolute;right:5px}.systemtags-select2-dropdown .systemtags-rename-form{display:inline-block;width:calc(100% - 20px);top:-6px;position:relative}.systemtags-select2-dropdown .systemtags-rename-form input{display:inline-block;height:30px;width:calc(100% - 40px)}.systemtags-select2-dropdown .label{width:85%;display:inline-block;overflow:hidden;text-overflow:ellipsis}.systemtags-select2-dropdown .label.hidden{display:none}.systemtags-select2-dropdown span{line-height:25px}.systemtags-select2-dropdown .systemtags-item{display:inline-block;height:25px;width:100%}.systemtags-select2-dropdown .select2-result-label{height:25px}.systemTagsInfoView,.systemtags-select2-container{width:100%}.systemTagsInfoView .select2-choices,.systemtags-select2-container .select2-choices{flex-wrap:nowrap !important;max-height:44px}.systemTagsInfoView .select2-choices .select2-search-choice.select2-locked .label,.systemtags-select2-container .select2-choices .select2-search-choice.select2-locked .label{opacity:.5}#select2-drop.systemtags-select2-dropdown .select2-results li.select2-result{padding:5px}/*# sourceMappingURL=systemtags.css.map */
|
||||
.systemtags-select2-dropdown .select2-result-label .checkmark{visibility:hidden;margin-left:-5px;margin-right:5px;padding:4px}.systemtags-select2-dropdown .select2-result-label .new-item .systemtags-actions{display:none}.systemtags-select2-dropdown .select2-selected .select2-result-label .checkmark{visibility:visible}.systemtags-select2-dropdown .select2-result-label .icon{display:inline-block;opacity:.5}.systemtags-select2-dropdown .select2-result-label .icon.rename{padding:4px}.systemtags-select2-dropdown .systemtags-actions{position:absolute;right:5px}.systemtags-select2-dropdown .systemtags-rename-form{display:inline-block;width:calc(100% - 20px);top:-6px;position:relative}.systemtags-select2-dropdown .systemtags-rename-form input{display:inline-block;height:30px;width:calc(100% - 40px)}.systemtags-select2-dropdown .label{width:85%;display:inline-block;overflow:hidden;text-overflow:ellipsis}.systemtags-select2-dropdown .label.hidden{display:none}.systemtags-select2-dropdown span{line-height:25px}.systemtags-select2-dropdown .systemtags-item{display:inline-block;height:25px;width:100%}.systemtags-select2-dropdown .select2-result-label{height:25px}.systemtags-select2-container{width:100%}.systemtags-select2-container .select2-choices{flex-wrap:nowrap !important;max-height:44px}.systemtags-select2-container .select2-choices .select2-search-choice.select2-locked .label{opacity:.5}#select2-drop.systemtags-select2-dropdown .select2-results li.select2-result{padding:5px}/*# sourceMappingURL=systemtags.css.map */
|
||||
|
||||
@ -1 +1 @@
|
||||
{"version":3,"sourceRoot":"","sources":["systemtags.scss"],"names":[],"mappings":"AAcE,8DACC,kBACA,iBACA,iBACA,YAED,iFACC,aAGF,gFACC,mBAED,yDACC,qBACA,WACA,gEACC,YAGF,iDACC,kBACA,UAED,qDACC,qBACA,wBACA,SACA,kBACA,2DACC,qBACA,YACA,wBAGF,oCACC,UACA,qBACA,gBACA,uBACA,2CACC,aAGF,kCACC,iBAED,8CACC,qBACA,YACA,WAED,mDACC,YAIF,kDAEC,WAEA,oFACC,4BACA,gBAGD,8KACC,WAIF,6EACC","file":"systemtags.css"}
|
||||
{"version":3,"sourceRoot":"","sources":["systemtags.scss"],"names":[],"mappings":"AAcE,8DACC,kBACA,iBACA,iBACA,YAED,iFACC,aAGF,gFACC,mBAED,yDACC,qBACA,WACA,gEACC,YAGF,iDACC,kBACA,UAED,qDACC,qBACA,wBACA,SACA,kBACA,2DACC,qBACA,YACA,wBAGF,oCACC,UACA,qBACA,gBACA,uBACA,2CACC,aAGF,kCACC,iBAED,8CACC,qBACA,YACA,WAED,mDACC,YAIF,8BACC,WAEA,+CACC,4BACA,gBAGD,4FACC,WAIF,6EACC","file":"systemtags.css"}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue