Add a sortable column
When you need this
Section titled “When you need this”A list screen should sort by a field it does not sort by today. The chain is the same for every list: the sort lives in the URL query, the page reads it into signals and passes it to the service, the service forwards sortBy and sortOrder, and the API refuses any field that is not on its per-endpoint allowlist before it reaches Prisma. You end up with the field on the allowlist, the page accepting it, and a test on each side. The worked example is the SEO redirects list at /seo/redirects, the one admin list that reads a sort from its URL today.
Two facts to set expectations. There is no table primitive in libs/admin-ui/; every list renders its own <table>. And no admin list renders a click-to-sort header yet (no aria-sort exists in the tree): the redirects page sorts by what its URL says, and the control that writes sortBy into the URL is the part you add, using the same query-merge navigation the filter chips use.
Files you touch
Section titled “Files you touch”apps/api/src/modules/seo/seo.constants.ts:VALID_REDIRECT_SORT_FIELDS, the allowlist. The field goes here first.apps/api/src/modules/seo/dto/list-redirects.dto.ts:@IsIn(VALID_REDIRECT_SORT_FIELDS)onsortBy.apps/api/src/modules/seo/seo.service.ts:listRedirects, which spreadssortByintoorderBy.libs/admin-services/src/seo/seo.schemas.tsandlibs/admin-services/src/seo/seo.service.ts: the query schema and the call that forwards it.apps/admin/src/app/features/seo/seo-redirects-list.page.ts: theSortFieldunion, thesortByandsortOrdersignals, the fetch.apps/admin/src/app/features/seo/seo-redirects-list.page.spec.ts: asserts the service receives the sort.apps/admin-e2e/src/modules/seo.journey.spec.ts: the URL round-trip journey to extend.
The pattern
Section titled “The pattern”1. Add the field to the API allowlist
Section titled “1. Add the field to the API allowlist”apps/api/src/modules/seo/seo.constants.ts holds the list. It is a const tuple so the DTO can type sortBy from it:
/** Allowlist for redirect list sortBy */export const VALID_REDIRECT_SORT_FIELDS = ['createdAt', 'updatedAt', 'fromPath'] as const;apps/api/src/modules/seo/dto/list-redirects.dto.ts validates against it, so a value outside the list is a 400 before the service runs (the global ValidationPipe in apps/api/src/main.ts runs with whitelist: true):
@IsOptional() @IsIn(VALID_REDIRECT_SORT_FIELDS) sortBy?: (typeof VALID_REDIRECT_SORT_FIELDS)[number];
@IsOptional() @IsIn(['asc', 'desc']) sortOrder?: 'asc' | 'desc';apps/api/src/modules/seo/seo.service.ts can then interpolate the key safely:
async listRedirects(dto: ListRedirectsAdminDto) { const sortBy = dto.sortBy ?? 'createdAt'; const sortOrder = dto.sortOrder ?? 'desc';
const [items, total] = await Promise.all([ this.prisma.seoRedirect.findMany({ where, skip, take: pageSize, orderBy: { [sortBy]: sortOrder }, }),Other modules keep the check in the service instead of the DTO. apps/api/src/modules/orders/orders.service.ts falls back to createdAt for an unknown field, and apps/api/src/modules/gift-cards/gift-cards.service.spec.ts proves that shape with an injection string:
it('rejects unknown sort field and falls back to createdAt', async () => { await service.list({ sortBy: 'injectedField; DROP TABLE' }); expect(prisma.giftCard.findMany).toHaveBeenCalledWith( expect.objectContaining({ orderBy: { createdAt: 'desc' } }), ); });Either way the field is never taken from the request as-is. The allowlists live in apps/api/src/modules/orders/orders.constants.ts (VALID_ORDER_SORT_FIELDS, VALID_ADMIN_ORDER_SORT_FIELDS), apps/api/src/modules/inventory/inventory.constants.ts and the SEO constants above.
2. The service forwards the query
Section titled “2. The service forwards the query”libs/admin-services/src/seo/seo.schemas.ts accepts the two keys; seo.service.ts turns the parsed object into query parameters:
export const listRedirectsQuerySchema = z.object({ page: z.number().int().positive().optional(), pageSize: z.number().int().positive().optional(), sortBy: z.string().optional(), sortOrder: z.enum(['asc', 'desc']).optional(), isAutoGenerated: z.boolean().optional(), search: z.string().optional(),}); listRedirects(query: ListRedirectsQuery = {}): Observable<ListRedirectsResponse> { const parsed = listRedirectsQuerySchema.parse(query); const params = new HttpParams({ fromObject: toParams(parsed as never) }); return this.http .get<unknown>(`${this.apiBase}/${adminRoute(SEO.base, SEO.redirects)}`, { params, withCredentials: true }) .pipe(map((raw) => listRedirectsResponseSchema.parse(raw))); }3. The page reads the sort from the URL
Section titled “3. The page reads the sort from the URL”apps/admin/src/app/features/seo/seo-redirects-list.page.ts narrows the query parameter to a union and defaults it:
type SortField = 'createdAt' | 'fromPath' | 'toPath';
readonly sortBy = computed<SortField>(() => { const raw = this.queryParams()['sortBy']; return raw === 'fromPath' || raw === 'toPath' ? raw : 'createdAt'; }); readonly sortOrder = computed<'asc' | 'desc'>(() => this.queryParams()['sortOrder'] === 'asc' ? 'asc' : 'desc', ); sortBy: this.sortBy(), sortOrder: this.sortOrder(),Add your field to the union and to the narrowing. The page re-fetches on every queryParams change, so the header control only has to navigate. The filter chips on the carriers list (apps/admin/src/app/features/shipping/carriers-list.page.ts) are the navigation to copy: merge one key, drop the page number:
setProvider(value: CarrierProvider | null): void { void this.router.navigate([], { relativeTo: this.route, queryParams: { provider: value, page: null }, queryParamsHandling: 'merge', }); }The header is code you add. The chips of the redirects page (seo-redirects-list.page.ts:82-95) give the classes and the pressed-state pattern; a header cell follows them, with aria-sort on the <th> instead of aria-pressed on the button:
<th scope="col" [attr.aria-sort]=" sortBy() === 'toPath' ? (sortOrder() === 'asc' ? 'ascending' : 'descending') : null "> <button type="button" class="min-h-[44px] text-xs tracking-widest uppercase px-3 py-2 border focus-visible:outline-2 focus-visible:outline-ink focus-visible:outline-offset-2" [class.border-ink]="sortBy() === 'toPath'" [class.text-ink]="sortBy() === 'toPath'" [class.border-line]="sortBy() !== 'toPath'" [class.text-ink-muted]="sortBy() !== 'toPath'" data-testid="seo-sort-toPath" (click)="setSort('toPath')" > Target </button></th>The method is setAutoFilter (line 321) with the sort keys in place of the filter key. A second click on the active column flips the order; a click on another column starts ascending; the page number is dropped either way:
setSort(field: SortField): void { const order = this.sortBy() === field && this.sortOrder() === 'asc' ? 'desc' : 'asc'; void this.router.navigate([], { relativeTo: this.route, queryParams: { sortBy: field, sortOrder: order, page: null }, queryParamsHandling: 'merge', }); }sortBy() and sortOrder() are the computed signals of step 3, read from the URL, so the header shows the state the list is really in.
4. Test both sides
Section titled “4. Test both sides”The page spec (apps/admin/src/app/features/seo/seo-redirects-list.page.spec.ts) asserts what the service receives:
it('fetches with defaults when URL has no filters', () => { configure(); expect(listSpy).toHaveBeenCalledWith({ page: 1, pageSize: 20, sortBy: 'createdAt', sortOrder: 'desc', }); });Add a case that configures the route with sortBy set to the new field and expects it forwarded. In Playwright, apps/admin-e2e/src/modules/seo.journey.spec.ts already proves a control round-trips to the URL; a sort header gets the same treatment, plus a read of the first cell:
test('/seo/redirects isAutoGenerated chip round-trips to URL', async ({ page }) => { await page.goto('/seo/redirects', { waitUntil: 'networkidle' }); await page.locator('[data-testid="seo-filter-chip-MANUAL"]').click(); await page.waitForURL(/isAutoGenerated=false/); await expect(page.locator('[data-testid="seo-filter-chip-MANUAL"]')).toHaveAttribute('aria-pressed', 'true');To assert the order itself against the real API, seed two rows through the API in the test (the SEO journeys do this with seedRedirect and delete them in finally), click the header, and compare the first row’s text to the row that must sort first. Nothing in the suite asserts a sorted order today; this is the assertion you add.
Tests to run
Section titled “Tests to run”npx nx test apinpx nx test admin-servicesnpx nx test adminnpx prettier --write <file>npx nx lint adminnpm run docs:generatenpm run test:scriptsnpx nx e2e admin-e2e --grep "seo/redirects"npm run test:e2enpx nx test apiruns the module’s service spec. Inapps/api/src/modules/seo/seo.service.spec.ts, thelistRedirects()describe (line 1309) has three cases and none asserts anorderBy; add one in the same shape asfilters by isAutoGenerated, withsortBy: 'toPath', sortOrder: 'asc'passed in andexpect.objectContaining({ orderBy: { toPath: 'asc' } })on thefindManycall. It does not compile until the constant carries the field, because the DTO typessortByfrom the tuple. The 400 for a field outside the list is not a service case: it comes from@IsInon the DTO and belongs to the Supertest e2e below.npm run docs:generaterewrites the module’s page of the public API reference underdocs/site/reference/from the DTO whose@IsInlist gained the field; commit the regenerated files with the column, ornpm run test:scriptsand thedocs-generate-checkCI job refuse the tree.npx nx test admin-servicesproves the query schema still forwardssortByandsortOrder.npx nx test adminruns the page spec with the new sort case.npx prettier --write <file>on the page file before lint; the root ESLint config runs Prettier as an error-level rule and reports formatting first on a template you just typed.npx nx lint adminthen covers the template: logical CSS only, no unused import.npx nx e2e admin-e2e --grep "seo/redirects"runs the redirects journeys against the real API, including your order assertion.npm run test:e2eruns the backend Supertest suite on the dockerized database; a new field on a DTO allowlist belongs in a request there too.
Gotchas
Section titled “Gotchas”- The admin’s
SortFieldunion and the API allowlist are typed separately and drift. Today the redirects page acceptstoPathfrom the URL whileVALID_REDIRECT_SORT_FIELDSdoes not include it, so?sortBy=toPathis a 400 from the API and a “Could not load” on the screen. Add the field to the API first, then to the union. - The page also offers
createdAtandfromPath; the API additionally acceptsupdatedAt. Keep the two lists identical when you touch either. - A sort field must be a real column of the Prisma model.
orderBy: { [sortBy]: sortOrder }throws at runtime on a field that is not; the allowlist is what keeps that from being reachable. - Reset the page number when the sort changes (
page: nullin the merge); otherwise the operator lands on page 3 of a differently ordered list. - The list re-fetches on every
queryParamsemission, including the first. Navigating twice in one handler fires two requests. - Sort keys on a list endpoint are read by the service with
sortByandsortOrder, notsort. The Zod query schema silently drops any other name. - Order assertions in Playwright must create their own rows. The seeded rows are shared by every spec in the run and their relative order is not stable across reseeds.