Skip to content
You are reading the unreleased documentation. No version is released yet, and these pages describe code that is not in a release.

Add a sortable column

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.

  • 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) on sortBy.
  • apps/api/src/modules/seo/seo.service.ts: listRedirects, which spreads sortBy into orderBy.
  • libs/admin-services/src/seo/seo.schemas.ts and libs/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: the SortField union, the sortBy and sortOrder signals, 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.

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.

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)));
}

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.

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.

Terminal window
npx nx test api
npx nx test admin-services
npx nx test admin
npx prettier --write <file>
npx nx lint admin
npm run docs:generate
npm run test:scripts
npx nx e2e admin-e2e --grep "seo/redirects"
npm run test:e2e
  • npx nx test api runs the module’s service spec. In apps/api/src/modules/seo/seo.service.spec.ts, the listRedirects() describe (line 1309) has three cases and none asserts an orderBy; add one in the same shape as filters by isAutoGenerated, with sortBy: 'toPath', sortOrder: 'asc' passed in and expect.objectContaining({ orderBy: { toPath: 'asc' } }) on the findMany call. It does not compile until the constant carries the field, because the DTO types sortBy from the tuple. The 400 for a field outside the list is not a service case: it comes from @IsIn on the DTO and belongs to the Supertest e2e below.
  • npm run docs:generate rewrites the module’s page of the public API reference under docs/site/reference/ from the DTO whose @IsIn list gained the field; commit the regenerated files with the column, or npm run test:scripts and the docs-generate-check CI job refuse the tree.
  • npx nx test admin-services proves the query schema still forwards sortBy and sortOrder.
  • npx nx test admin runs 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 admin then 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:e2e runs the backend Supertest suite on the dockerized database; a new field on a DTO allowlist belongs in a request there too.
  • The admin’s SortField union and the API allowlist are typed separately and drift. Today the redirects page accepts toPath from the URL while VALID_REDIRECT_SORT_FIELDS does not include it, so ?sortBy=toPath is 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 createdAt and fromPath; the API additionally accepts updatedAt. 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: null in the merge); otherwise the operator lands on page 3 of a differently ordered list.
  • The list re-fetches on every queryParams emission, including the first. Navigating twice in one handler fires two requests.
  • Sort keys on a list endpoint are read by the service with sortBy and sortOrder, not sort. 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.