From cda65852ac0113f2d9c04d7790f08bc621e433ef Mon Sep 17 00:00:00 2001 From: xkello Date: Mon, 4 May 2026 14:57:32 +0000 Subject: [PATCH] Add admin panel enhancements according to #3283 --- .../admin-lib/src/modules/admin/adminApi.ts | 10 ++- .../admin/components/AccountsTable.vue | 76 +++++++++++++---- .../admin/components/AdminProjectsTable.vue | 83 ++++++++++++++----- .../admin-lib/src/modules/admin/store.ts | 22 +++-- 4 files changed, 147 insertions(+), 44 deletions(-) diff --git a/web-app/packages/admin-lib/src/modules/admin/adminApi.ts b/web-app/packages/admin-lib/src/modules/admin/adminApi.ts index 08114297..5a1ef867 100644 --- a/web-app/packages/admin-lib/src/modules/admin/adminApi.ts +++ b/web-app/packages/admin-lib/src/modules/admin/adminApi.ts @@ -28,9 +28,10 @@ export const AdminApi = { }, async fetchUsers( - params: PaginatedUsersParams + params: PaginatedUsersParams, + signal?: AbortSignal ): Promise> { - return AdminModule.httpService.get(`/app/admin/users`, { params }) + return AdminModule.httpService.get(`/app/admin/users`, { params, signal }) }, async fetchUserByName( @@ -73,9 +74,10 @@ export const AdminApi = { }, async getProjects( - params: PaginatedAdminProjectsParams + params: PaginatedAdminProjectsParams, + signal?: AbortSignal ): Promise> { - return AdminModule.httpService.get('/app/admin/projects', { params }) + return AdminModule.httpService.get('/app/admin/projects', { params, signal }) }, /** diff --git a/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue b/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue index 8f456d47..ad312fc7 100644 --- a/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue +++ b/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue @@ -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 = {} + 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 { @@ -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({ diff --git a/web-app/packages/admin-lib/src/modules/admin/components/AdminProjectsTable.vue b/web-app/packages/admin-lib/src/modules/admin/components/AdminProjectsTable.vue index 8ac2ea4f..7af70914 100644 --- a/web-app/packages/admin-lib/src/modules/admin/components/AdminProjectsTable.vue +++ b/web-app/packages/admin-lib/src/modules/admin/components/AdminProjectsTable.vue @@ -189,6 +189,7 @@ import { AppContainer, ConfirmDialogProps } from '@mergin/lib' +import debounce from 'lodash/debounce' import { mapActions, mapState } from 'pinia' import { DataTablePageEvent, @@ -221,7 +222,8 @@ export default defineComponent({ data() { return { options: Object.assign({}, this.initialOptions), - search: '' + search: '', + abortController: null as AbortController | null } }, computed: { @@ -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' }), @@ -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 = {} + 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) { @@ -309,7 +354,7 @@ export default defineComponent({ const listeners = { confirm: async () => { await this.restoreProject({ projectId: item.id }) - this.fetchProjects() + this.doFetch() } } this.showDialog({ @@ -334,7 +379,7 @@ export default defineComponent({ const listeners = { confirm: async () => { await this.deleteProject({ projectId: item.id }) - this.fetchProjects() + this.doFetch() } } this.showDialog({ @@ -344,7 +389,7 @@ export default defineComponent({ }, onRefresh() { - this.fetchProjects() + this.doFetch() } } }) diff --git a/web-app/packages/admin-lib/src/modules/admin/store.ts b/web-app/packages/admin-lib/src/modules/admin/store.ts index a0544bbd..5bf5c5fa 100644 --- a/web-app/packages/admin-lib/src/modules/admin/store.ts +++ b/web-app/packages/admin-lib/src/modules/admin/store.ts @@ -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) } @@ -261,6 +266,7 @@ export const useAdminStore = defineStore('adminModule', { async getProjects(payload: { params: SortingOptions & Pick + signal?: AbortSignal }) { const notificationStore = useNotificationStore() @@ -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 }