Merge pull request #5770 from nextcloud-libraries/backport/5757/next

[next] feat: migrate `NcCollectionList` component from `nextcloud-vue-collections`
This commit is contained in:
Ferdinand Thiessen 2024-07-04 18:06:40 +02:00 коммит произвёл GitHub
Родитель 2e3c392633 3c1412f3da
Коммит a7a1c028a5
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
8 изменённых файлов: 905 добавлений и 0 удалений

Просмотреть файл

@ -27,6 +27,9 @@ msgstr ""
msgid "Activities"
msgstr ""
msgid "Add to a project"
msgstr ""
msgid "Animals & Nature"
msgstr ""
@ -102,6 +105,9 @@ msgstr ""
msgid "Confirm changes"
msgstr ""
msgid "Connect items to a project to make them easier to find"
msgstr ""
msgid "Custom"
msgstr ""
@ -136,6 +142,15 @@ msgstr ""
msgid "External documentation for {name}"
msgstr ""
msgid "Failed to add the item to the project"
msgstr ""
msgid "Failed to create a project"
msgstr ""
msgid "Failed to rename the project"
msgstr ""
msgid "Favorite"
msgstr ""
@ -161,6 +176,9 @@ msgstr ""
msgid "Gold"
msgstr ""
msgid "Hide details"
msgstr ""
msgid "Hide password"
msgstr ""
@ -295,6 +313,9 @@ msgstr ""
msgid "Related team resources"
msgstr ""
msgid "Rename project"
msgstr ""
#. TRANSLATORS: A color name for RGB(191, 103, 139)
msgid "Rosy brown"
msgstr ""
@ -337,6 +358,9 @@ msgstr ""
msgid "Settings navigation"
msgstr ""
msgid "Show details"
msgstr ""
msgid "Show password"
msgstr ""
@ -370,6 +394,9 @@ msgstr ""
msgid "Travel & Places"
msgstr ""
msgid "Type to search for existing projects"
msgstr ""
msgid "Type to search time zone"
msgstr ""

Просмотреть файл

@ -0,0 +1,373 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<docs>
Provides a Vue standalone component for Nextcloud Projects feature introduced in Nextcloud 16. Replaces deprecated `nextcloud-vue-collections` library.
Projects feature is deprecated since Nextcloud 25, and superseded by Related resources. See [NcRelatedResourcesPanel](#/Components/NcRelatedResourcesPanel) documentation for more information.
### Usage
To enable feature in Nextcloud, run following command:
```sh
occ config:system:set --value true 'projects.enabled'
```
</docs>
<template>
<ul v-if="collections && type && id" id="collection-list" class="collection-list">
<li @click="showSelect">
<div class="avatar">
<span class="icon-projects" />
</div>
<div id="collection-select-container">
<NcSelect ref="select"
v-model="value"
:aria-label-combobox="t('Add to a project')"
:options="options"
:placeholder="placeholder"
label="title"
:limit="5"
@close="isSelectOpen = false"
@open="isSelectOpen = true"
@option:selected="select"
@search="search">
<template #selected-option="option">
<span class="option__desc">
<span class="option__title">{{ option.title }}</span>
</span>
</template>
<template #option="option">
<span class="option__wrapper">
<span v-if="option.class" :class="option.class" class="avatar" />
<NcAvatar v-else-if="option.method !== 2" allow-placeholder :display-name="option.title" />
<span class="option__title">{{ option.title }}</span>
</span>
</template>
<p class="hint">
{{ t('Connect items to a project to make them easier to find') }}
</p>
</NcSelect>
</div>
</li>
<transition name="fade">
<li v-if="error" class="error">
{{ error }}
</li>
</transition>
<NcCollectionListItem v-for="collection in collections"
:key="collection.id"
:collection="collection"
:error="collectionsError[collection.id]"
@rename-collection="renameCollectionFromItem"
@remove-resource="removeResourceFromCollection" />
</ul>
</template>
<script>
import debounce from 'debounce'
import { ref } from 'vue'
import { t } from '../../l10n.js'
import NcAvatar from '../NcAvatar/index.js'
import NcSelect from '../NcSelect/index.js'
import NcCollectionListItem from './NcCollectionListItem.vue'
import { useCollections } from './useCollections.js'
import { searchService } from './service.ts'
const METHOD_CREATE_COLLECTION = 0
const METHOD_ADD_TO_COLLECTION = 1
export default {
name: 'NcCollectionList',
components: {
NcCollectionListItem,
NcAvatar,
NcSelect,
},
props: {
/**
* Resource type identifier
*/
type: {
type: String,
default: null,
},
/**
* Unique id of the resource
*/
id: {
type: String,
default: null,
},
/**
* Name of the resource
*/
name: {
type: String,
default: '',
},
/**
* Whether the component is active (to start fetch resources)
*/
isActive: {
type: Boolean,
default: true,
},
},
setup() {
const {
storedCollections,
fetchCollectionsByResource,
createCollection,
addResourceToCollection,
removeResourceFromCollection,
renameCollection,
} = useCollections()
const searchCollections = ref([])
const search = debounce(function(query, loading) {
if (query !== '') {
loading(true)
searchService(query).then(collections => {
searchCollections.value = collections
}).catch(e => {
console.error('Failed to search for collections', e)
}).finally(() => {
loading(false)
})
}
}, 500)
return {
storedCollections,
fetchCollectionsByResource,
createCollection,
addResourceToCollection,
removeResourceFromCollection,
renameCollection,
searchCollections,
search,
}
},
data() {
return {
selectIsOpen: false,
generatingCodes: false,
codes: undefined,
value: null,
model: {},
collectionsError: {},
error: null,
isSelectOpen: false,
}
},
computed: {
collections() {
return this.storedCollections.filter(collection => collection.resources
.some(resource => resource && resource.id === String(this.id) && resource.type === this.type),
)
},
placeholder() {
return this.isSelectOpen
? t('Type to search for existing projects')
: t('Add to a project')
},
options() {
const options = []
window.OCP.Collaboration.getTypes().sort().forEach(type => {
options.push({
method: METHOD_CREATE_COLLECTION,
type,
title: window.OCP.Collaboration.getLabel(type),
class: window.OCP.Collaboration.getIcon(type),
action: () => window.OCP.Collaboration.trigger(type),
})
})
for (const index in this.searchCollections) {
if (!this.collections.find(collection => collection.id === this.searchCollections[index].id)) {
options.push({
method: METHOD_ADD_TO_COLLECTION,
title: this.searchCollections[index].name,
collectionId: this.searchCollections[index].id,
})
}
}
return options
},
resourceIdentifier() {
return {
resourceType: this.type,
resourceId: this.id,
isActive: this.isActive,
}
},
},
watch: {
resourceIdentifier: {
deep: true,
immediate: true,
handler(resourceIdentifier) {
if (!resourceIdentifier.isActive || !resourceIdentifier.resourceId || !resourceIdentifier.resourceType) {
return
}
this.fetchCollectionsByResource(resourceIdentifier)
},
},
},
methods: {
t,
select(selectedOption) {
if (selectedOption.method === METHOD_CREATE_COLLECTION) {
selectedOption.action().then(resourceId => {
this.createCollection({
baseResourceType: this.type,
baseResourceId: this.id,
resourceType: selectedOption.type,
resourceId,
name: this.name,
}).catch((e) => {
this.setError(t('Failed to create a project'), e)
})
}).catch((e) => {
console.error('No resource selected', e)
})
}
if (selectedOption.method === METHOD_ADD_TO_COLLECTION) {
this.addResourceToCollection({
collectionId: selectedOption.collectionId, resourceType: this.type, resourceId: this.id,
}).catch((e) => {
this.setError(t('Failed to add the item to the project'), e)
})
}
this.value = null
},
showSelect() {
this.selectIsOpen = true
this.$refs.select.$el.focus()
},
setError(error, e) {
console.error(error, e)
this.error = error
setTimeout(() => {
this.error = null
}, 5000)
},
renameCollectionFromItem({ collectionId, name }) {
this.renameCollection({ collectionId, name })
.catch((e) => {
console.error(t('Failed to rename the project'), e)
this.collectionsError[collectionId] = t('Failed to rename the project')
setTimeout(() => {
this.collectionsError[collectionId] = null
}, 5000)
})
},
},
}
</script>
<style lang="scss" scoped>
.collection-list * {
box-sizing: border-box;
}
.collection-list > li {
display: flex;
align-items: center;
gap: 12px;
& > .avatar {
margin-top: 0;
}
}
#collection-select-container {
display: flex;
flex-direction: column;
}
.v-select {
// NcAvatar in the dropdown
span.avatar {
display: block;
padding: 16px;
opacity: .7;
background-repeat: no-repeat;
background-position: center;
&:hover {
opacity: 1;
}
}
}
p.hint {
z-index: 1;
// fix alignment
margin-top: -16px;
padding: 8px 8px;
color: var(--color-text-maxcontrast);
line-height: normal;
}
div.avatar {
width: 32px;
height: 32px;
margin: 0;
padding: 8px;
background-color: var(--color-background-dark);
margin-top: 30px;
}
/** TODO provide white icon in core */
.icon-projects {
display: block;
padding: 8px;
background-repeat: no-repeat;
background-position: center;
}
.option__wrapper {
display: flex;
.avatar {
display: block;
width: 32px;
height: 32px;
background-color: var(--color-background-darker) !important;
}
.option__title {
padding: 4px;
}
}
.fade-enter-active, .fade-leave-active {
transition: opacity .5s;
}
.fade-enter, .fade-leave-to {
opacity: 0;
}
</style>

Просмотреть файл

@ -0,0 +1,326 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<li class="collection-list-item">
<NcAvatar :display-name="collection.name" allow-placeholder class="collection-avatar" />
<span v-if="newName === null"
class="collection-item-name"
title=""
@click="showDetails">{{ collection.name }}</span>
<form v-else :class="{'should-shake': error }" @submit.prevent="renameCollection">
<input v-model="newName"
type="text"
autocomplete="off"
autocapitalize="off">
<input type="submit" value="" class="icon-confirm">
</form>
<div v-if="!detailsOpen && newName === null" class="linked-icons">
<component :is="getComponent(resource).component"
v-for="resource in resources.slice(0, 2)"
:key="resource.type + '|' + resource.id"
:title="resource.name"
:to="getComponent(resource).to"
:href="getComponent(resource).href"
:class="typeClass(resource)">
<img :src="iconUrl(resource)" :alt="resource.name">
</component>
</div>
<span v-if="newName === null" class="sharingOptionsGroup">
<NcActions>
<NcActionButton icon="icon-info"
@click.prevent="toggleDetails">
{{ detailsOpen ? t('Hide details') : t('Show details') }}
</NcActionButton>
<NcActionButton icon="icon-rename"
@click.prevent="openRename">
{{ t('Rename project') }}
</NcActionButton>
</NcActions>
</span>
<transition name="fade">
<div v-if="error" class="error">
{{ error }}
</div>
</transition>
<transition name="fade">
<ul v-if="detailsOpen" class="resource-list-details">
<li v-for="resource in resources"
:key="resource.type + '|' + resource.id"
:class="typeClass(resource)">
<component :is="getComponent(resource).component"
:to="getComponent(resource).to"
:href="getComponent(resource).href">
<img :src="iconUrl(resource)" :alt="resource.name">
<span class="resource-name">{{ resource.name || '' }}</span>
</component>
<span class="icon-close" @click="removeResource(collection, resource)" />
</li>
</ul>
</transition>
</li>
</template>
<script>
import { t } from '../../l10n.js'
import { getRoute } from '../NcRichText/autolink.ts'
import NcActions from '../NcActions/index.js'
import NcActionButton from '../NcActionButton/index.js'
import NcAvatar from '../NcAvatar/index.js'
export default {
name: 'NcCollectionListItem',
components: {
NcAvatar,
NcActions,
NcActionButton,
},
props: {
collection: {
type: Object,
default: null,
},
error: {
type: String,
default: undefined,
},
},
emits: ['remove-resource', 'rename-collection'],
data() {
return {
detailsOpen: false,
newName: null,
}
},
computed: {
getIcon() {
return (resource) => [resource.iconClass]
},
typeClass() {
return (resource) => 'resource-type-' + resource.type
},
resources() {
// invalid resources come from server as empty array ([]) and not an object
return this.collection.resources?.filter(resource => !Array.isArray(resource)) ?? []
},
getComponent() {
return (resource) => {
const route = getRoute(this.$router, resource.link)
return route
? { component: 'router-link', to: route, href: undefined }
: { component: 'a', to: undefined, href: resource.link }
}
},
iconUrl() {
return (resource) => {
if (resource.mimetype) {
return OC.MimeType.getIconUrl(resource.mimetype)
}
if (resource.iconUrl) {
return resource.iconUrl
}
return ''
}
},
},
methods: {
t,
toggleDetails() {
this.detailsOpen = !this.detailsOpen
},
showDetails() {
this.detailsOpen = true
},
removeResource(collection, resource) {
this.$emit('remove-resource', {
collectionId: collection.id,
resourceType: resource.type,
resourceId: resource.id,
})
},
openRename() {
this.newName = this.collection.name
},
renameCollection() {
if (this.newName) {
this.$emit('rename-collection', {
collectionId: this.collection.id,
name: this.newName,
})
}
this.newName = null
},
},
}
</script>
<style scoped lang="scss">
.fade-enter-active, .fade-leave-active {
transition: opacity .3s ease;
}
.fade-enter, .fade-leave-to
/* .fade-leave-active below version 2.1.8 */
{
opacity: 0;
}
.linked-icons {
display: flex;
img {
padding: 12px;
height: 44px;
display: block;
background-repeat: no-repeat;
background-position: center;
opacity: 0.7;
&:hover {
opacity: 1;
}
}
}
.popovermenu {
display: none;
&.open {
display: block;
}
}
li.collection-list-item {
flex-wrap: wrap;
height: auto;
cursor: pointer;
margin-bottom: 0 !important;
.collection-avatar {
margin-top: 0;
}
form, .collection-item-name {
flex-basis: 10%;
flex-grow: 1;
display: flex;
}
.collection-item-name {
padding: 12px 9px;
}
input {
margin-top: 4px;
border-color: var(--color-border-maxcontrast);
&[type=text] {
flex-grow: 1;
}
}
.error {
flex-basis: 100%;
width: 100%;
}
.resource-list-details {
flex-basis: 100%;
width: 100%;
li {
display: flex;
margin-left: 44px;
border-radius: 3px;
cursor: pointer;
&:hover {
background-color: var(--color-background-dark);
}
a {
flex-grow: 1;
padding: 3px;
max-width: calc(100% - 30px);
display: flex;
}
}
span {
display: inline-block;
vertical-align: top;
margin-right: 10px;
}
span.resource-name {
text-overflow: ellipsis;
overflow: hidden;
position: relative;
vertical-align: top;
white-space: nowrap;
flex-grow: 1;
padding: 4px;
}
img {
width: 24px;
height: 24px;
}
.icon-close {
opacity: .7;
&:hover, &:focus {
opacity: 1;
}
}
}
}
.should-shake {
animation: shake 0.6s 1 linear;
}
@keyframes shake {
0% {
transform: translate(15px);
}
20% {
transform: translate(-15px);
}
40% {
transform: translate(7px);
}
60% {
transform: translate(-7px);
}
80% {
transform: translate(3px);
}
100% {
transform: translate(0px);
}
}
</style>

Просмотреть файл

@ -0,0 +1,5 @@
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export { default } from './NcCollectionList.vue'

Просмотреть файл

@ -0,0 +1,80 @@
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import axios, { type AxiosResponse } from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
import type { OCSResponse } from '@nextcloud/typings/ocs'
/**
* Extracts the OCS data from a response
* @param response OCS response
*/
function extractOcsData(response: AxiosResponse<OCSResponse>) {
return response.data.ocs.data
}
/**
* Lists all collections
* @param collectionId Collection ID
*/
export function listCollectionService(collectionId: number) {
return axios.get(generateOcsUrl('collaboration/resources/collections/{collectionId}', { collectionId })).then(extractOcsData)
}
/**
* Renames a collection
* @param collectionId Collection ID
* @param collectionName New collection name
*/
export function renameCollectionService(collectionId: number, collectionName: string) {
return axios.put(generateOcsUrl('collaboration/resources/collections/{collectionId}', { collectionId }), { collectionName }).then(extractOcsData)
}
/**
* Lists all collections for a resource
* @param resourceType Resource type
* @param resourceId Resource ID
*/
export function getCollectionsByResourceService(resourceType: string, resourceId: string) {
return axios.get(generateOcsUrl('collaboration/resources/{resourceType}/{resourceId}', { resourceType, resourceId })).then(extractOcsData)
}
/**
* Creates a collection
* @param resourceType Resource type
* @param resourceId Resource ID
* @param name Collection name
*/
export function createCollectionService(resourceType: string, resourceId: string, name: string) {
return axios.post(generateOcsUrl('collaboration/resources/{resourceType}/{resourceId}', { resourceType, resourceId }), { name }).then(extractOcsData)
}
/**
* Adds a resource to a collection
* @param collectionId Collection ID
* @param resourceType Resource type
* @param resourceId Resource ID
*/
export function addResourceService(collectionId: number, resourceType: string, resourceId: string) {
return axios.post(generateOcsUrl('collaboration/resources/collections/{collectionId}', { collectionId }), { resourceType, resourceId }).then(extractOcsData)
}
/**
* Removes a resource from a collection
* @param collectionId Collection ID
* @param resourceType Resource type
* @param resourceId Resource ID
*/
export function removeResourceService(collectionId: number, resourceType: string, resourceId: string) {
return axios.delete(generateOcsUrl('collaboration/resources/collections/{collectionId}', { collectionId }), { params: { resourceType, resourceId } }).then(extractOcsData)
}
/**
* Searches for collections
* @param query Search query
*/
export function searchService(query: string) {
return axios.get(generateOcsUrl('collaboration/resources/collections/search/{query}', { query })).then(extractOcsData)
}

Просмотреть файл

@ -0,0 +1,92 @@
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { ref } from 'vue'
import {
renameCollectionService,
getCollectionsByResourceService,
createCollectionService,
addResourceService,
removeResourceService,
} from './service.ts'
/**
* Use collections composable
*/
export function useCollections() {
// State
const storedCollections = ref([])
// Mutations
const addCollections = (collections) => {
storedCollections.value = collections
}
const addCollection = (collection) => {
storedCollections.value.push(collection)
}
const removeCollection = (collectionId) => {
storedCollections.value = storedCollections.value.filter(item => item.id !== collectionId)
}
const updateCollection = (collection) => {
const index = storedCollections.value.findIndex(item => item.id === collection.id)
if (index !== -1) {
storedCollections.value[index] = collection
} else {
addCollection(collection)
}
}
// Actions
const fetchCollectionsByResource = async ({ resourceType, resourceId }) => {
const collections = await getCollectionsByResourceService(resourceType, resourceId)
addCollections(collections)
}
const createCollection = async ({ baseResourceType, baseResourceId, resourceType, resourceId, name }) => {
const collection = await createCollectionService(baseResourceType, baseResourceId, name)
addCollection(collection)
await addResourceToCollection({
collectionId: collection.id,
resourceType,
resourceId,
})
}
const renameCollection = async ({ collectionId, name }) => {
const collection = await renameCollectionService(collectionId, name)
updateCollection(collection)
}
const addResourceToCollection = async ({ collectionId, resourceType, resourceId }) => {
const collection = await addResourceService(collectionId, resourceType, String(resourceId))
updateCollection(collection)
}
const removeResourceFromCollection = async ({ collectionId, resourceType, resourceId }) => {
const collection = await removeResourceService(collectionId, resourceType, String(resourceId))
if (collection.resources.length > 0) {
updateCollection(collection)
} else {
removeCollection(collectionId)
}
}
return {
storedCollections,
fetchCollectionsByResource,
createCollection,
renameCollection,
addResourceToCollection,
removeResourceFromCollection,
}
}

Просмотреть файл

@ -37,6 +37,7 @@ export { default as NcBreadcrumbs } from './NcBreadcrumbs/index.js'
export { default as NcButton } from './NcButton/index'
export { default as NcCheckboxRadioSwitch } from './NcCheckboxRadioSwitch/index.js'
export { default as NcChip } from './NcChip/index'
export { default as NcCollectionList } from './NcCollectionList/index.js'
export { default as NcColorPicker } from './NcColorPicker/index.js'
export { default as NcContent } from './NcContent/index.js'
export { default as NcCounterBubble } from './NcCounterBubble/index.js'

Просмотреть файл

@ -120,6 +120,7 @@ module.exports = async () => {
'src/components/NcAppSidebar*/*.vue',
'src/components/NcBreadcrumb*/*.vue',
'src/components/NcCheckboxRadioSwitch/NcCheckboxContent.vue',
'src/components/NcCollectionList/!(NcCollectionList).vue',
'src/components/NcContent/*.vue',
'src/components/NcDashboard*/*.vue',
'src/components/NcDialog*/*.vue',