Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions web-app/packages/admin-lib/src/modules/admin/adminApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ export const AdminApi = {
},

async fetchUsers(
params: PaginatedUsersParams
params: PaginatedUsersParams,
signal?: AbortSignal
): Promise<AxiosResponse<UsersResponse>> {
return AdminModule.httpService.get(`/app/admin/users`, { params })
return AdminModule.httpService.get(`/app/admin/users`, { params, signal })
},

async fetchUserByName(
Expand Down Expand Up @@ -73,9 +74,10 @@ export const AdminApi = {
},

async getProjects(
params: PaginatedAdminProjectsParams
params: PaginatedAdminProjectsParams,
signal?: AbortSignal
): Promise<AxiosResponse<PaginatedAdminProjectsResponse>> {
return AdminModule.httpService.get('/app/admin/projects', { params })
return AdminModule.httpService.get('/app/admin/projects', { params, signal })
},

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,27 +145,64 @@ export default defineComponent({
{ field: 'email', header: 'Email', sortable: true },
{ field: 'profile.name', header: 'Full name' },
{ field: 'active', header: 'Active' }
] as TableDataHeader[]
] as TableDataHeader[],
abortController: null as AbortController | null
}
},
computed: {
...mapState(useAdminStore, ['users', 'loading'])
},
created() {
this.resetPaging = debounce(this.resetPaging, 1000)
this.fetchUsers({ params: this.getParams() })
// Restore any search/sort/page state from the URL before the first fetch
this.initFromQuery()
// Delay search-triggered fetches so rapid typing doesn't spam the API
this.onSearch = debounce(this.onSearch, 500)
this.doFetch()
},
methods: {
...mapActions(useAdminStore, ['fetchUsers']),
...mapActions(useDialogStore, ['show']),

onSearch() {
this.resetPaging()
this.fetchUsers({ params: this.getParams() })
// Seed local state from URL query params so the page is shareable / survives navigation
initFromQuery() {
const q = this.$route.query
if (q.q) this.searchByName = String(q.q)
if (q.page) this.options.page = Number(q.page)
if (q.per_page) this.options.itemsPerPage = Number(q.per_page)
if (q.order_by) this.options.sortBy[0] = String(q.order_by)
if (q.desc) this.options.sortDesc[0] = q.desc === 'true'
},

// Reflect current search/sort/page state into the URL (defaults are omitted to keep URLs clean)
updateQuery() {
const query: Record<string, string> = {}
if (this.searchByName) query.q = this.searchByName
if (this.options.page > 1) query.page = String(this.options.page)
if (this.options.itemsPerPage !== 20)
query.per_page = String(this.options.itemsPerPage)
if (this.options.sortBy[0] && this.options.sortBy[0] !== 'username')
query.order_by = this.options.sortBy[0]
if (this.options.sortDesc[0]) query.desc = 'true'
// replace (not push) so back-button skips intermediate search states
this.$router.replace({ query })
},

// Single entry point for all fetches: cancels any in-flight request, syncs the URL, then fetches
doFetch() {
// Abort the previous request so a stale slower response can't overwrite a newer one
this.abortController?.abort()
this.abortController = new AbortController()
this.updateQuery()
this.fetchUsers({
params: this.getParams(),
signal: this.abortController.signal
})
},

async resetPaging() {
// Called on every keystroke (debounced); resets to page 1 so results start from the beginning
onSearch() {
this.options.page = 1
this.doFetch()
},

getParams(): PaginatedUsersParams {
Expand All @@ -184,34 +221,45 @@ export default defineComponent({
},

onRefresh() {
this.fetchUsers({ params: this.getParams() })
this.doFetch()
},

onPage(event: DataTablePageEvent) {
this.options.page = event.page + 1
this.options.itemsPerPage = event.rows
this.fetchUsers({ params: this.getParams() })
this.doFetch()
},

onSort(event: DataTableSortEvent) {
this.options.sortBy[0] = event.sortField?.toString()
this.options.sortDesc[0] = event.sortOrder < 1
this.fetchUsers({ params: this.getParams() })
this.doFetch()
},

rowClick(event: DataTableRowClickEvent) {
this.$router.push({
const originalEvent = event.originalEvent as MouseEvent
// Let the browser handle clicks that originate from a link inside the row (e.g. username column)
if ((originalEvent.target as HTMLElement).closest('a')) return

const location = {
name: AdminRoutes.ACCOUNT,
params: { username: event.data.username }
})
}
// Ctrl/Cmd/Shift+click opens in a new tab; plain click navigates in the same tab
if (originalEvent.ctrlKey || originalEvent.metaKey || originalEvent.shiftKey) {
window.open(this.$router.resolve(location).href, '_blank')
} else {
this.$router.push(location)
}
},

createUserDialog() {
const dialog = { maxWidth: 500, header: 'Create user' }
const listeners = {
success: () => {
this.resetPaging()
this.fetchUsers({ params: this.getParams() })
// After creating a user, go back to page 1 so the new account is visible
this.options.page = 1
this.doFetch()
}
}
this.show({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ import {
AppContainer,
ConfirmDialogProps
} from '@mergin/lib'
import debounce from 'lodash/debounce'
import { mapActions, mapState } from 'pinia'
import {
DataTablePageEvent,
Expand Down Expand Up @@ -221,7 +222,8 @@ export default defineComponent({
data() {
return {
options: Object.assign({}, this.initialOptions),
search: ''
search: '',
abortController: null as AbortController | null
}
},
computed: {
Expand All @@ -247,8 +249,11 @@ export default defineComponent({
}
},
created() {
this.resetPaging()
this.fetchProjects()
// Restore any search/sort/page state from the URL before the first fetch
this.initFromQuery()
// Delay search-triggered fetches so rapid typing doesn't spam the API
this.onSearch = debounce(this.onSearch, 500)
this.doFetch()
},
methods: {
...mapActions(useDialogStore, { showDialog: 'show' }),
Expand All @@ -259,46 +264,86 @@ export default defineComponent({
'deleteProject'
]),

paginating(options) {
this.options = options
this.fetchProjects()
// Seed local state from URL query params so the page is shareable / survives navigation
initFromQuery() {
const q = this.$route.query
if (q.q) this.search = String(q.q)
if (q.page) this.options.page = Number(q.page)
if (q.per_page) this.options.itemsPerPage = Number(q.per_page)
if (q.order_by) this.options.sortBy[0] = String(q.order_by)
if (q.desc) this.options.sortDesc[0] = q.desc === 'true'
},

async resetPaging() {
this.options.page = 1
// Reflect current search/sort/page state into the URL (defaults are omitted to keep URLs clean)
updateQuery() {
const query: Record<string, string> = {}
if (this.search) query.q = this.search
if (this.options.page > 1) query.page = String(this.options.page)
if (this.options.itemsPerPage !== 20)
query.per_page = String(this.options.itemsPerPage)
if (this.options.sortBy[0] && this.options.sortBy[0] !== 'updated')
query.order_by = this.options.sortBy[0]
if (!this.options.sortDesc[0]) query.desc = 'false'
// replace (not push) so back-button skips intermediate search states
this.$router.replace({ query })
},

fetchProjects() {
this.getProjects({ params: { ...this.options, like: this.search } })
// Single entry point for all fetches: cancels any in-flight request, syncs the URL, then fetches
doFetch() {
// Abort the previous request so a stale slower response can't overwrite a newer one
this.abortController?.abort()
this.abortController = new AbortController()
this.updateQuery()
this.getProjects({
params: { ...this.options, like: this.search },
signal: this.abortController.signal
})
},

paginating(options) {
this.options = options
this.doFetch()
},

// Called on every keystroke (debounced); resets to page 1 so results start from the beginning
onSearch() {
this.resetPaging()
this.fetchProjects()
this.options.page = 1
this.doFetch()
},

onPage(event: DataTablePageEvent) {
this.options.page = event.page + 1
this.options.itemsPerPage = event.rows
this.fetchProjects()
this.doFetch()
},

onSort(event: DataTableSortEvent) {
this.options.sortBy[0] = event.sortField?.toString()
this.options.sortDesc[0] = event.sortOrder < 1
this.fetchProjects()
this.doFetch()
},

rowClick(event: DataTableRowClickEvent) {
// Removed projects have no detail view, only Restore/Delete buttons
if (event.data.removed_at) return

this.$router.push({
const originalEvent = event.originalEvent as MouseEvent
// Let the browser handle clicks that originate from a link or button inside the row
if ((originalEvent.target as HTMLElement).closest('a, button')) return

const location = {
name: AdminRoutes.PROJECT,
params: {
namespace: event.data.workspace,
projectName: event.data.name
}
})
}
// Ctrl/Cmd/Shift+click opens in a new tab; plain click navigates in the same tab
if (originalEvent.ctrlKey || originalEvent.metaKey || originalEvent.shiftKey) {
window.open(this.$router.resolve(location).href, '_blank')
} else {
this.$router.push(location)
}
},

confirmRestore(item) {
Expand All @@ -309,7 +354,7 @@ export default defineComponent({
const listeners = {
confirm: async () => {
await this.restoreProject({ projectId: item.id })
this.fetchProjects()
this.doFetch()
}
}
this.showDialog({
Expand All @@ -334,7 +379,7 @@ export default defineComponent({
const listeners = {
confirm: async () => {
await this.deleteProject({ projectId: item.id })
this.fetchProjects()
this.doFetch()
}
}
this.showDialog({
Expand All @@ -344,7 +389,7 @@ export default defineComponent({
},

onRefresh() {
this.fetchProjects()
this.doFetch()
}
}
})
Expand Down
22 changes: 15 additions & 7 deletions web-app/packages/admin-lib/src/modules/admin/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,20 @@ export const useAdminStore = defineStore('adminModule', {
this.isServerConfigHidden = value
},

async fetchUsers(payload: { params: PaginatedUsersParams }) {
async fetchUsers(payload: {
params: PaginatedUsersParams
signal?: AbortSignal
}) {
const notificationStore = useNotificationStore()

this.setLoading(true)
try {
const response = await AdminApi.fetchUsers(payload.params)
const response = await AdminApi.fetchUsers(payload.params, payload.signal)
this.setUsers(response.data)
} catch (e) {
notificationStore.error({ text: errorUtils.getErrorMessage(e) })
if (!axios.isCancel(e)) {
notificationStore.error({ text: errorUtils.getErrorMessage(e) })
}
} finally {
this.setLoading(false)
}
Expand Down Expand Up @@ -261,6 +266,7 @@ export const useAdminStore = defineStore('adminModule', {

async getProjects(payload: {
params: SortingOptions & Pick<PaginatedAdminProjectsParams, 'like'>
signal?: AbortSignal
}) {
const notificationStore = useNotificationStore()

Expand All @@ -277,13 +283,15 @@ export const useAdminStore = defineStore('adminModule', {
params.like = payload.params.like.trim()
}

const response = await AdminApi.getProjects(params)
const response = await AdminApi.getProjects(params, payload.signal)
this.projects.items = response.data.items
this.projects.count = response.data.count
} catch (e) {
notificationStore.error({
text: 'Failed to fetch projects'
})
if (!axios.isCancel(e)) {
notificationStore.error({
text: 'Failed to fetch projects'
})
}
} finally {
this.projects.loading = false
}
Expand Down
Loading