2424 <PInputText
2525 placeholder =" Search accounts"
2626 data-cy =" search-members-field"
27- v-model =" searchByName "
27+ v-model =" search "
2828 class =" w-full"
2929 @input =" onSearch"
3030 />
4444 :first =" (options.page - 1) * options.itemsPerPage"
4545 :sort-field =" options.sortBy[0]"
4646 :sort-order =" options.sortDesc[0] ? -1 : 1"
47+ :rowHover =" true"
4748 removableSort
4849 reorderable-columns
4950 @page =" onPage"
50- @row-click =" rowClick"
5151 @sort =" onSort"
5252 data-cy =" accounts-table"
5353 >
54- <template v-for =" header in headers " :key =" header .field " >
55- <PColumn
56- v-if =" header.field === 'username'"
57- :field =" header.field"
58- :header =" header.header"
59- :sortable =" header.sortable"
60- >
61- <template #body =" slotProps " >
62- <router-link
63- class =" title-t4"
64- :to =" {
65- name: 'account',
66- params: { username: slotProps.data.username }
67- }"
68- >
69- {{ slotProps.data.username }}
70- </router-link >
71- </template >
72- </PColumn >
73- <PColumn
74- v-else-if =" header.field === 'active'"
75- :header =" header.header"
76- :field =" header.field"
77- >
78- <template #body =" slotProps " >
79- <i v-if =" slotProps.data.active" class =" ti ti-check" />
54+ <PColumn field =" username" header =" Username" :sortable =" true" >
55+ <template #body =" { data } " >
56+ <router-link
57+ :to =" accountRoute(data)"
58+ class =" dt-row-link title-t4"
59+ >
60+ {{ data.username }}
61+ </router-link >
62+ </template >
63+ </PColumn >
64+ <PColumn field =" email" header =" Email" :sortable =" true" >
65+ <template #body =" { data } " >
66+ <router-link :to =" accountRoute(data)" class =" dt-row-link" >
67+ {{ data.email }}
68+ </router-link >
69+ </template >
70+ </PColumn >
71+ <PColumn field =" profile.name" header =" Full name" >
72+ <template #body =" { data } " >
73+ <router-link :to =" accountRoute(data)" class =" dt-row-link" >
74+ {{ data.profile?.name }}
75+ </router-link >
76+ </template >
77+ </PColumn >
78+ <PColumn field =" active" header =" Active" >
79+ <template #body =" { data } " >
80+ <router-link :to =" accountRoute(data)" class =" dt-row-link" >
81+ <i v-if =" data.active" class =" ti ti-check" />
8082 <i v-else class =" ti ti-x" />
81- </template >
82- </PColumn >
83- <PColumn
84- v-else
85- :field =" header.field"
86- :header =" header.header"
87- :sortable =" header.sortable"
88- ></PColumn >
89- </template >
83+ </router-link >
84+ </template >
85+ </PColumn >
9086 <template #paginatorstart >
9187 <PButton
9288 icon =" ti ti-refresh"
106102<script lang="ts">
107103import {
108104 PaginatedUsersParams ,
105+ useDataTableSearch ,
109106 useDialogStore ,
110- TableDataHeader ,
111107 AppContainer ,
112108 AppSection
113109} from ' @mergin/lib'
114- import debounce from ' lodash/debounce'
115- import { mapActions , mapState } from ' pinia'
116- import {
117- DataTablePageEvent ,
118- DataTableRowClickEvent ,
119- DataTableSortEvent
120- } from ' primevue/datatable'
110+ import { mapState } from ' pinia'
121111import { defineComponent } from ' vue'
122112
123113import { AdminRoutes } from ' @/modules'
@@ -130,134 +120,50 @@ export default defineComponent({
130120 AppContainer ,
131121 AppSection
132122 },
133- data() {
123+ setup() {
124+ const adminStore = useAdminStore ()
125+ const dialogStore = useDialogStore ()
126+
127+ const tableSearch = useDataTableSearch ({
128+ defaultSortBy: ' username' ,
129+ defaultSortDesc: false
130+ })
131+
132+ tableSearch .setFetchFn ((signal ) => {
133+ const { options, search } = tableSearch
134+ const params: PaginatedUsersParams = {
135+ page: options .page ,
136+ per_page: options .itemsPerPage
137+ }
138+ if (options .sortBy [0 ]) {
139+ params .descending = options .sortDesc [0 ]
140+ params .order_by = options .sortBy [0 ]
141+ }
142+ if (search .value ) params .like = search .value .trim ()
143+ adminStore .fetchUsers ({ params , signal })
144+ })
145+
134146 return {
135- options: {
136- sortBy: [' username' ],
137- sortDesc: [false ],
138- itemsPerPage: 20 ,
139- page: 1 ,
140- perPageOptions: [20 , 50 , 100 ]
141- },
142- searchByName: ' ' ,
143- headers: [
144- { field: ' username' , header: ' Username' , sortable: true },
145- { field: ' email' , header: ' Email' , sortable: true },
146- { field: ' profile.name' , header: ' Full name' },
147- { field: ' active' , header: ' Active' }
148- ] as TableDataHeader [],
149- abortController: null as AbortController | null
147+ ... tableSearch ,
148+ show: dialogStore .show .bind (dialogStore )
150149 }
151150 },
152151 computed: {
153152 ... mapState (useAdminStore , [' users' , ' loading' ])
154153 },
155154 created() {
156- // Restore any search/sort/page state from the URL before the first fetch
157155 this .initFromQuery ()
158- // Delay search-triggered fetches so rapid typing doesn't spam the API
159- this .onSearch = debounce (this .onSearch , 500 )
160156 this .doFetch ()
161157 },
162158 methods: {
163- ... mapActions (useAdminStore , [' fetchUsers' ]),
164- ... mapActions (useDialogStore , [' show' ]),
165-
166- // Seed local state from URL query params so the page is shareable / survives navigation
167- initFromQuery() {
168- const q = this .$route .query
169- if (q .q ) this .searchByName = String (q .q )
170- if (q .page ) this .options .page = Number (q .page )
171- if (q .per_page ) this .options .itemsPerPage = Number (q .per_page )
172- if (q .order_by ) this .options .sortBy [0 ] = String (q .order_by )
173- if (q .desc ) this .options .sortDesc [0 ] = q .desc === ' true'
174- },
175-
176- // Reflect current search/sort/page state into the URL (defaults are omitted to keep URLs clean)
177- updateQuery() {
178- const query: Record <string , string > = {}
179- if (this .searchByName ) query .q = this .searchByName
180- if (this .options .page > 1 ) query .page = String (this .options .page )
181- if (this .options .itemsPerPage !== 20 )
182- query .per_page = String (this .options .itemsPerPage )
183- if (this .options .sortBy [0 ] && this .options .sortBy [0 ] !== ' username' )
184- query .order_by = this .options .sortBy [0 ]
185- if (this .options .sortDesc [0 ]) query .desc = ' true'
186- // replace (not push) so back-button skips intermediate search states
187- this .$router .replace ({ query })
188- },
189-
190- // Single entry point for all fetches: cancels any in-flight request, syncs the URL, then fetches
191- doFetch() {
192- // Abort the previous request so a stale slower response can't overwrite a newer one
193- this .abortController ?.abort ()
194- this .abortController = new AbortController ()
195- this .updateQuery ()
196- this .fetchUsers ({
197- params: this .getParams (),
198- signal: this .abortController .signal
199- })
200- },
201-
202- // Called on every keystroke (debounced); resets to page 1 so results start from the beginning
203- onSearch() {
204- this .options .page = 1
205- this .doFetch ()
206- },
207-
208- getParams(): PaginatedUsersParams {
209- const params = {
210- page: this .options .page ,
211- per_page: this .options .itemsPerPage
212- } as PaginatedUsersParams
213- if (this .options .sortBy [0 ]) {
214- params .descending = this .options .sortDesc [0 ]
215- params .order_by = this .options .sortBy [0 ]
216- }
217- if (this .searchByName ) {
218- params .like = this .searchByName .trim ()
219- }
220- return params
221- },
222-
223- onRefresh() {
224- this .doFetch ()
225- },
226-
227- onPage(event : DataTablePageEvent ) {
228- this .options .page = event .page + 1
229- this .options .itemsPerPage = event .rows
230- this .doFetch ()
231- },
232-
233- onSort(event : DataTableSortEvent ) {
234- this .options .sortBy [0 ] = event .sortField ?.toString ()
235- this .options .sortDesc [0 ] = event .sortOrder < 1
236- this .doFetch ()
237- },
238-
239- rowClick(event : DataTableRowClickEvent ) {
240- const originalEvent = event .originalEvent as MouseEvent
241- // Let the browser handle clicks that originate from a link inside the row (e.g. username column)
242- if ((originalEvent .target as HTMLElement ).closest (' a' )) return
243-
244- const location = {
245- name: AdminRoutes .ACCOUNT ,
246- params: { username: event .data .username }
247- }
248- // Ctrl/Cmd/Shift+click opens in a new tab; plain click navigates in the same tab
249- if (originalEvent .ctrlKey || originalEvent .metaKey || originalEvent .shiftKey ) {
250- window .open (this .$router .resolve (location ).href , ' _blank' )
251- } else {
252- this .$router .push (location )
253- }
159+ accountRoute(data ) {
160+ return { name: AdminRoutes .ACCOUNT , params: { username: data .username } }
254161 },
255162
256163 createUserDialog() {
257164 const dialog = { maxWidth: 500 , header: ' Create user' }
258165 const listeners = {
259166 success : () => {
260- // After creating a user, go back to page 1 so the new account is visible
261167 this .options .page = 1
262168 this .doFetch ()
263169 }
0 commit comments