feat(NcCollectionList): merge nextcloud-vue-collections into nextcloud/vue

Signed-off-by: Maksim Sukharev <antreesy.web@gmail.com>
This commit is contained in:
Maksim Sukharev 2024-07-02 09:13:35 +02:00 коммит произвёл Ferdinand Thiessen
Родитель 196685c121
Коммит 0637422d32
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 45FAE7268762B400
6 изменённых файлов: 818 добавлений и 0 удалений

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

@ -0,0 +1,345 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<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('core', '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('core', '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" />
</ul>
</template>
<script>
import debounce from 'debounce'
import { t } from '@nextcloud/l10n'
import NcAvatar from '../NcAvatar/index.js'
import NcSelect from '../NcSelect/index.js'
import NcCollectionListItem from './NcCollectionListItem.vue'
import { state, actions } from './collectionstore.js'
const METHOD_CREATE_COLLECTION = 0
const METHOD_ADD_TO_COLLECTION = 1
const _debouncedSearch = debounce(
function(query, loading) {
if (query !== '') {
loading(true)
actions.search(query).then((collections) => {
this.searchCollections = collections
}).catch(e => {
console.error('Failed to search for collections', e)
}).finally(() => {
loading(false)
})
}
}, 500)
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: '',
},
isActive: {
type: Boolean,
default: true,
},
},
setup() {
return {
state,
}
},
data() {
return {
selectIsOpen: false,
generatingCodes: false,
codes: undefined,
value: null,
model: {},
searchCollections: [],
error: null,
isSelectOpen: false,
}
},
computed: {
collections() {
return this.state.collections.filter((collection) => {
return typeof collection.resources.find((resource) => resource && resource.id === '' + this.id && resource.type === this.type) !== 'undefined'
})
},
placeholder() {
return this.isSelectOpen ? t('core', 'Type to search for existing projects') : t('core', '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.findIndex((collection) => collection.id === this.searchCollections[index].id) === -1) {
options.push({
method: METHOD_ADD_TO_COLLECTION,
title: this.searchCollections[index].name,
collectionId: this.searchCollections[index].id,
})
}
}
return options
},
},
watch: {
type() {
if (this.isActive) {
actions.fetchCollectionsByResource({
resourceType: this.type,
resourceId: this.id,
})
}
},
id() {
if (this.isActive) {
actions.fetchCollectionsByResource({
resourceType: this.type,
resourceId: this.id,
})
}
},
isActive(isActive) {
if (isActive) {
actions.fetchCollectionsByResource({
resourceType: this.type,
resourceId: this.id,
})
}
},
},
mounted() {
actions.fetchCollectionsByResource({
resourceType: this.type,
resourceId: this.id,
})
},
methods: {
t,
select(selectedOption, id) {
if (selectedOption.method === METHOD_CREATE_COLLECTION) {
selectedOption.action().then((id) => {
actions.createCollection({
baseResourceType: this.type,
baseResourceId: this.id,
resourceType: selectedOption.type,
resourceId: id,
name: this.name,
}).catch((e) => {
this.setError(t('core', 'Failed to create a project'), e)
})
}).catch((e) => {
console.error('No resource selected', e)
})
}
if (selectedOption.method === METHOD_ADD_TO_COLLECTION) {
actions.addResourceToCollection({
collectionId: selectedOption.collectionId, resourceType: this.type, resourceId: this.id,
}).catch((e) => {
this.setError(t('core', 'Failed to add the item to the project'), e)
})
}
},
search(query, loading) {
_debouncedSearch.bind(this)(query, loading)
},
showSelect() {
this.selectIsOpen = true
this.$refs.select.$el.focus()
},
hideSelect() {
this.selectIsOpen = false
},
isVueComponent(object) {
return object._isVue
},
setError(error, e) {
console.error(error, e)
this.error = error
setTimeout(() => {
this.error = null
}, 5000)
},
},
}
</script>
<style lang="scss" scoped>
.collection-list * {
box-sizing: border-box;
}
.collection-list > li {
display: flex;
align-items: start;
gap: 12px;
& > .avatar {
margin-top: auto;
}
}
#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;
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,313 @@
<!--
- 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="{'shouldshake': error.rename }" @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">
<a v-for="resource in limitedResources(collection)"
:key="resource.type + '|' + resource.id"
:title="resource.name"
:href="resource.link"
:class="typeClass(resource)"><img :src="iconUrl(resource)"></a>
</div>
<span v-if="newName === null" class="sharingOptionsGroup">
<NcActions>
<NcActionButton icon="icon-info"
@click.prevent="toggleDetails">
{{ detailsOpen ? t('core', 'Hide details') : t('core', 'Show details') }}
</NcActionButton>
<NcActionButton icon="icon-rename"
@click.prevent="openRename">
{{ t('core', 'Rename project') }}
</NcActionButton>
</NcActions>
</span>
<transition name="fade">
<div v-if="error.rename" class="error">
{{ error.rename }}
</div>
</transition>
<transition name="fade">
<ul v-if="detailsOpen" class="resource-list-details">
<li v-for="resource in collection.resources"
:key="resource.type + '|' + resource.id"
:class="typeClass(resource)">
<a :href="resource.link"><img :src="iconUrl(resource)"><span class="resource-name">{{ resource.name || '' }}</span></a>
<span class="icon-close" @click="removeResource(collection, resource)" />
</li>
</ul>
</transition>
</li>
</template>
<script>
import { set } from 'vue'
import { t } from '@nextcloud/l10n'
import NcActions from '../NcActions/index.js'
import NcActionButton from '../NcActionButton/index.js'
import NcAvatar from '../NcAvatar/index.js'
import { actions } from './collectionstore.js'
export default {
name: 'NcCollectionListItem',
components: {
NcAvatar,
NcActions,
NcActionButton,
},
props: {
collection: {
type: Object,
default: null,
},
},
data() {
return {
detailsOpen: false,
newName: null,
error: {},
}
},
computed: {
getIcon() {
return (resource) => [resource.iconClass]
},
typeClass() {
return (resource) => 'resource-type-' + resource.type
},
limitedResources() {
return (collection) => collection.resources ? collection.resources.slice(0, 2) : []
},
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
},
hideDetails() {
this.detailsOpen = false
},
removeResource(collection, resource) {
actions.removeResource({
collectionId: collection.id, resourceType: resource.type, resourceId: resource.id,
})
},
openRename() {
this.newName = this.collection.name
},
renameCollection() {
if (this.newName === '') {
this.newName = null
return
}
actions.renameCollection({
collectionId: this.collection.id,
name: this.newName,
}).then((collection) => {
this.newName = null
}).catch((e) => {
this.$set(this.error, 'rename', t('core', 'Failed to rename the project'))
console.error(e)
setTimeout(() => {
set(this.error, 'rename', null)
}, 3000)
})
},
},
}
</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: 6px;
}
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;
}
}
}
}
.shouldshake {
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,71 @@
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
class CollectionService {
constructor() {
this.http = axios
}
listCollection(collectionId) {
return this.http.get(generateOcsUrl('collaboration/resources/collections/{collectionId}', { collectionId }))
}
renameCollection(collectionId, collectionName) {
return this.http.put(generateOcsUrl('collaboration/resources/collections/{collectionId}', { collectionId }), {
collectionName,
}).then(result => {
return result.data.ocs.data
})
}
getCollectionsByResource(resourceType, resourceId) {
return this.http.get(generateOcsUrl('collaboration/resources/{resourceType}/{resourceId}', { resourceType, resourceId }))
.then(result => {
return result.data.ocs.data
})
}
createCollection(resourceType, resourceId, name) {
return this.http.post(generateOcsUrl('collaboration/resources/{resourceType}/{resourceId}', { resourceType, resourceId }), {
name,
})
.then((response) => {
return response.data.ocs.data
})
}
addResource(collectionId, resourceType, resourceId) {
resourceId = '' + resourceId
return this.http.post(generateOcsUrl('collaboration/resources/collections/{collectionId}', { collectionId }), {
resourceType,
resourceId,
}).then((response) => {
return response.data.ocs.data
})
}
removeResource(collectionId, resourceType, resourceId) {
return this.http.delete(generateOcsUrl('collaboration/resources/collections/{collectionId}', { collectionId }), { params: { resourceType, resourceId } })
.then((response) => {
return response.data.ocs.data
})
}
search(query) {
return this.http.get(generateOcsUrl('collaboration/resources/collections/search/{query}', { query }))
.then((response) => {
return response.data.ocs.data
})
}
}
const service = new CollectionService()
export default service

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

@ -0,0 +1,82 @@
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { reactive, set } from 'vue'
import service from './collectionservice.js'
const state = reactive({
collections: [],
})
const mutations = {
addCollections(collections) {
set(state, 'collections', collections)
},
addCollection(collection) {
state.collections.push(collection)
},
removeCollection(collectionId) {
set(state, 'collections', state.collections.filter(item => item.id !== collectionId))
},
updateCollection(collection) {
const index = state.collections.findIndex((_item) => _item.id === collection.id)
if (index !== -1) {
set(state.collections, index, collection)
} else {
state.collections.push(collection)
}
},
}
const actions = {
fetchCollectionsByResource({ resourceType, resourceId }) {
return service.getCollectionsByResource(resourceType, resourceId).then((collections) => {
mutations.addCollections(collections)
return collections
})
},
createCollection({ baseResourceType, baseResourceId, resourceType, resourceId, name }) {
return service.createCollection(baseResourceType, baseResourceId, name).then((collection) => {
mutations.addCollection(collection)
actions.addResourceToCollection({
collectionId: collection.id,
resourceType,
resourceId,
})
})
},
renameCollection({ collectionId, name }) {
return service.renameCollection(collectionId, name).then((collection) => {
mutations.updateCollection(collection)
return collection
})
},
addResourceToCollection({ collectionId, resourceType, resourceId }) {
return service.addResource(collectionId, resourceType, resourceId).then((collection) => {
mutations.updateCollection(collection)
return collection
})
},
removeResource({ collectionId, resourceType, resourceId }) {
return service.removeResource(collectionId, resourceType, resourceId).then((collection) => {
if (collection.resources.length > 0) {
mutations.updateCollection(collection)
} else {
mutations.removeCollection(collection)
}
})
},
search(query) {
return service.search(query)
},
}
const store = {
actions,
state,
}
export default store
export { actions, state }

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

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

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

@ -36,6 +36,7 @@ export { default as NcBreadcrumb } from './NcBreadcrumb/index.js'
export { default as NcBreadcrumbs } from './NcBreadcrumbs/index.js'
export { default as NcButton } from './NcButton/index.js'
export { default as NcCheckboxRadioSwitch } from './NcCheckboxRadioSwitch/index.js'
export { default as NcCollectionList, NcCollectionListItem } 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'