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 597 additions and 15 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
}

4
dist/core-common.js vendored

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

View File

@ -1,3 +1,25 @@
/**
* @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/>.
*
*/
/**
* @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
*

File diff suppressed because one or more lines are too long