Add accessible system tags select

Signed-off-by: Christopher Ng <chrng8@gmail.com>
This commit is contained in:
Christopher Ng 2023-04-19 16:52:06 -07:00
parent c580b1a52c
commit ee81e2cef8
13 changed files with 569 additions and 9 deletions

View File

@ -10,6 +10,9 @@ module.exports = {
firstDay: true,
'cypress/globals': true,
},
parserOptions: {
parser: '@typescript-eslint/parser',
},
plugins: [
'cypress',
],

View File

@ -36,10 +36,16 @@
@closed="handleClosed">
<!-- TODO: create a standard to allow multiple elements here? -->
<template v-if="fileInfo" #description>
<LegacyView v-for="view in views"
:key="view.cid"
:component="view"
:file-info="fileInfo" />
<div class="sidebar__description">
<SystemTags v-if="isSystemTagsEnabled"
v-show="showTags"
:file-id="fileInfo.id"
@has-tags="value => showTags = value" />
<LegacyView v-for="view in views"
:key="view.cid"
:component="view"
:file-info="fileInfo" />
</div>
</template>
<!-- Actions menu -->
@ -96,22 +102,25 @@ import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import FileInfo from '../services/FileInfo.js'
import SidebarTab from '../components/SidebarTab.vue'
import LegacyView from '../components/LegacyView.vue'
import SystemTags from '../../../systemtags/src/components/SystemTags.vue'
export default {
name: 'Sidebar',
components: {
LegacyView,
NcActionButton,
NcAppSidebar,
NcEmptyContent,
LegacyView,
SidebarTab,
SystemTags,
},
data() {
return {
// reactive state
Sidebar: OCA.Files.Sidebar.state,
showTags: false,
error: null,
loading: true,
fileInfo: null,
@ -410,9 +419,7 @@ export default {
* Toggle the tags selector
*/
toggleTags() {
if (OCA.SystemTags && OCA.SystemTags.View) {
OCA.SystemTags.View.toggle()
}
this.showTags = !this.showTags
},
/**
@ -505,7 +512,7 @@ export default {
</script>
<style lang="scss" scoped>
.app-sidebar {
&--has-preview::v-deep {
&--has-preview:deep {
.app-sidebar-header__figure {
background-size: cover;
}
@ -525,6 +532,12 @@ export default {
height: 100% !important;
}
:deep {
.app-sidebar-header__description {
margin: 0 16px 4px 16px !important;
}
}
.svg-icon {
::v-deep svg {
width: 20px;
@ -533,4 +546,11 @@ export default {
}
}
}
.sidebar__description {
display: flex;
flex-direction: column;
width: 100%;
gap: 8px 0;
}
</style>

View File

@ -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>

View File

@ -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()

View File

@ -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'))
}
}

View File

@ -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() ?? '',
},
})

View File

@ -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
}

View File

@ -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
}

BIN
dist/core-common.js vendored

Binary file not shown.

Binary file not shown.

BIN
dist/files-sidebar.js vendored

Binary file not shown.

Binary file not shown.

Binary file not shown.