refactor: Circle details

There is no list view for circles so we do not need AppContentDetails /
AppContentList.
Make the member list reactive and separate into smaller chunks

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen
2025-02-10 18:40:32 +01:00
parent f25e5d1327
commit a9cd6aa69e
7 changed files with 247 additions and 236 deletions

View File

@ -4,41 +4,20 @@
-->
<template>
<AppContent v-if="!circle">
<EmptyContent :name="t('contacts', 'Please select a team')">
<AppContent>
<EmptyContent v-if="!circle" :name="t('contacts', 'Please select a team')">
<template #icon>
<AccountGroup :size="20" />
</template>
</EmptyContent>
</AppContent>
<AppContent v-else-if="loading">
<EmptyContent class="empty-content" :name="t('contacts', 'Loading team…')">
<EmptyContent v-else-if="loading" class="empty-content" :name="t('contacts', 'Loading team…')">
<template #icon>
<IconLoading :size="20" />
</template>
</EmptyContent>
</AppContent>
<AppContent v-else :show-details.sync="showDetails">
<!-- main contacts details -->
<CircleDetails :circle="circle">
<!-- not a member -->
<template v-if="!circle.isMember">
<!-- Pending request validation -->
<EmptyContent v-if="circle.isPendingMember" :name="t('contacts', 'Your request to join this team is pending approval')">
<template #icon>
<IconLoading :size="20" />
</template>
</EmptyContent>
<EmptyContent v-else :name="t('contacts', 'You are not a member of {circle}', { circle: circle.displayName})">
<template #icon>
<AccountGroup :size="20" />
</template>
</EmptyContent>
</template>
</CircleDetails>
<CircleDetails v-else :circle="circle" />
</AppContent>
</template>
<script>
@ -76,7 +55,6 @@ export default {
data() {
return {
loadingList: false,
showDetails: false,
}
},
@ -130,11 +108,6 @@ export default {
this.loadingList = false
}
},
// Hide the circle details
hideDetails() {
this.showDetails = false
},
},
}
</script>

View File

@ -4,7 +4,7 @@
-->
<template>
<AppContentDetails>
<div class="circle-details">
<!-- contact header -->
<DetailsHeader>
<!-- avatar and upload photo -->
@ -72,7 +72,25 @@
@update:value="onDescriptionChangeDebounce" />
</section>
<section v-if="circle.isMember">
<!-- not a member -->
<template v-if="!circle.isMember">
<!-- Pending request validation -->
<NcEmptyContent v-if="circle.isPendingMember"
:name="t('contacts', 'Your request to join this team is pending approval')">
<template #icon>
<NcLoadingIcon :size="20" />
</template>
</NcEmptyContent>
<NcEmptyContent v-else
:name="t('contacts', 'You are not a member of {circle}', { circle: circle.displayName})">
<template #icon>
<IconAccountGroup :size="20" />
</template>
</NcEmptyContent>
</template>
<section v-else>
<ContentHeading>
{{ t('contacts', 'Team resources') }}
</ContentHeading>
@ -102,12 +120,7 @@
</div>
</section>
<section v-if="members.length > 0">
<ContentHeading>
{{ t('contacts', 'Team members') }}
</ContentHeading>
<MemberList :list="members" />
</section>
<MemberList v-if="members.length" :list="members" />
<Modal v-if="(circle.isOwner || circle.isAdmin) && !circle.isPersonal && showSettingsModal" @close="showSettingsModal=false">
<div class="circle-settings">
@ -161,11 +174,7 @@
</Button>
</div>
</Modal>
<section v-else>
<slot />
</section>
</AppContentDetails>
</div>
</template>
<script>
@ -177,10 +186,11 @@ import { showError } from '@nextcloud/dialogs'
import axios from '@nextcloud/axios'
import {
NcAppContentDetails as AppContentDetails,
NcAvatar as Avatar,
NcButton as Button,
NcEmptyContent,
NcListItem as ListItem,
NcLoadingIcon,
NcModal as Modal,
NcRichContenteditable as RichContenteditable,
} from '@nextcloud/vue'
@ -189,12 +199,13 @@ import Cog from 'vue-material-design-icons/Cog.vue'
import Login from 'vue-material-design-icons/Login.vue'
import Logout from 'vue-material-design-icons/Logout.vue'
import IconDelete from 'vue-material-design-icons/Delete.vue'
import IconAccountGroup from 'vue-material-design-icons/AccountGroup.vue'
import { CircleEdit, editCircle } from '../services/circles.ts'
import CircleActionsMixin from '../mixins/CircleActionsMixin.js'
import DetailsHeader from './DetailsHeader.vue'
import CircleConfigs from './CircleDetails/CircleConfigs.vue'
import MemberList from './MemberList.vue'
import MemberList from './MemberList/MemberList.vue'
import ContentHeading from './CircleDetails/ContentHeading.vue'
import CirclePasswordSettings from './CircleDetails/CirclePasswordSettings.vue'
@ -202,9 +213,7 @@ export default {
name: 'CircleDetails',
components: {
AppContentDetails,
Avatar,
MemberList,
Button,
CircleConfigs,
CirclePasswordSettings,
@ -212,10 +221,14 @@ export default {
DetailsHeader,
ListItem,
Cog,
IconAccountGroup,
IconDelete,
Login,
Logout,
MemberList,
Modal,
IconDelete,
NcEmptyContent,
NcLoadingIcon,
RichContenteditable,
},
@ -384,6 +397,10 @@ export default {
margin-left: 8px;
}
.circle-details {
padding-inline: 20px;
}
.circle-details-section {
&:not(:first-of-type) {
margin-top: 24px;

View File

@ -75,6 +75,7 @@ $top-padding: 50px;
.contact-header {
display: flex;
align-items: center;
flex-wrap: wrap;
padding: $top-padding 0 20px;
gap: $contact-details-row-gap;
&__quick-actions{

View File

@ -4,46 +4,50 @@
-->
<template>
<AppContentList v-if="!hasMembers" class="members-list">
<template v-if="loading">
<EmptyContent class="empty-content" :name="t('contacts', 'Loading members list …')">
<template #icon>
<IconLoading :size="20" />
</template>
</EmptyContent>
</template>
<template v-else-if="!circle.isMember">
<EmptyContent class="empty-content" :name="t('contacts', 'The list of members is only visible to members of this team')">
<template #icon>
<IconContact :size="20" />
</template>
</EmptyContent>
</template>
<template v-else>
<EmptyContent class="empty-content" :name="t('contacts', 'You currently have no access to the member list')">
<template #icon>
<IconContact :size="20" />
</template>
</EmptyContent>
</template>
</AppContentList>
<section class="member-list">
<ContentHeading>
{{ t('contacts', 'Team members') }}
</ContentHeading>
<AppContentList v-else>
<div class="members-list__new">
<Button v-if="circle.canManageMembers"
@click="onShowPicker(circle.id)">
<template #icon>
<IconLoading v-if="loading" />
<IconAdd :size="20" />
</template>
{{ t('contacts', 'Add members') }}
</Button>
<NcEmptyContent v-if="loading" class="empty-content" :name="t('contacts', 'Loading members list …')">
<template #icon>
<IconLoading :size="20" />
</template>
</NcEmptyContent>
<NcEmptyContent v-else-if="!circle.isMember"
class="empty-content"
:name="t('contacts', 'The list of members is only visible to members of this team')">
<template #icon>
<IconContact :size="20" />
</template>
</NcEmptyContent>
<NcEmptyContent v-else-if="!hasMembers"
class="empty-content"
:name="t('contacts', 'You currently have no access to the member list')">
<template #icon>
<IconContact :size="20" />
</template>
</NcEmptyContent>
<div v-else>
<div class="member-list__new">
<NcButton v-if="circle.canManageMembers"
@click="onShowPicker(circle.id)">
<template #icon>
<NcLoadingIcon v-if="loading" />
<IconAdd v-else :size="20" />
</template>
{{ t('contacts', 'Add members') }}
</NcButton>
</div>
<MemberListGroup v-for="group, index in groupedList"
:key="`member-list-group-${index}`"
v-bind="group" />
</div>
<MembersListItem v-for="member in filteredList"
:key="member.singleId"
:source="member" />
<!-- member picker -->
<EntityPicker v-if="showPicker"
:confirm-label="t('contacts', 'Add to {circle}', { circle: circle.displayName })"
@ -55,42 +59,43 @@
@close="resetPicker"
@search="onSearch"
@submit="onPickerPick" />
</AppContentList>
</section>
</template>
<script>
<script lang="ts">
import {
NcAppContentList as AppContentList,
NcButton as Button,
NcEmptyContent as EmptyContent,
NcLoadingIcon as IconLoading,
NcButton,
NcEmptyContent,
NcLoadingIcon,
isMobile,
} from '@nextcloud/vue'
import MembersListItem from './MembersList/MembersListItem.vue'
import EntityPicker from './EntityPicker/EntityPicker.vue'
import MemberListGroup from './MemberListGroup.vue'
import EntityPicker from '../EntityPicker/EntityPicker.vue'
import IconContact from 'vue-material-design-icons/AccountMultiple.vue'
import IconAdd from 'vue-material-design-icons/Plus.vue'
import RouterMixin from '../mixins/RouterMixin.js'
import RouterMixin from '../../mixins/RouterMixin.js'
import { getRecommendations, getSuggestions } from '../services/collaborationAutocompletion.js'
import { showError, showWarning } from '@nextcloud/dialogs'
import { subscribe } from '@nextcloud/event-bus'
import { SHARES_TYPES_MEMBER_MAP, CIRCLES_MEMBER_GROUPING } from '../models/constants.ts'
import { t } from '@nextcloud/l10n'
import { getRecommendations, getSuggestions } from '../../services/collaborationAutocompletion.js'
import { SHARES_TYPES_MEMBER_MAP, CIRCLES_MEMBER_GROUPING } from '../../models/constants'
import { defineComponent } from 'vue'
export default {
export default defineComponent({
name: 'MemberList',
components: {
AppContentList,
Button,
EntityPicker,
EmptyContent,
IconContact,
IconAdd,
IconLoading,
MembersListItem,
MemberListGroup,
NcButton,
NcEmptyContent,
NcLoadingIcon,
},
mixins: [isMobile, RouterMixin],
props: {
@ -129,54 +134,31 @@ export default {
return this.$store.getters.getCircle(this.selectedCircle)
},
groupedList() {
// Group per userType
return this.list.reduce(function(list, member) {
const userType = member.userType
list[userType] = list[userType] || []
list[userType].push(member)
return list
}, Object.create(null))
},
filteredList() {
const groupList = CIRCLES_MEMBER_GROUPING.map((group) => {
group.label = group.labelStandalone
return group
})
return Object.keys(this.groupedList)
// Object.keys returns string
.map(type => parseInt(type, 10))
// Map populated types to the group entry
.map(type => groupList.find(group => group.type === type))
// Removed undefined group
.filter(group => group !== undefined)
// Injecting headings
.map(group => {
return [{
heading: true,
...group,
}, ...(this.groupedList[group.type] || [])]
})
// Merging sub-arrays
.flat()
},
hasMembers() {
return this.filteredList.length > 0
return this.groupedList.length > 0
},
filteredPickerData() {
return this.pickerData.filter(entity => {
const type = SHARES_TYPES_MEMBER_MAP[entity.shareType]
const list = this.groupedList[type]
const list = this.list.filter(({ userType }) => userType === type)
if (list) {
return list.find(member => member.userId === entity.shareWith) === undefined
return list.find((member) => member.userId === entity.shareWith) === undefined
}
// If the type doesn't exists, there is no member of this type
return true
})
},
groupedList() {
return CIRCLES_MEMBER_GROUPING
.map(({ labelStandalone, type }) => ({
type,
label: labelStandalone,
members: [...this.list.filter(({ userType }) => userType === type)],
}))
.filter(({ members }) => members.length > 0)
},
},
mounted() {
@ -253,7 +235,7 @@ export default {
try {
const members = await this.$store.dispatch('addMembersToCircle', { circleId: this.pickerCircle, selection })
if (members.length !== selection.length) {
if (members.length < selection.length) {
showWarning(t('contacts', 'Some members could not be added'))
// TODO filter successful members and edit selection
this.pickerSelection = {}
@ -279,13 +261,14 @@ export default {
this.pickerSelection = {}
},
},
}
})
</script>
<style lang="scss" scoped>
.members-list {
.member-list {
// Make virtual scroller scrollable
max-height: 100%;
max-width: 900px;
overflow: auto;
&__new {

View File

@ -0,0 +1,61 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type Member from '../../models/member'
import MemberListItem from './MemberListItem.vue'
defineProps<{
label: string
type: number
members: Member[]
}>()
</script>
<template>
<div class="member-list-group">
<h4 :id="`member-list-group-${type}`"
class="member-list-group__heading">
{{ label }}
</h4>
<ul :aria-labelledby="`member-list-group-${type}`" class="member-list-group__list">
<MemberListItem v-for="member in members"
:key="member.singleId"
:source="member" />
</ul>
</div>
</template>
<style scoped lang="scss">
.member-list-group {
&__heading {
display: flex;
overflow: hidden;
flex-shrink: 0;
padding-top: 22px;
padding-inline-start: 8px;
margin-bottom: 0;
user-select: none;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 1.1rem;
color: var(--color-primary-element);
}
&__list {
-webkit-columns: 2;
-moz-columns: 2;
columns: 2;
}
}
@media screen and (max-width: 700px) {
.member-list-group__list {
-webkit-columns: 1;
-moz-columns: 1;
columns: 1;
}
}
</style>

View File

@ -4,118 +4,116 @@
-->
<template>
<span v-if="source.heading" class="members-list__heading">
{{ source.label }}
</span>
<ListItemIcon v-else
:id="source.singleId"
:avatar-size="avatarSize"
:display-name="source.displayName"
:icon-class="source.isUser ? null : 'icon-group-white'"
:is-no-user="!source.isUser"
:subname="levelName"
<NcListItem compact
:name="source.displayName"
class="members-list__item">
<!-- Accept invite -->
<template v-if="!loading && isPendingApproval && circle.canManageMembers">
<Actions>
<ActionButton @click="acceptMember">
<template #icon>
<IconCheck :size="20" />
</template>
{{ t('contacts', 'Accept membership request') }}
</ActionButton>
</Actions>
<Actions>
<ActionButton @click="deleteMember">
<template #icon>
<IconClose :size="20" />
</template>
{{ t('contacts', 'Reject membership request') }}
</ActionButton>
</Actions>
class="members-list-item">
<template #icon>
<NcAvatar disable-menu
:size="avatarSize"
:display-name="source.displayName"
:is-no-user="!source.isUser" />
</template>
<Actions v-else @close="onMenuClose">
<ActionText v-if="loading" icon="icon-loading-small">
<!-- Level -->
<template #subname>
{{ levelName }}
</template>
<!-- Accept invite -->
<template v-if="!loading && isPendingApproval && circle.canManageMembers" #extra-actions>
<NcButton @click="acceptMember">
<template #icon>
<IconCheck :size="20" />
</template>
{{ t('contacts', 'Accept membership request') }}
</NcButton>
<NcButton @click="deleteMember">
<template #icon>
<IconClose :size="20" />
</template>
{{ t('contacts', 'Reject membership request') }}
</NcButton>
</template>
<template v-else #actions>
<NcActionText v-if="loading" icon="icon-loading-small">
{{ t('contacts', 'Loading …') }}
</ActionText>
</NcActionText>
<!-- Normal menu -->
<template v-else>
<!-- Level picker -->
<template v-if="canChangeLevel">
<ActionText>
<NcActionText>
{{ t('contacts', 'Manage level') }}
<template #icon>
<ShieldCheck :size="16" />
<IconShieldCheck :size="16" />
</template>
</ActionText>
<ActionButton v-for="level in availableLevelsChange"
</NcActionText>
<NcActionButton v-for="level in availableLevelsChange"
:key="level"
icon=""
@click="changeLevel(level)">
{{ levelChangeLabel(level) }}
</ActionButton>
</NcActionButton>
<ActionSeparator />
<NcActionSeparator />
</template>
<!-- Leave or delete member from circle -->
<ActionButton v-if="isCurrentUser && !circle.isOwner" @click="deleteMember">
<NcActionButton v-if="isCurrentUser && !circle.isOwner" @click="deleteMember">
{{ t('contacts', 'Leave team') }}
<template #icon>
<ExitToApp :size="16" />
<IconExitToApp :size="16" />
</template>
</ActionButton>
<ActionButton v-else-if="canDelete" @click="deleteMember">
</NcActionButton>
<NcActionButton v-else-if="canDelete" @click="deleteMember">
<template #icon>
<IconDelete :size="20" />
</template>
{{ t('contacts', 'Remove member') }}
</ActionButton>
</NcActionButton>
</template>
</Actions>
</ListItemIcon>
</template>
</NcListItem>
</template>
<script>
import { CIRCLES_MEMBER_LEVELS, MemberLevels, MemberStatus } from '../../models/constants.ts'
import {
NcActions as Actions,
NcListItemIcon as ListItemIcon,
NcActionSeparator as ActionSeparator,
NcActionButton as ActionButton,
NcActionText as ActionText,
NcAvatar,
NcListItem,
NcActionSeparator,
NcActionButton,
NcActionText,
} from '@nextcloud/vue'
import IconDelete from 'vue-material-design-icons/Delete.vue'
import IconCheck from 'vue-material-design-icons/Check.vue'
import IconClose from 'vue-material-design-icons/Close.vue'
import ExitToApp from 'vue-material-design-icons/ExitToApp.vue'
import ShieldCheck from 'vue-material-design-icons/ShieldCheck.vue'
import IconDelete from 'vue-material-design-icons/Delete.vue'
import IconExitToApp from 'vue-material-design-icons/ExitToApp.vue'
import IconShieldCheck from 'vue-material-design-icons/ShieldCheck.vue'
import { changeMemberLevel } from '../../services/circles.ts'
import { showError } from '@nextcloud/dialogs'
import RouterMixin from '../../mixins/RouterMixin.js'
export default {
name: 'MembersListItem',
name: 'MemberListItem',
components: {
Actions,
ActionButton,
ActionSeparator,
ActionText,
IconDelete,
IconCheck,
IconClose,
ExitToApp,
ListItemIcon,
ShieldCheck,
IconDelete,
IconExitToApp,
IconShieldCheck,
NcListItem,
NcActionButton,
NcActionSeparator,
NcActionText,
NcAvatar,
},
mixins: [RouterMixin],
props: {
@ -342,32 +340,10 @@ export default {
},
}
</script>
<style lang="scss">
.members-list__heading {
display: flex;
overflow: hidden;
flex-shrink: 0;
padding-top: 22px;
padding-left: 8px;
user-select: none;
white-space: nowrap;
text-overflow: ellipsis;
pointer-events: none;
color: var(--color-primary-element);
line-height: 22px;
}
.members-list__item {
padding: 8px;
<style lang="scss">
.members-list-item {
user-select: none;
box-sizing: border-box;
margin-bottom: 8px;
border-radius: var(--border-radius-rounded);
&:focus,
&:hover {
background-color: var(--color-background-hover);
}
}
</style>

View File

@ -1,14 +1,14 @@
{
"compilerOptions": {
"module": "ES6",
"moduleResolution": "node",
"target": "ES6",
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ESNext",
"strictNullChecks": true,
"sourceMap": true,
"allowSyntheticDefaultImports": true,
"declaration": true,
},
"include": [
"./src/**/*"
]
"include": [
"./src/**/*"
]
}