From cda65852ac0113f2d9c04d7790f08bc621e433ef Mon Sep 17 00:00:00 2001
From: xkello
Date: Mon, 4 May 2026 14:57:32 +0000
Subject: [PATCH 01/11] 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
}
From 9a88a5a8171d4c874cafd0fbd82ce2771959233e Mon Sep 17 00:00:00 2001
From: Richard Kello
Date: Fri, 15 May 2026 14:23:20 +0200
Subject: [PATCH 02/11] Update code with use of reusables, fix some checks
---
.../admin/components/AccountsTable.vue | 218 +++--------
.../admin/components/AdminProjectsTable.vue | 355 +++++++-----------
.../themes/mm-theme-light/_extensions.scss | 9 +
.../lib/src/common/composables/index.ts | 2 +
.../common/composables/useDataTableSearch.ts | 107 ++++++
web-app/packages/lib/src/mm-theme.ts | 2 +-
6 files changed, 308 insertions(+), 385 deletions(-)
create mode 100644 web-app/packages/lib/src/common/composables/useDataTableSearch.ts
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 ad312fc7..b52e4c35 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
@@ -24,7 +24,7 @@
@@ -44,49 +44,45 @@
:first="(options.page - 1) * options.itemsPerPage"
:sort-field="options.sortBy[0]"
:sort-order="options.sortDesc[0] ? -1 : 1"
+ :rowHover="true"
removableSort
reorderable-columns
@page="onPage"
- @row-click="rowClick"
@sort="onSort"
data-cy="accounts-table"
>
-
-
-
-
- {{ slotProps.data.username }}
-
-
-
-
-
-
+
+
+
+ {{ data.username }}
+
+
+
+
+
+
+ {{ data.email }}
+
+
+
+
+
+
+ {{ data.profile?.name }}
+
+
+
+
+
+
+
-
-
-
-
+
+
+
import {
PaginatedUsersParams,
+ useDataTableSearch,
useDialogStore,
- TableDataHeader,
AppContainer,
AppSection
} from '@mergin/lib'
-import debounce from 'lodash/debounce'
-import { mapActions, mapState } from 'pinia'
-import {
- DataTablePageEvent,
- DataTableRowClickEvent,
- DataTableSortEvent
-} from 'primevue/datatable'
+import { mapState } from 'pinia'
import { defineComponent } from 'vue'
import { AdminRoutes } from '@/modules'
@@ -130,134 +120,50 @@ export default defineComponent({
AppContainer,
AppSection
},
- data() {
+ setup() {
+ const adminStore = useAdminStore()
+ const dialogStore = useDialogStore()
+
+ const tableSearch = useDataTableSearch({
+ defaultSortBy: 'username',
+ defaultSortDesc: false
+ })
+
+ tableSearch.setFetchFn((signal) => {
+ const { options, search } = tableSearch
+ const params: PaginatedUsersParams = {
+ page: options.page,
+ per_page: options.itemsPerPage
+ }
+ if (options.sortBy[0]) {
+ params.descending = options.sortDesc[0]
+ params.order_by = options.sortBy[0]
+ }
+ if (search.value) params.like = search.value.trim()
+ adminStore.fetchUsers({ params, signal })
+ })
+
return {
- options: {
- sortBy: ['username'],
- sortDesc: [false],
- itemsPerPage: 20,
- page: 1,
- perPageOptions: [20, 50, 100]
- },
- searchByName: '',
- headers: [
- { field: 'username', header: 'Username', sortable: true },
- { field: 'email', header: 'Email', sortable: true },
- { field: 'profile.name', header: 'Full name' },
- { field: 'active', header: 'Active' }
- ] as TableDataHeader[],
- abortController: null as AbortController | null
+ ...tableSearch,
+ show: dialogStore.show.bind(dialogStore)
}
},
computed: {
...mapState(useAdminStore, ['users', 'loading'])
},
created() {
- // 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']),
-
- // 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
- })
- },
-
- // Called on every keystroke (debounced); resets to page 1 so results start from the beginning
- onSearch() {
- this.options.page = 1
- this.doFetch()
- },
-
- getParams(): PaginatedUsersParams {
- const params = {
- page: this.options.page,
- per_page: this.options.itemsPerPage
- } as PaginatedUsersParams
- if (this.options.sortBy[0]) {
- params.descending = this.options.sortDesc[0]
- params.order_by = this.options.sortBy[0]
- }
- if (this.searchByName) {
- params.like = this.searchByName.trim()
- }
- return params
- },
-
- onRefresh() {
- this.doFetch()
- },
-
- onPage(event: DataTablePageEvent) {
- this.options.page = event.page + 1
- this.options.itemsPerPage = event.rows
- this.doFetch()
- },
-
- onSort(event: DataTableSortEvent) {
- this.options.sortBy[0] = event.sortField?.toString()
- this.options.sortDesc[0] = event.sortOrder < 1
- this.doFetch()
- },
-
- rowClick(event: DataTableRowClickEvent) {
- 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)
- }
+ accountRoute(data) {
+ return { name: AdminRoutes.ACCOUNT, params: { username: data.username } }
},
createUserDialog() {
const dialog = { maxWidth: 500, header: 'Create user' }
const listeners = {
success: () => {
- // After creating a user, go back to page 1 so the new account is visible
this.options.page = 1
this.doFetch()
}
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 7af70914..6c268652 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
@@ -43,124 +43,118 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
:first="(options.page - 1) * options.itemsPerPage"
:sortField="options.sortBy[0]"
:sortOrder="options.sortDesc[0] ? -1 : 1"
+ :rowHover="true"
+ :row-class="(data) => (data.removed_at ? 'opacity-80' : '')"
removableSort
reorderable-columns
@page="onPage"
@sort="onSort"
- @row-click="rowClick"
data-cy="projects-table"
- :row-class="(data) => (data.removed_at ? 'opacity-80' : '')"
>
-
-
-
- {{ slotProps.data.workspace }}
-
-
+
+
+
+ {{ data.workspace }}
+
+ {{ data.workspace }}
+
+
-
-
- {{
- slotProps.data.name
- }}
-
- {{ slotProps.data.name }}
-
-
-
+
+
+
+ {{ data.name }}
+
+ {{ data.name }}
+
+
-
-
-
- {{ $filters.timediff(slotProps.data.updated) }}
+
+
+
+
+ {{ $filters.timediff(data.updated) }}
-
-
+
+
+ {{ $filters.timediff(data.updated) }}
+
+
+
-
-
- {{ $filters.filesize(slotProps.data.disk_usage, 'MB') }}
-
-
+
+
+
+ {{ $filters.filesize(data.disk_usage, 'MB') }}
+
+ {{ $filters.filesize(data.disk_usage, 'MB') }}
+
+
-
-
-
- {{ $filters.timediff(slotProps.data.removed_at) }}
-
-
-
+
+
+
+ {{ $filters.timediff(data.removed_at) }}
+
+
+
+
+
+
+ {{ data.removed_by }}
+
+
-
-
-
-
+
+
+
+
+
-
-
import {
ConfirmDialog,
+ useDataTableSearch,
useDialogStore,
useNotificationStore,
SortingOptions,
- TableDataHeader,
AppSection,
AppContainer,
ConfirmDialogProps
} from '@mergin/lib'
-import debounce from 'lodash/debounce'
-import { mapActions, mapState } from 'pinia'
-import {
- DataTablePageEvent,
- DataTableRowClickEvent,
- DataTableSortEvent
-} from 'primevue/datatable'
+import { mapState, mapActions } from 'pinia'
import { PropType, defineComponent } from 'vue'
import { AdminRoutes, useAdminStore } from '@/main'
@@ -219,130 +207,45 @@ export default defineComponent({
})
}
},
- data() {
+ setup(props) {
+ const adminStore = useAdminStore()
+ const dialogStore = useDialogStore()
+ const notificationStore = useNotificationStore()
+
+ const tableSearch = useDataTableSearch({
+ defaultSortBy: props.initialOptions.sortBy[0],
+ defaultSortDesc: props.initialOptions.sortDesc[0]
+ })
+
+ tableSearch.setFetchFn((signal) => {
+ const { options, search } = tableSearch
+ adminStore.getProjects({
+ params: { ...options, like: search.value },
+ signal
+ })
+ })
+
return {
- options: Object.assign({}, this.initialOptions),
- search: '',
- abortController: null as AbortController | null
+ ...tableSearch,
+ showDialog: dialogStore.show.bind(dialogStore),
+ error: notificationStore.error.bind(notificationStore),
+ show: notificationStore.show.bind(notificationStore)
}
},
computed: {
- ...mapState(useAdminStore, ['projects']),
- headers(): TableDataHeader[] {
- return [
- ...(this.showNamespace
- ? [
- {
- header: 'Workspace',
- field: 'workspace',
- sortable: true
- }
- ]
- : []),
- { header: 'Name', field: 'name', sortable: true },
- { header: 'Last Update', field: 'updated', sortable: true },
- { header: 'Size', field: 'disk_usage', sortable: true },
- { header: 'Scheduled removal at', field: 'removed_at', sortable: true },
- { header: 'Removed by', field: 'removed_by', sortable: true },
- { header: '', field: 'buttons', sortable: false }
- ]
- }
+ ...mapState(useAdminStore, ['projects'])
},
created() {
- // 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' }),
- ...mapActions(useNotificationStore, ['error', 'show']),
- ...mapActions(useAdminStore, [
- 'getProjects',
- 'restoreProject',
- 'deleteProject'
- ]),
-
- // 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'
- },
-
- // 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 })
- },
-
- // 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.options.page = 1
- this.doFetch()
- },
-
- onPage(event: DataTablePageEvent) {
- this.options.page = event.page + 1
- this.options.itemsPerPage = event.rows
- this.doFetch()
- },
+ ...mapActions(useAdminStore, ['restoreProject', 'deleteProject']),
- onSort(event: DataTableSortEvent) {
- this.options.sortBy[0] = event.sortField?.toString()
- this.options.sortDesc[0] = event.sortOrder < 1
- this.doFetch()
- },
-
- rowClick(event: DataTableRowClickEvent) {
- // Removed projects have no detail view, only Restore/Delete buttons
- if (event.data.removed_at) return
-
- 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 = {
+ projectRoute(data) {
+ return {
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)
+ params: { namespace: data.workspace, projectName: data.name }
}
},
@@ -386,10 +289,6 @@ export default defineComponent({
component: ConfirmDialog,
params: { props, listeners, dialog: { header: 'Delete project' } }
})
- },
-
- onRefresh() {
- this.doFetch()
}
}
})
diff --git a/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss b/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
index ccf252bb..156473c4 100644
--- a/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
+++ b/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
@@ -133,3 +133,12 @@ img {
text-decoration-line: underline;
text-decoration-style: dotted;
}
+
+td:has(.dt-row-link) {
+ padding: 0;
+}
+
+.dt-row-link {
+ display: block;
+ padding: $tableBodyCellPadding;
+}
diff --git a/web-app/packages/lib/src/common/composables/index.ts b/web-app/packages/lib/src/common/composables/index.ts
index dd4fe405..86cca258 100644
--- a/web-app/packages/lib/src/common/composables/index.ts
+++ b/web-app/packages/lib/src/common/composables/index.ts
@@ -3,3 +3,5 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
export { default as useRouterTitle } from './use_router_title'
+export { useDataTableSearch } from './useDataTableSearch'
+export type { DataTableSearchOptions } from './useDataTableSearch'
diff --git a/web-app/packages/lib/src/common/composables/useDataTableSearch.ts b/web-app/packages/lib/src/common/composables/useDataTableSearch.ts
new file mode 100644
index 00000000..4743f192
--- /dev/null
+++ b/web-app/packages/lib/src/common/composables/useDataTableSearch.ts
@@ -0,0 +1,107 @@
+// Copyright (C) Lutra Consulting Limited
+//
+// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
+
+import debounce from 'lodash/debounce'
+import { ref, reactive } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import {
+ DataTablePageEvent,
+ DataTableSortEvent
+} from 'primevue/datatable'
+
+export interface DataTableSearchOptions {
+ defaultSortBy?: string
+ defaultSortDesc?: boolean
+}
+
+/**
+ * Shared search/pagination/URL-sync logic for lazy-loaded admin data tables.
+ *
+ * Call setFetchFn() immediately after setup to register the table-specific
+ * fetch action; all event handlers will invoke it automatically.
+ */
+export function useDataTableSearch(opts: DataTableSearchOptions = {}) {
+ const { defaultSortBy = '', defaultSortDesc = false } = opts
+
+ const route = useRoute()
+ const router = useRouter()
+
+ const search = ref('')
+ const options = reactive({
+ sortBy: [defaultSortBy] as string[],
+ sortDesc: [defaultSortDesc] as boolean[],
+ itemsPerPage: 20,
+ page: 1,
+ perPageOptions: [20, 50, 100]
+ })
+ const abortController = ref(null)
+ const fetchFn = ref<((signal: AbortSignal) => void) | null>(null)
+
+ function setFetchFn(fn: (signal: AbortSignal) => void) {
+ fetchFn.value = fn
+ }
+
+ function initFromQuery() {
+ const q = route.query
+ if (q.q) search.value = String(q.q)
+ if (q.page) options.page = Number(q.page)
+ if (q.per_page) options.itemsPerPage = Number(q.per_page)
+ if (q.order_by) options.sortBy[0] = String(q.order_by)
+ if (q.desc) options.sortDesc[0] = q.desc === 'true'
+ }
+
+ function updateQuery() {
+ const query: Record = {}
+ if (search.value) query.q = search.value
+ if (options.page > 1) query.page = String(options.page)
+ if (options.itemsPerPage !== 20)
+ query.per_page = String(options.itemsPerPage)
+ if (options.sortBy[0] && options.sortBy[0] !== defaultSortBy)
+ query.order_by = options.sortBy[0]
+ if (options.sortDesc[0] !== defaultSortDesc)
+ query.desc = String(options.sortDesc[0])
+ router.replace({ query })
+ }
+
+ function doFetch() {
+ abortController.value?.abort()
+ abortController.value = new AbortController()
+ updateQuery()
+ fetchFn.value?.(abortController.value.signal)
+ }
+
+ const onSearch = debounce(() => {
+ options.page = 1
+ doFetch()
+ }, 500)
+
+ function onPage(event: DataTablePageEvent) {
+ options.page = event.page + 1
+ options.itemsPerPage = event.rows
+ doFetch()
+ }
+
+ function onSort(event: DataTableSortEvent) {
+ options.sortBy[0] = event.sortField?.toString() ?? ''
+ options.sortDesc[0] = event.sortOrder < 1
+ doFetch()
+ }
+
+ function onRefresh() {
+ doFetch()
+ }
+
+ return {
+ search,
+ options,
+ abortController,
+ setFetchFn,
+ initFromQuery,
+ doFetch,
+ onSearch,
+ onPage,
+ onSort,
+ onRefresh
+ }
+}
diff --git a/web-app/packages/lib/src/mm-theme.ts b/web-app/packages/lib/src/mm-theme.ts
index 169ab5ca..8d6e8571 100644
--- a/web-app/packages/lib/src/mm-theme.ts
+++ b/web-app/packages/lib/src/mm-theme.ts
@@ -157,7 +157,7 @@ export default usePassThrough(
} as TagPassThroughOptions,
column: {
bodyCell: {
- class: 'pl-4 py-2'
+ // class: 'pl-4 py-2'
},
headerCell: {
class: 'pl-4 py-1',
From 414817fd85b631611607e72c5da5ec7568cc2fc5 Mon Sep 17 00:00:00 2001
From: Richard Kello
Date: Fri, 15 May 2026 17:05:04 +0200
Subject: [PATCH 03/11] Fix some prettier issues
---
.../lib/src/common/composables/useDataTableSearch.ts | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/web-app/packages/lib/src/common/composables/useDataTableSearch.ts b/web-app/packages/lib/src/common/composables/useDataTableSearch.ts
index 4743f192..e04a2123 100644
--- a/web-app/packages/lib/src/common/composables/useDataTableSearch.ts
+++ b/web-app/packages/lib/src/common/composables/useDataTableSearch.ts
@@ -3,12 +3,9 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
import debounce from 'lodash/debounce'
+import { DataTablePageEvent, DataTableSortEvent } from 'primevue/datatable'
import { ref, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
-import {
- DataTablePageEvent,
- DataTableSortEvent
-} from 'primevue/datatable'
export interface DataTableSearchOptions {
defaultSortBy?: string
From 78c62b9da9a93eb59942084bca93a73c9a5f4dfe Mon Sep 17 00:00:00 2001
From: Richard Kello
Date: Wed, 20 May 2026 23:33:43 +0200
Subject: [PATCH 04/11] Add slot for WS table in admin for user profile
---
.../admin-lib/src/modules/admin/views/AccountDetailView.vue | 1 +
1 file changed, 1 insertion(+)
diff --git a/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue b/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue
index 9096e9bb..a5b708a0 100644
--- a/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue
+++ b/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue
@@ -63,6 +63,7 @@
+
Advanced
From 005bfc066c695f9110f08a4cc22bd2705eb6a642 Mon Sep 17 00:00:00 2001
From: Richard Kello
Date: Sun, 24 May 2026 16:09:36 +0200
Subject: [PATCH 05/11] Update compiled type declarations for
useDataTableSearch and TableDataHeader
---
.../admin/components/AccountsTable.vue | 76 ++++++----
.../admin/components/AdminProjectsTable.vue | 143 ++++++++++--------
.../modules/admin/views/AccountDetailView.vue | 4 +-
.../lib/src/common/components/types.ts | 4 +
.../common/composables/useDataTableSearch.ts | 11 +-
5 files changed, 141 insertions(+), 97 deletions(-)
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 b52e4c35..aee0ce4d 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
@@ -51,38 +51,28 @@
@sort="onSort"
data-cy="accounts-table"
>
-
-
-
- {{ data.username }}
-
-
-
-
-
-
- {{ data.email }}
-
-
-
-
-
-
- {{ data.profile?.name }}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ {{
+ fieldValue(data, header.field) ? 'Active' : 'Inactive'
+ }}
+ {{
+ fieldValue(data, header.field)
+ }}
+
+
+
+
import {
PaginatedUsersParams,
+ TableDataHeader,
useDataTableSearch,
useDialogStore,
AppContainer,
AppSection
} from '@mergin/lib'
+import get from 'lodash/get'
import { mapState } from 'pinia'
import { defineComponent } from 'vue'
@@ -114,6 +106,19 @@ import { AdminRoutes } from '@/modules'
import CreateUserForm from '@/modules/admin/components/CreateUserForm.vue'
import { useAdminStore } from '@/modules/admin/store'
+const headers: TableDataHeader[] = [
+ {
+ field: 'username',
+ header: 'Username',
+ sortable: true,
+ linked: true,
+ class: 'title-t4'
+ },
+ { field: 'email', header: 'Email', sortable: true, linked: true },
+ { field: 'profile.name', header: 'Full name', linked: true },
+ { field: 'active', header: 'Active', linked: true, type: 'boolean' }
+]
+
export default defineComponent({
name: 'AccountsTable',
components: {
@@ -145,7 +150,8 @@ export default defineComponent({
return {
...tableSearch,
- show: dialogStore.show.bind(dialogStore)
+ show: dialogStore.show.bind(dialogStore),
+ headers
}
},
computed: {
@@ -156,6 +162,10 @@ export default defineComponent({
this.doFetch()
},
methods: {
+ fieldValue(data: unknown, field: string) {
+ return get(data, field)
+ },
+
accountRoute(data) {
return { name: AdminRoutes.ACCOUNT, params: { username: data.username } }
},
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 6c268652..d8502969 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
@@ -51,66 +51,39 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
@sort="onSort"
data-cy="projects-table"
>
-
-
-
- {{ data.workspace }}
-
- {{ data.workspace }}
-
-
-
-
-
-
- {{ data.name }}
-
- {{ data.name }}
-
-
-
-
-
-
-
- {{ $filters.timediff(data.updated) }}
+
+
+
+
+ {{ $filters.timediff(data[header.field]) }}
+ {{ cellContent(data, header) }}
+
+
+ {{ $filters.timediff(data[header.field]) }}
+ {{ cellContent(data, header) }}
-
-
- {{ $filters.timediff(data.updated) }}
-
-
-
-
-
-
-
- {{ $filters.filesize(data.disk_usage, 'MB') }}
-
- {{ $filters.filesize(data.disk_usage, 'MB') }}
-
-
+
+
+
import {
ConfirmDialog,
+ TableDataHeader,
useDataTableSearch,
useDialogStore,
useNotificationStore,
@@ -233,7 +207,46 @@ export default defineComponent({
}
},
computed: {
- ...mapState(useAdminStore, ['projects'])
+ ...mapState(useAdminStore, ['projects']),
+ headers(): TableDataHeader[] {
+ const cols: TableDataHeader[] = []
+ if (this.showNamespace) {
+ cols.push({
+ field: 'workspace',
+ header: 'Workspace',
+ sortable: true,
+ linked: true,
+ conditionalLink: 'removed_at'
+ })
+ }
+ cols.push(
+ {
+ field: 'name',
+ header: 'Name',
+ sortable: true,
+ linked: true,
+ class: 'font-semibold',
+ conditionalLink: 'removed_at'
+ },
+ {
+ field: 'updated',
+ header: 'Last Update',
+ sortable: true,
+ linked: true,
+ type: 'timediff',
+ conditionalLink: 'removed_at'
+ },
+ {
+ field: 'disk_usage',
+ header: 'Size',
+ sortable: true,
+ linked: true,
+ type: 'filesize',
+ conditionalLink: 'removed_at'
+ }
+ )
+ return cols
+ }
},
created() {
this.initFromQuery()
@@ -242,6 +255,16 @@ export default defineComponent({
methods: {
...mapActions(useAdminStore, ['restoreProject', 'deleteProject']),
+ cellContent(
+ data: Record,
+ header: TableDataHeader
+ ): string {
+ const val = data[header.field]
+ if (header.type === 'timediff') return this.$filters.timediff(val)
+ if (header.type === 'filesize') return this.$filters.filesize(val, 'MB')
+ return String(val ?? '')
+ },
+
projectRoute(data) {
return {
name: AdminRoutes.PROJECT,
diff --git a/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue b/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue
index 9096e9bb..18ceade0 100644
--- a/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue
+++ b/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue
@@ -44,7 +44,9 @@
>
{{ user?.email }}
-
+
diff --git a/web-app/packages/lib/src/common/components/types.ts b/web-app/packages/lib/src/common/components/types.ts
index 245bc52f..73340e8a 100644
--- a/web-app/packages/lib/src/common/components/types.ts
+++ b/web-app/packages/lib/src/common/components/types.ts
@@ -14,6 +14,10 @@ export interface TableDataHeader {
field: string
sortable?: boolean
width?: number
+ linked?: boolean
+ class?: string
+ type?: 'boolean' | 'filesize' | 'timediff'
+ conditionalLink?: string
}
export type TipMessageSeverity = 'info' | 'danger'
diff --git a/web-app/packages/lib/src/common/composables/useDataTableSearch.ts b/web-app/packages/lib/src/common/composables/useDataTableSearch.ts
index e04a2123..cad29b64 100644
--- a/web-app/packages/lib/src/common/composables/useDataTableSearch.ts
+++ b/web-app/packages/lib/src/common/composables/useDataTableSearch.ts
@@ -10,6 +10,7 @@ import { useRoute, useRouter } from 'vue-router'
export interface DataTableSearchOptions {
defaultSortBy?: string
defaultSortDesc?: boolean
+ defaultItemsPerPage?: number
}
/**
@@ -19,7 +20,11 @@ export interface DataTableSearchOptions {
* fetch action; all event handlers will invoke it automatically.
*/
export function useDataTableSearch(opts: DataTableSearchOptions = {}) {
- const { defaultSortBy = '', defaultSortDesc = false } = opts
+ const {
+ defaultSortBy = '',
+ defaultSortDesc = false,
+ defaultItemsPerPage = 20
+ } = opts
const route = useRoute()
const router = useRouter()
@@ -28,7 +33,7 @@ export function useDataTableSearch(opts: DataTableSearchOptions = {}) {
const options = reactive({
sortBy: [defaultSortBy] as string[],
sortDesc: [defaultSortDesc] as boolean[],
- itemsPerPage: 20,
+ itemsPerPage: defaultItemsPerPage,
page: 1,
perPageOptions: [20, 50, 100]
})
@@ -52,7 +57,7 @@ export function useDataTableSearch(opts: DataTableSearchOptions = {}) {
const query: Record
= {}
if (search.value) query.q = search.value
if (options.page > 1) query.page = String(options.page)
- if (options.itemsPerPage !== 20)
+ if (options.itemsPerPage !== defaultItemsPerPage)
query.per_page = String(options.itemsPerPage)
if (options.sortBy[0] && options.sortBy[0] !== defaultSortBy)
query.order_by = options.sortBy[0]
From 5765de4c860032c2bc867d96b563a53877f974f9 Mon Sep 17 00:00:00 2001
From: Richard Kello
Date: Mon, 1 Jun 2026 09:47:58 +0200
Subject: [PATCH 06/11] Add configurable confirm dialog logo and update Project
Settings
---
.../dialog/components/ConfirmDialog.vue | 44 ++++++++++++++-----
.../packages/lib/src/modules/dialog/types.ts | 4 ++
.../lib/src/modules/project/routes.ts | 2 +-
.../project/views/ProjectViewTemplate.vue | 2 +-
4 files changed, 38 insertions(+), 14 deletions(-)
diff --git a/web-app/packages/lib/src/modules/dialog/components/ConfirmDialog.vue b/web-app/packages/lib/src/modules/dialog/components/ConfirmDialog.vue
index 634cfb47..087af8d8 100644
--- a/web-app/packages/lib/src/modules/dialog/components/ConfirmDialog.vue
+++ b/web-app/packages/lib/src/modules/dialog/components/ConfirmDialog.vue
@@ -6,17 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
-

-

-

+
{{ text }}
{{ description }}
{{ hint }}
@@ -73,13 +63,18 @@ import { ref, computed, defineEmits, withDefaults } from 'vue'
import { ConfirmDialogProps } from '../types'
+import negativeIcon from '@/assets/negative.svg'
+import neutralIcon from '@/assets/neutral.svg'
+import trashIcon from '@/assets/trash.svg'
+import warningIcon from '@/assets/warning-dialog.svg'
import TipMessage from '@/common/components/TipMessage.vue'
import { useDialogStore } from '@/modules/dialog/store'
const props = withDefaults(defineProps
(), {
confirmText: 'Ok',
cancelText: 'Cancel',
- severity: 'primary'
+ severity: 'primary',
+ logoVariant: 'auto'
})
const confirmValue = ref('')
@@ -91,6 +86,31 @@ const isConfirmed = computed(() => {
: true
})
+const logoSrc = computed(() => {
+ const variant =
+ props.logoVariant === 'auto'
+ ? props.severity === 'danger'
+ ? 'danger'
+ : props.severity === 'warning'
+ ? 'warning'
+ : 'primary'
+ : props.logoVariant
+
+ if (variant === 'danger') {
+ return trashIcon
+ }
+
+ if (variant === 'warning') {
+ return warningIcon
+ }
+
+ if (variant === 'negative') {
+ return negativeIcon
+ }
+
+ return neutralIcon
+})
+
const { close } = useDialogStore()
function confirm() {
diff --git a/web-app/packages/lib/src/modules/dialog/types.ts b/web-app/packages/lib/src/modules/dialog/types.ts
index 133bce2e..49157d5b 100644
--- a/web-app/packages/lib/src/modules/dialog/types.ts
+++ b/web-app/packages/lib/src/modules/dialog/types.ts
@@ -9,6 +9,10 @@ import { TipMessageProps } from '@/common'
export interface ConfirmDialogProps {
text: string
severity?: 'primary' | 'danger' | 'warning'
+ /**
+ * Optional logo override. When omitted, logo follows current severity behavior.
+ */
+ logoVariant?: 'auto' | 'primary' | 'danger' | 'warning' | 'negative'
confirmText?: string
cancelText?: string
description?: string
diff --git a/web-app/packages/lib/src/modules/project/routes.ts b/web-app/packages/lib/src/modules/project/routes.ts
index b4fbc0b6..684cdc3c 100644
--- a/web-app/packages/lib/src/modules/project/routes.ts
+++ b/web-app/packages/lib/src/modules/project/routes.ts
@@ -59,7 +59,7 @@ export const getProjectTitle = (
query.file_path || 'Files',
route.params.projectName as string
],
- [ProjectRouteName.ProjectSettings]: ['Settings', projectName],
+ [ProjectRouteName.ProjectSettings]: ['Settings & API', projectName],
[ProjectRouteName.ProjectHistory]: [
query.version_id || 'History',
projectName
diff --git a/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue b/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue
index e8ea5b63..027b32f8 100644
--- a/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue
+++ b/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue
@@ -208,7 +208,7 @@ export default defineComponent({
})
tabs.push({
route: ProjectRouteName.ProjectSettings,
- header: 'Settings'
+ header: 'Settings & API'
})
}
}
From c70625af588ec70eaf162c0e843c9911e7025e87 Mon Sep 17 00:00:00 2001
From: Richard Kello
Date: Mon, 1 Jun 2026 10:08:22 +0200
Subject: [PATCH 07/11] Replace boolean value
---
.../admin-lib/src/modules/admin/components/AccountsTable.vue | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
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 aee0ce4d..08fb807e 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
@@ -63,7 +63,7 @@
class="dt-row-link"
:class="header.class"
>
- {{
+ {{
fieldValue(data, header.field) ? 'Active' : 'Inactive'
}}
{{
@@ -116,7 +116,7 @@ const headers: TableDataHeader[] = [
},
{ field: 'email', header: 'Email', sortable: true, linked: true },
{ field: 'profile.name', header: 'Full name', linked: true },
- { field: 'active', header: 'Active', linked: true, type: 'boolean' }
+ { field: 'active', header: 'Active', linked: true }
]
export default defineComponent({
From 6b9aba40cf4c680f7a13622c8b4d6a0865f15daa Mon Sep 17 00:00:00 2001
From: Richard Kello
Date: Mon, 1 Jun 2026 11:24:23 +0200
Subject: [PATCH 08/11] Make some naming changes
---
.../lib/src/modules/project/views/ProjectViewTemplate.vue | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue b/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue
index 027b32f8..c0f517bb 100644
--- a/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue
+++ b/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue
@@ -164,7 +164,11 @@ export default defineComponent({
type: Boolean,
default: false
},
- mapRoute: String
+ mapRoute: String,
+ settingsTabHeader: {
+ type: String,
+ default: 'Settings'
+ }
},
data() {
return {
@@ -208,7 +212,7 @@ export default defineComponent({
})
tabs.push({
route: ProjectRouteName.ProjectSettings,
- header: 'Settings & API'
+ header: this.settingsTabHeader
})
}
}
From f18e219dbce07a69c23c27b537b526bd9580deeb Mon Sep 17 00:00:00 2001
From: Herman Snevajs
Date: Fri, 5 Jun 2026 09:39:43 +0200
Subject: [PATCH 09/11] Add classes for invitation action icons
---
.../themes/mm-theme-light/_extensions.scss | 19 +++++++++++++++++++
.../components/ProjectShareDialogTemplate.vue | 2 +-
2 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss b/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
index 156473c4..2fb161a3 100644
--- a/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
+++ b/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
@@ -118,6 +118,25 @@ img {
overflow-wrap: anywhere;
}
+// Icon-only action button used in list rows (e.g. resend / trash on invitation rows)
+.p-button.icon-action-btn {
+ color: map-get($colors, 'forest');
+
+ &:not(:disabled):hover {
+ background: map-get($colors, 'light-green');
+ color: map-get($colors, 'forest');
+ }
+
+ &.icon-action-btn--danger {
+ color: map-get($colors, 'grape');
+
+ &:not(:disabled):hover {
+ background: map-get($colors, 'negative-light');
+ color: map-get($colors, 'grape');
+ }
+ }
+}
+
// Color of error messages in inputs ...
.p-error {
color: map-get($map: $colors, $key: grape);
diff --git a/web-app/packages/lib/src/modules/project/components/ProjectShareDialogTemplate.vue b/web-app/packages/lib/src/modules/project/components/ProjectShareDialogTemplate.vue
index c25bc181..c66fd4a0 100644
--- a/web-app/packages/lib/src/modules/project/components/ProjectShareDialogTemplate.vue
+++ b/web-app/packages/lib/src/modules/project/components/ProjectShareDialogTemplate.vue
@@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
class="underline"
>Learn more about permission system.
+ >
Date: Fri, 12 Jun 2026 09:20:12 +0200
Subject: [PATCH 10/11] Suppress focus halo on icon action buttons after mouse
click
QA reported the resend / trash buttons on invitation rows keep the
focus halo after a mouse click. Add a :focus:not(:focus-visible) rule
to .icon-action-btn so the ring is dropped on mouse interaction but
preserved for keyboard navigation (accessibility).
---
.../src/assets/sass/themes/mm-theme-light/_extensions.scss | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss b/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
index 2fb161a3..6da85d8c 100644
--- a/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
+++ b/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
@@ -122,6 +122,11 @@ img {
.p-button.icon-action-btn {
color: map-get($colors, 'forest');
+ // Suppress the focus halo after a mouse click
+ &:focus:not(:focus-visible) {
+ box-shadow: none;
+ }
+
&:not(:disabled):hover {
background: map-get($colors, 'light-green');
color: map-get($colors, 'forest');
From 3ff5dc25dcaa8d4fe993917e4b59d8c785f27e59 Mon Sep 17 00:00:00 2001
From: Herman Snevajs
Date: Thu, 18 Jun 2026 13:33:53 +0200
Subject: [PATCH 11/11] Apply focus halo suppression to all PrimeVue buttons
The :focus:not(:focus-visible) rule was previously scoped only to
.icon-action-btn, so it only worked on the icon-only resend/trash
buttons in the workspace Members and project Collaborators tables.
Other invitation-related buttons (the labeled Resend pill on the
dashboard, the X on the dashboard, the labeled buttons in the
invitation sidebar) kept the lingering focus halo after a mouse click.
Move the rule out of .icon-action-btn and apply it globally to
.p-button. The :focus-visible exclusion still preserves the focus ring
for keyboard navigation, so accessibility is unaffected.
---
.../sass/themes/mm-theme-light/_extensions.scss | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss b/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
index 6da85d8c..de9cf422 100644
--- a/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
+++ b/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
@@ -118,15 +118,16 @@ img {
overflow-wrap: anywhere;
}
+// Suppress the focus halo after a mouse click on any PrimeVue button while
+// keeping the ring for keyboard navigation (accessibility).
+.p-button:focus:not(:focus-visible) {
+ box-shadow: none;
+}
+
// Icon-only action button used in list rows (e.g. resend / trash on invitation rows)
.p-button.icon-action-btn {
color: map-get($colors, 'forest');
- // Suppress the focus halo after a mouse click
- &:focus:not(:focus-visible) {
- box-shadow: none;
- }
-
&:not(:disabled):hover {
background: map-get($colors, 'light-green');
color: map-get($colors, 'forest');