simplify <List> API and move logic outside of it

This commit is contained in:
Henry Jameson 2026-06-08 05:27:47 +03:00
commit a0d5decc49
15 changed files with 209 additions and 510 deletions

View file

@ -1,5 +1,3 @@
import { isEmpty } from 'lodash'
import Checkbox from 'src/components/checkbox/checkbox.vue'
const List = {
@ -10,7 +8,7 @@ const List = {
},
items: {
type: Array,
default: () => [],
default: [],
},
fetchFunction: {
type: Function,
@ -40,28 +38,37 @@ const List = {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: true,
},
bottomedOut: {
type: Boolean,
default: false,
},
error: {
type: String,
default: null,
},
},
emits: ['fetchRequested'],
components: {
Checkbox,
},
data() {
return {
loading: false,
bottomedOut: false,
error: false,
dynamicItems: this.itemsFunction ? [] : null,
selected: [],
}
},
computed: {
allKeys() {
return this.actualItems.map(this.getKey)
return this.items.map(this.getKey)
},
filteredSelected() {
return this.allKeys.filter((key) => this.selected.indexOf(key) !== -1)
},
allSelected() {
return this.filteredSelected.length === this.actualItems.length
return this.filteredSelected.length === this.items.length
},
noneSelected() {
return this.filteredSelected.length === 0
@ -69,54 +76,26 @@ const List = {
someSelected() {
return !this.allSelected && !this.noneSelected
},
actualItems() {
return this.dynamicItems || this.actualItems
},
},
created() {
if (this.fetchFunction) {
window.addEventListener('scroll', this.scrollLoad)
window.addEventListener('scroll', this.scrollLoad)
if (this.dynamicItems.length === 0) {
this.fetchEntries()
}
if (this.items.length === 0) {
this.fetchEntries()
}
},
unmounted() {
window.removeEventListener('scroll', this.scrollLoad)
},
methods: {
// Entries is not a computed because computed can't track the dynamic
// selector for changes and won't trigger after fetch.
updateEntries(newEntries) {
this.dynamicItems = this.itemsFunction(newEntries)
},
fetchEntries() {
if (!this.loading) {
this.loading = true
this.error = false
this.fetchFunction()
.then((newEntries) => {
this.loading = false
this.bottomedOut = isEmpty(newEntries)
return newEntries
})
.catch((error) => {
this.loading = false
this.error = error
})
.finally((newEntries) => {
this.updateEntries(newEntries)
})
}
this.$emit('fetchRequested')
},
scrollLoad(e) {
if (this.fetchFunction) {
const bodyBRect = document.body.getBoundingClientRect()
const height = Math.max(bodyBRect.height, -bodyBRect.y)
if (
this.loading === false &&
this.bottomedOut === false &&
this.$el.offsetHeight > 0 &&
window.innerHeight + window.pageYOffset >= height - 750
) {
@ -128,7 +107,6 @@ const List = {
return this.filteredSelected.indexOf(this.getKey(item)) !== -1
},
toggle(checked, item) {
console.log('TOGGLE', checked, item)
const key = this.getKey(item)
const oldChecked = this.isSelected(key)
if (checked !== oldChecked) {
@ -138,6 +116,7 @@ const List = {
this.selected.splice(this.selected.indexOf(key), 1)
}
}
this.$emit('selected', this.selected)
},
toggleAll(value) {
if (value) {
@ -145,6 +124,7 @@ const List = {
} else {
this.selected = []
}
this.$emit('selected', this.selected)
},
},
}

View file

@ -28,7 +28,7 @@
role="list"
>
<div
v-for="item in actualItems"
v-for="item in items"
:key="getKey(item)"
class="list-item"
:class="[getClass(item), nonInteractive ? '-non-interactive' : '']"
@ -50,7 +50,7 @@
/>
</div>
<div
v-if="actualItems.length === 0 && !!$slots.empty"
v-if="items.length === 0 && !!$slots.empty"
class="list-empty-content faint"
>
<slot name="empty" />

View file

@ -1,87 +0,0 @@
import SelectableList from 'src/components/selectable_list/selectable_list.vue'
const PageList = {
components: {
SelectableList,
},
props: {
/**
* only make the checkbox clickable to toggle, not the whole area
*/
boxOnly: {
type: Boolean,
default: false,
},
/**
* how many entries to fetch at once
*/
pageSize: {
type: Number,
default: 50,
},
/**
* the function/callback used to fetch new entries (one page)
*/
fetchPage: {
type: Function,
default: async () => [],
},
/**
* wether or not this is a single page list (so it won't allow fetching more pages)
*/
singlePage: {
type: Boolean,
default: false,
},
},
data() {
return {
pageIndex: 1,
items: [],
canLoadMore: true,
isLoading: false,
}
},
methods: {
/**
* reset and load first page
*/
reset() {
this.canLoadMore = true
this.pageIndex = 1
this.items = []
this.isLoading = false
this.loadMore() // load one page
},
/**
* load another page
*/
loadMore() {
if (!this.isLoading && this.canLoadMore) {
this.isLoading = true
console.log('is loading = true')
this.fetchPage(this.$store, {
page: this.pageIndex++,
pageSize: this.pageSize,
}).then((items) => {
this.items = [...this.items, ...items]
this.isLoading = false
})
}
},
/**
* get currently selected elements
* @returns {Array}
*/
getSelected() {
return this.$refs.list.selected
},
},
/**
* auto-load first page when mounted
*/
mounted() {
this.loadMore()
},
}
export default PageList

View file

@ -1,83 +0,0 @@
<template>
<div class="page-list">
<SelectableList
ref="list"
:box-only="true"
:get-key="i => i"
:items="items"
>
<template #header="slotProps">
<slot
name="header"
v-bind="slotProps"
/>
</template>
<template #item="slotProps">
<slot
name="item"
v-bind="slotProps"
/>
</template>
<template #load="slotProps">
<slot
v-if="isLoading"
name="load"
v-bind="slotProps"
/>
</template>
<template #empty="slotProps">
<slot
v-if="items.length == 0 && !isLoading"
name="empty"
v-bind="slotProps"
/>
</template>
</SelectableList>
<div v-if="!singlePage">
<button
v-if="canLoadMore"
class="button button-default btn"
type="button"
@click="loadMore"
>
{{ $t('page_list.load_more') }}
</button>
</div>
</div>
</template>
<script src="./page_list.js"></script>
<style lang="scss">
.page-list {
--__line-height: 1.5em;
--__horizontal-gap: 0.75em;
--__vertical-gap: 0.5em;
&-item-inner {
display: flex;
align-items: center;
> * {
min-width: 0;
}
}
&-header {
display: flex;
align-items: center;
padding: var(--__vertical-gap) var(--__horizontal-gap);
border-bottom: 1px solid;
border-bottom-color: var(--border);
&-actions {
flex: 1;
}
}
&-checkbox-wrapper {
padding-right: var(--__horizontal-gap);
flex: none;
}
}
</style>

View file

@ -1,70 +0,0 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import List from 'src/components/list/list.vue'
const SelectableList = {
components: {
List,
Checkbox,
},
props: {
boxOnly: {
type: Boolean,
default: false,
},
items: {
type: Array,
default: () => [],
},
getKey: {
type: Function,
default: (item) => item.id,
},
},
data() {
return {
selected: [],
}
},
computed: {
allKeys() {
return this.items.map(this.getKey)
},
filteredSelected() {
return this.allKeys.filter((key) => this.selected.indexOf(key) !== -1)
},
allSelected() {
return this.filteredSelected.length === this.items.length
},
noneSelected() {
return this.filteredSelected.length === 0
},
someSelected() {
return !this.allSelected && !this.noneSelected
},
},
methods: {
isSelected(item) {
return this.filteredSelected.indexOf(this.getKey(item)) !== -1
},
toggle(checked, item) {
const key = this.getKey(item)
const oldChecked = this.isSelected(key)
if (checked !== oldChecked) {
if (checked) {
this.selected.push(key)
} else {
this.selected.splice(this.selected.indexOf(key), 1)
}
}
},
toggleAll(value) {
if (value) {
this.selected = this.allKeys.slice(0)
} else {
this.selected = []
}
},
},
}
export default SelectableList

View file

@ -1,119 +0,0 @@
<template>
<div class="selectable-list">
<div
v-if="items.length > 0"
class="selectable-list-header"
>
<div class="selectable-list-checkbox-wrapper">
<Checkbox
:model-value="allSelected"
:indeterminate="someSelected"
@update:model-value="toggleAll"
>
{{ $t('selectable_list.select_all') }}
</Checkbox>
</div>
<div class="selectable-list-header-actions">
<slot
name="header"
:selected="filteredSelected"
/>
</div>
</div>
<List
:items="items"
:get-key="getKey"
:get-class="item => isSelected(item) ? '-active' : ''"
>
<template #item="{item}">
<div
v-if="!boxOnly"
class="selectable-list-item-inner"
:class="{ 'selectable-list-item-selected-inner': isSelected(item) }"
@click.stop="toggle(!isSelected(item), item)"
>
<div class="selectable-list-checkbox-wrapper">
<Checkbox
:model-value="isSelected(item)"
@update:model-value="checked => toggle(checked, item)"
@click.stop
/>
</div>
<slot
name="item"
:item="item"
/>
</div>
<div
v-else
class="selectable-list-item-inner"
:class="{ 'selectable-list-item-selected-inner': isSelected(item) }"
>
<div
class="selectable-list-checkbox-wrapper"
@click.stop="toggle(!isSelected(item), item)"
>
<Checkbox
:model-value="isSelected(item)"
@update:model-value="checked => toggle(checked, item)"
@click.stop
/>
</div>
<slot
name="item"
:item="item"
/>
</div>
</template>
<template #empty>
<slot name="empty" />
</template>
<template #load>
<slot name="load" />
</template>
</List>
</div>
</template>
<script src="./selectable_list.js"></script>
<style lang="scss">
.selectable-list {
--__line-height: 1.5em;
--__horizontal-gap: 0.75em;
--__vertical-gap: 0.5em;
display: flex;
flex-direction: column;
.list {
flex: 1;
}
&-item-inner {
display: flex;
align-items: center;
> * {
min-width: 0;
}
}
&-header {
display: flex;
align-items: center;
padding: var(--__vertical-gap) var(--__horizontal-gap);
border-bottom: 1px solid;
border-bottom-color: var(--border);
&-actions {
flex: 1;
}
}
&-checkbox-wrapper {
padding-right: var(--__horizontal-gap);
flex: none;
}
}
</style>

View file

@ -1,7 +1,9 @@
import { isEmpty } from 'lodash'
import BasicUserCard from 'src/components/basic_user_card/basic_user_card.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import GenericConfirm from 'src/components/confirm_modal/generic_confirm.vue'
import PageList from 'src/components/page_list/page_list.vue'
import List from 'src/components/list/list.vue'
import Popover from 'src/components/popover/popover.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import Select from 'src/components/select/select.vue'
@ -17,7 +19,6 @@ const UsersTab = {
},
data() {
return {
init: false,
filtersOrigin: 'local',
filtersActivity: 'all',
filtersPrivileges: 'all',
@ -28,6 +29,11 @@ const UsersTab = {
filtersEmail: '',
expandedUser: null,
loading: false,
error: null,
bottomedOut: false,
users: [],
page: 1,
total: null,
}
},
computed: {
@ -79,26 +85,7 @@ const UsersTab = {
filtersExternal() {
return this.filtersOrigin === 'external'
},
},
components: {
Checkbox,
Select,
BasicUserCard,
PageList,
ProgressButton,
AdminCard,
TabSwitcher,
Popover,
GenericConfirm,
},
methods: {
/**
* fetch a new page of users via admin-api
* @param {object} store
* @param {object} opts
*/
fetchPage(store, opts) {
if (!this.init) return new Promise(() => [])
fetchOptions() {
const filters = {
isAdmin: this.filtersIsAdmin,
isModerator: this.filtersIsModerator,
@ -109,22 +96,51 @@ const UsersTab = {
needApproval: this.filtersNeedApproval,
unconfirmed: this.filtersUnconfirmeUnconfirmed,
}
const nopts = {
...opts,
...{
query: this.filtersQuery,
filters,
name: this.filtersName,
email: this.filtersEmail,
},
return {
query: this.filtersQuery,
name: this.filtersName,
email: this.filtersEmail,
pageSize: 50,
filters,
}
return store.dispatch('fetchAdminUsers', nopts)
},
/**
* reset the userlist explicitly
*/
reset() {
this.$refs.userList.reset()
},
components: {
Checkbox,
Select,
BasicUserCard,
List,
ProgressButton,
AdminCard,
TabSwitcher,
Popover,
GenericConfirm,
},
methods: {
fetchPage() {
if (this.loading) return
this.loading = true
this.error = null
this.$store
.dispatch('fetchAdminUsers', {
...this.fetchOptions,
page: this.page,
})
.then((result) => {
console.log('RESULT', result)
this.loading = false
this.bottomedOut = isEmpty(result.users)
this.page += 1
this.total = result.count
this.users.push(...result.users)
})
.catch((error) => {
this.loading = false
this.error = error
})
},
/**
* show the confirmation box for bulk actions.
@ -147,20 +163,18 @@ const UsersTab = {
u.id !== this.$store.state.users.currentUser.id
) {
const uf = this.$store.getters.findUser(u.id)
console.log('user: ', uf)
this.$store.dispatch(action, this.$store.getters.findUser(u.id))
}
})
this.reset()
},
},
/**
* mark as initialized and reset user list
*/
mounted() {
this.init = true
this.reset()
},
watch: {
fetchOptions () {
this.page = 1
this.users = []
this.fetchPage()
}
}
}
export default UsersTab

View file

@ -1,6 +1,6 @@
.UsersTab {
max-height: 100%;
display: grid;
display: flex;
flex-direction: column;
overflow-y: hidden;

View file

@ -14,7 +14,6 @@
<input
v-model="filtersQuery"
class="input string-input filter-input"
@input="reset()"
>
</label>
<label class="filter">
@ -24,7 +23,6 @@
<input
v-model="filtersName"
class="input string-input filter-input"
@input="reset()"
>
</label>
<label class="filter">
@ -34,7 +32,6 @@
<input
v-model="filtersEmail"
class="input string-input filter-input"
@input="reset()"
>
</label>
<div class="filter">
@ -43,7 +40,6 @@
</div>
<Select
v-model="filtersOrigin"
@update:model-value="reset"
>
<option
value="all"
@ -68,7 +64,6 @@
</div>
<Select
v-model="filtersActivity"
@update:model-value="reset"
>
<option
value="all"
@ -91,10 +86,7 @@
<div class="query-label">
{{ $t('admin_dash.users.labels.privileges') }}
</div>
<Select
v-model="filtersPrivileges"
@update:model-value="reset"
>
<Select v-model="filtersPrivileges">
<option
value="all"
>
@ -118,27 +110,25 @@
</Select>
</div>
<div class="filter">
<Checkbox
@update:model-value="v => {filtersNeedApproval = v; reset();}"
>
<Checkbox v-model="filtersNeedApproval">
{{ $t('admin_dash.users.options.only_unapproved') }}
</Checkbox>
</div>
<div class="filter">
<Checkbox
@update:model-value="v => {filtersUncomfirmed = v; reset();}"
>
<Checkbox v-model="filtersUncomfirmed">
{{ $t('admin_dash.users.options.only_unconfirmed') }}
</Checkbox>
</div>
</div>
<PageList
ref="userList"
:refresh="true"
<List
:get-key="i => i"
:box-only="true"
:page-size="20"
:fetch-page="(store, opts) => fetchPage(store, opts)"
:items="users"
:loading="loading"
:error="error"
:bottomed-out="bottomedOut"
@fetch-requested="fetchPage"
selectable
scrollable
>
<template #header>
<Popover
@ -264,7 +254,7 @@
<template #empty>
<span> no users </span>
</template>
</PageList>
</List>
<GenericConfirm
ref="confirmActivate"
:title="$t('admin_dash.users.actions.confirm_multi.title')"

View file

@ -1,6 +1,5 @@
import { get, map, reject } from 'lodash'
import { get, map, reject, isEmpty } from 'lodash'
import withSubscription from 'src/components/../hocs/with_subscription/with_subscription'
import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
import BlockCard from 'src/components/block_card/block_card.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
@ -18,6 +17,15 @@ const MutesAndBlocks = {
data() {
return {
activeTab: 'profile',
mutesLoading: false,
mutesError: null,
mutesBottomedOut: false,
blocksLoading: false,
blocksError: null,
blocksBottomedOut: false,
domainsLoading: false,
domainsError: null,
domainsBottomedOut: false,
}
},
created() {
@ -41,8 +49,37 @@ const MutesAndBlocks = {
user() {
return this.$store.state.users.currentUser
},
blocks() {
return get(this.$store.state.users.currentUser, 'blockIds', [])
},
mutes() {
return get(this.$store.state.users.currentUser, 'muteIds', [])
},
domains() {
return get(this.$store.state.users.currentUser, 'domainMutes', [])
},
},
methods: {
fetchItems(group) {
if (this[group + 'Loading']) return
const capGroup = group[0].toUpperCase() + group.slice(1)
this[group + 'Loading'] = true
this[group + 'Error'] = null
this.$store
.dispatch('fetch' + capGroup, this.userId)
.then((newEntries) => {
this[group + 'Loading'] = false
this[group + 'BottomedOut'] = isEmpty(newEntries)
return newEntries
})
.catch((error) => {
this[group + 'Loading'] = false
this[group + 'Error'] = error
})
},
importFollows(file) {
return this.$store.state.api.backendInteractor
.importFollows({ file })
@ -74,15 +111,6 @@ const MutesAndBlocks = {
})
.join('\n')
},
getBlocks() {
return get(this.$store.state.users.currentUser, 'blockIds', [])
},
getMutes() {
return get(this.$store.state.users.currentUser, 'muteIds', [])
},
getDomainMutes() {
return get(this.$store.state.users.currentUser, 'domainMutes', [])
},
activateTab(tabName) {
this.activeTab = tabName
},

View file

@ -22,8 +22,11 @@
</div>
<List
:get-key="i => i"
:items-function="getBlocks"
:fetch-function="() => $store.dispatch('fetchBlocks')"
:items="blocks"
:loading="blocksLoading"
:error="blocksError"
:bottomed-out="blocksBottomedOut"
@fetch-requested="fetchItems('blocks')"
scrollable
selectable
>
@ -76,8 +79,11 @@
</div>
<List
:get-key="i => i"
:items-function="getMutes"
:fetch-function="() => $store.dispatch('fetchMutes')"
:items="mutes"
:loading="mutesLoading"
:error="mutesError"
:bottomed-out="mutesBottomedOut"
@fetch-requested="fetchItems('mutes')"
scrollable
selectable
>
@ -130,8 +136,11 @@
</div>
<List
:get-key="i => i"
:items-function="getDomainMutes"
:fetch-function="() => $store.dispatch('fetchDomainMutes')"
:items="domains"
:loading="domainsLoading"
:error="domainsError"
:bottomed-out="domainsBottomedOut"
@fetch-requested="fetchItems('domainMutes')"
scrollable
selectable
>

View file

@ -1,4 +1,4 @@
import { get } from 'lodash'
import { get, isEmpty } from 'lodash'
import { mapState } from 'pinia'
import Conversation from 'src/components/conversation/conversation.vue'
@ -27,6 +27,12 @@ const UserProfile = {
userId: null,
tab: defaultTabKey,
footerRef: null,
friendsLoading: false,
friendsError: null,
friendsBottomedOut: false,
followersLoading: false,
followersError: null,
followersBottomedOut: false,
}
},
created() {
@ -83,22 +89,44 @@ const UserProfile = {
compactProfiles() {
return useMergedConfigStore().mergedConfig.compactProfiles
},
friends() {
return get(
this.$store.getters.findUser(this.userId),
'friendIds',
[],
).map((id) => this.$store.getters.findUser(id))
},
followers() {
return get(
this.$store.getters.findUser(this.userId),
'followerIds',
[],
).map((id) => this.$store.getters.findUser(id))
},
},
methods: {
setFooterRef(el) {
this.footerRef = el
},
getFriends() {
return get(
this.$store.getters.findUser(this.userId),
'friendIds', []
).map((id) => this.$store.getters.findUser(id))
},
getFollowers() {
return get(
this.$store.getters.findUser(this.userId),
'followerIds', []
).map((id) => this.$store.getters.findUser(id))
fetchUsers(group) {
if (this[group + 'Loading']) return
const capGroup = group[0].toUpperCase() + group.slice(1)
this[group + 'Loading'] = true
this[group + 'Error'] = null
this.$store
.dispatch('fetch' + capGroup, this.userId)
.then((newEntries) => {
this[group + 'Loading'] = false
this[group + 'BottomedOut'] = isEmpty(newEntries)
return newEntries
})
.catch((error) => {
this[group + 'Loading'] = false
this[group + 'Error'] = error
})
},
load(userNameOrId) {
const startFetchingTimeline = (timeline, userId) => {

View file

@ -40,9 +40,11 @@
:disabled="!user.friends_count"
>
<List
:user-id="userId"
:items-function="getFriends"
:fetch-function="() => $store.dispatch('fetchFriends', userId)"
:items="friends"
:loading="friendsLoading"
:error="friendsError"
:bottomed-out="friendsBottomedOut"
@fetch-requested="fetchUsers('friends')"
>
<template #item="{item}">
<FollowCard :user="item" />
@ -57,9 +59,11 @@
:disabled="!user.followers_count"
>
<List
:user-id="userId"
:items-function="getFollowers"
:fetch-function="() => $store.dispatch('fetchFollowers', userId)"
:items="followers"
:loading="followersLoading"
:error="followersError"
:bottomed-out="followersBottomedOut"
@fetch-requested="fetchUsers('followers')"
>
<template #item="{item}">
<FollowCard

View file

@ -61,11 +61,13 @@ const adminSettingsStorage = {
},
actions: {
async fetchAdminUsers(store, opts) {
const users = await store.rootState.api.backendInteractor.adminListUsers({
const data = await store.rootState.api.backendInteractor.adminListUsers({
opts,
})
users.forEach((user) => store.dispatch('fetchUserIfMissing', user.id))
return users
data.users.forEach((user) =>
store.dispatch('fetchUserIfMissing', user.id),
)
return data
},
adminAddUserToAdminGroup(store, user) {
store.rootState.api.backendInteractor

View file

@ -1673,7 +1673,10 @@ const adminListUsers = ({ opts, credentials }) => {
url: url,
credentials,
method: 'GET',
}).then((data) => data.users.map(parseUser))
}).then((data) => ({
...data,
users: data.users.map(parseUser),
}))
}
const adminAddUserToAdminGroup = ({ user, credentials }) => {