Skip to content

Commit 42645b0

Browse files
Staff list controls: pagination, sorting & search (Services, Agreements, Submissions, Team) (#39)
* feat(platform): pagination, sorting & search on the staff Services list Shared, URL-synced list controls (initiative staff-list-query, wave 1) plus the Services surface wired up end-to-end. platform-web: - lib/list-search.ts: useListSearch hook + listSearchValidator (bind page/sort/order/q to TanStack Router search) + Paginated<T> envelope type - components/console/list/: SortableHeader, debounced ListSearchInput, ListPagination (offset pager, hidden when it fits one page) - Services page: search box, sortable headers, pager; keepPreviousData platform-api: - ServicesService.list: { workspaceId, q, sort, order, limit, offset } -> { items, total, limit, offset }; ILIKE over title/description; sortable by title/updated/status (derived status precedence subquery); count(*)::int total; page derived fields batched (no N+1) - add updatedAt to the service response DTO Tests: validator, primitives, behavioral search/sort, page-query schema, e2e 400s for out-of-range paging/unknown sort. * feat(platform): pagination, sorting & search on the staff Service Agreements list Initiative staff-list-query, wave 2. Reuses the wave-1 list primitives. platform-api: - new paginated ServiceAgreementsService.listPage + GET /v1/service-agreements/page: { workspaceId?, q, sort, order, limit, offset } -> { items, total, limit, offset }; ILIKE on title; sortable title/updated/status (derived status precedence); published-only; workspace scope lists the workspace's OWN agreements (globals excluded server-side, feature 150), admin scope lists globals; batched status - the existing unpaginated GET / is untouched: it still feeds the attach/default pickers, which need the full workspace + global set - add updatedAt to the agreement response DTO platform-web: - shared AgreementsList (workspace + admin) uses agreementsPageQueryOptions + the wave-1 primitives; drops the client-side !isGlobal filter (server does it now) - validateSearch on both service-agreements index routes - useListSearch gains an optional defaultSort so the shared component is correct on any host route Tests: page-query schema, e2e 401/403/400, behavioral search+sort, updated the console mock to the /page envelope. * feat(platform): pagination, sorting & search on the staff Submissions queue Initiative staff-list-query, wave 3 — the biggest rework. platform-api: - SubmissionsService.list rewritten from fetch-all + per-row toSummary N+1 to ONE SQL query (+ a count): joins the latest submission version (max-version correlated subquery, the (submission_id, version) pair is unique), applicant (users), and service (correlated subqueries on document_references) - ILIKE search over applicant name, service title, and the computed reference (YYYYMMDD-XXXX, UTC); draft exclusion (feature 151) + the status tab enforced in SQL; sortable by submitted/updated/status - { q, sort, order, limit, offset, status } -> { items, total, limit, offset } platform-web: - Submissions page: search + sortable headers (status/submitted/updated) + pagination via the wave-1 primitives; the status tab is now URL-synced too - useListSearch gains setFilter (arbitrary URL filter params, e.g. the status tab); route validateSearch composes the list params + status Verified against live Postgres (raw SQL + an ephemeral SubmissionsService.list probe: base/search/status/draft-exclusion/sort/paging all correct). Tests: query-schema DTO, e2e 400s (paging/sort/status), behavioral tab+search+ sort, updated the console mock to the paginated envelope. * feat(platform): pagination, sorting & search on the staff Team list Initiative staff-list-query, wave 4 (final) — the Teams (members) surface. platform-api: - new paginated WorkspacesService.listMembersPage + GET /v1/workspaces/:id/members/page: { q, sort, order, limit, offset } -> { items, total, limit, offset }; ILIKE on display name/email; sortable name/role/joined (every sort tiebreaks on name); sort=role (default) keeps the admins-first ordering - the unpaginated GET /:id/members is untouched: it still feeds the member-detail lookup, which resolves one member out of the full list platform-web: - Team page: search + sortable headers (Member/Role/Joined) + pagination via the wave-1 primitives; URL-synced - useListSearch gains defaultOrder so the admins-first (role, asc) default is self-contained on the route Verified against live Postgres (raw SQL + an ephemeral listMembersPage probe: base/sorts/search/no-match all correct). Tests: query-schema DTO, e2e 401/400, behavioral search+sort team-list test; updated the add-member mock to serve the /members/page envelope. * style(platform-web): right-align list search boxes, stack New/Add buttons above them Across the staff list pages (Services, Service Agreements, Team): the search box now sits on the right, with the New/Add action button on its own row above it, right-aligned. The Team 'Add member' button is now the primary (bcgov blue) variant at the same size as the other lists' New buttons (was an outline button). Submissions is unchanged (search already right; no create action).
1 parent 080eeae commit 42645b0

45 files changed

Lines changed: 1802 additions & 238 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/platform-api/src/modules/service-agreements/controllers/service-agreements-v1.controller.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { ZodSerializerDto } from 'nestjs-zod';
55
import {
66
CreateServiceAgreementDto,
77
ListServiceAgreementsDto,
8+
ListServiceAgreementsPageDto,
89
ServiceAgreementDetailDto,
910
ServiceAgreementListDto,
11+
ServiceAgreementListPageDto,
1012
ServiceAgreementVersionDto,
1113
ServiceAgreementWithVersionDto,
1214
UpdateServiceAgreementDto,
@@ -35,6 +37,15 @@ export class ServiceAgreementsV1Controller {
3537
return { items: await this.agreements.list(this.actor(user), query) };
3638
}
3739

40+
// Paginated/searchable browse for the console + admin list surfaces. Declared before `:id` so the
41+
// static segment wins route matching. The unpaginated `GET /` above still feeds the attach/default
42+
// pickers (which need the full workspace + global set).
43+
@Get('page')
44+
@ZodSerializerDto(ServiceAgreementListPageDto)
45+
listPage(@CurrentUser() user: AuthUser, @Query() query: ListServiceAgreementsPageDto) {
46+
return this.agreements.listPage(this.actor(user), query);
47+
}
48+
3849
@Get(':id')
3950
@ZodSerializerDto(ServiceAgreementDetailDto)
4051
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {

apps/platform-api/src/modules/service-agreements/dtos/service-agreement.dtos.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,23 @@ export const listServiceAgreementsSchema = z.object({ workspaceId: z.uuid().opti
2929
export class ListServiceAgreementsDto extends createZodDto(listServiceAgreementsSchema) {}
3030
export type ListServiceAgreementsQuery = z.infer<typeof listServiceAgreementsSchema>;
3131

32+
/**
33+
* Paginated, sortable, searchable agreements list (initiative `staff-list-query`). Workspace scope
34+
* (`workspaceId` present) lists the workspace's OWN agreements only — globals are excluded from the
35+
* workspace list (feature 150); admin scope (no `workspaceId`) lists globals. `sort: 'status'` orders
36+
* by the derived status precedence (published → draft → archived → none).
37+
*/
38+
export const listServiceAgreementsPageSchema = z.object({
39+
workspaceId: z.uuid().optional(),
40+
q: z.string().trim().max(255).optional(),
41+
sort: z.enum(['title', 'updated', 'status']).default('updated'),
42+
order: z.enum(['asc', 'desc']).default('desc'),
43+
limit: z.coerce.number().int().min(1).max(100).default(20),
44+
offset: z.coerce.number().int().min(0).default(0),
45+
});
46+
export class ListServiceAgreementsPageDto extends createZodDto(listServiceAgreementsPageSchema) {}
47+
export type ListServiceAgreementsPageQuery = z.infer<typeof listServiceAgreementsPageSchema>;
48+
3249
// ── Response schemas + DTOs ─────────────────────────────────────────────────────────────────────
3350

3451
export const serviceAgreementSchema = z.object({
@@ -38,6 +55,7 @@ export const serviceAgreementSchema = z.object({
3855
title: z.string(),
3956
kind: z.string(),
4057
createdAt: z.string(),
58+
updatedAt: z.string(),
4159
});
4260
export class ServiceAgreementDto extends createZodDto(serviceAgreementSchema) {}
4361
export type ServiceAgreementResponse = z.infer<typeof serviceAgreementSchema>;
@@ -97,6 +115,16 @@ export class ServiceAgreementListDto extends createZodDto(
97115
) {}
98116
export type ServiceAgreementSummary = z.infer<typeof serviceAgreementSummarySchema>;
99117

118+
/** Paginated agreements list envelope (initiative `staff-list-query`). */
119+
export const serviceAgreementListPageSchema = z.object({
120+
items: z.array(serviceAgreementSummarySchema),
121+
total: z.number().int(),
122+
limit: z.number().int(),
123+
offset: z.number().int(),
124+
});
125+
export type ServiceAgreementListPageResponse = z.infer<typeof serviceAgreementListPageSchema>;
126+
export class ServiceAgreementListPageDto extends createZodDto(serviceAgreementListPageSchema) {}
127+
100128
// ── Row → DTO mappers ───────────────────────────────────────────────────────────────────────────
101129

102130
export function toAgreementDto(row: Document): ServiceAgreementResponse {
@@ -106,6 +134,7 @@ export function toAgreementDto(row: Document): ServiceAgreementResponse {
106134
title: row.title,
107135
kind: row.kind,
108136
createdAt: row.createdAt.toISOString(),
137+
updatedAt: row.updatedAt.toISOString(),
109138
};
110139
}
111140

apps/platform-api/src/modules/service-agreements/services/service-agreements.service.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@ import {
1616
workspaces,
1717
} from '@repo/database';
1818
import { InjectDatabase } from '@repo/nestjs/database';
19-
import { and, asc, desc, eq, isNull, or, sql } from 'drizzle-orm';
19+
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
2020
import {
2121
type AssociatedService,
2222
type CreateServiceAgreementInput,
23+
type ListServiceAgreementsPageQuery,
2324
type ListServiceAgreementsQuery,
2425
type ServiceAgreementDetail,
26+
type ServiceAgreementListPageResponse,
2527
type ServiceAgreementSummary,
2628
type ServiceAgreementVersionResponse,
2729
type ServiceAgreementWithVersion,
@@ -136,6 +138,81 @@ export class ServiceAgreementsService {
136138
);
137139
}
138140

141+
/**
142+
* Paginated, sortable, searchable agreements list (initiative `staff-list-query`). Workspace scope
143+
* lists the workspace's OWN published agreements only (globals excluded — feature 150); admin scope
144+
* lists global published agreements. Paging/sort/search run in SQL; the page's status is derived in
145+
* one batched follow-up query (not per-row).
146+
*/
147+
async listPage(
148+
actor: Actor,
149+
query: ListServiceAgreementsPageQuery,
150+
): Promise<ServiceAgreementListPageResponse> {
151+
let scope;
152+
if (query.workspaceId !== undefined) {
153+
await this.requireMembership(actor.id, query.workspaceId);
154+
// Workspace list = the workspace's OWN agreements; globals live on the admin surface and are
155+
// reachable when attaching/defaulting, not here (feature 150).
156+
scope = eq(documents.workspaceId, query.workspaceId);
157+
} else {
158+
if (!actor.isAdmin) {
159+
throw new ForbiddenException('Only an admin can list global service agreements');
160+
}
161+
scope = isNull(documents.workspaceId);
162+
}
163+
const hasPublished = sql`exists (select 1 from ${documentVersions} dv where dv.document_id = ${documents.id} and dv.status = 'published')`;
164+
const q = query.q?.trim();
165+
const search = q !== undefined && q !== '' ? ilike(documents.title, `%${q}%`) : undefined;
166+
const where = and(eq(documents.kind, KIND), scope, hasPublished, search);
167+
// Derived status precedence for `sort: 'status'` — published(0) → draft(1) → archived(2) → none(3).
168+
const statusRank = sql`(CASE
169+
WHEN EXISTS (SELECT 1 FROM ${documentVersions} dv WHERE dv.document_id = ${documents.id} AND dv.status = 'published') THEN 0
170+
WHEN EXISTS (SELECT 1 FROM ${documentVersions} dv WHERE dv.document_id = ${documents.id} AND dv.status = 'draft') THEN 1
171+
WHEN EXISTS (SELECT 1 FROM ${documentVersions} dv WHERE dv.document_id = ${documents.id}) THEN 2
172+
ELSE 3 END)`;
173+
const sortExpr =
174+
query.sort === 'title'
175+
? documents.title
176+
: query.sort === 'status'
177+
? statusRank
178+
: documents.updatedAt;
179+
const direction = query.order === 'asc' ? asc : desc;
180+
const [docs, totals] = await Promise.all([
181+
this.db
182+
.select()
183+
.from(documents)
184+
.where(where)
185+
.orderBy(direction(sortExpr), desc(documents.createdAt))
186+
.limit(query.limit)
187+
.offset(query.offset),
188+
this.db
189+
.select({ count: sql<number>`count(*)::int` })
190+
.from(documents)
191+
.where(where),
192+
]);
193+
const docIds = docs.map((doc) => doc.id);
194+
const versionRows =
195+
docIds.length === 0
196+
? []
197+
: await this.db
198+
.select({ documentId: documentVersions.documentId, status: documentVersions.status })
199+
.from(documentVersions)
200+
.where(inArray(documentVersions.documentId, docIds));
201+
const statusByDoc = new Map<string, Array<{ status: 'draft' | 'published' | 'archived' }>>();
202+
for (const row of versionRows) {
203+
const list = statusByDoc.get(row.documentId);
204+
if (list) list.push(row);
205+
else statusByDoc.set(row.documentId, [row]);
206+
}
207+
const items = docs.map((doc) =>
208+
Object.assign(toAgreementDto(doc), {
209+
status: summarizeStatus(statusByDoc.get(doc.id) ?? []),
210+
isGlobal: doc.workspaceId === null,
211+
}),
212+
);
213+
return { items, total: totals[0]?.count ?? 0, limit: query.limit, offset: query.offset };
214+
}
215+
139216
/** An agreement + its versions + the type definition to render the editor. */
140217
async get(actor: Actor, id: string): Promise<ServiceAgreementDetail> {
141218
const doc = await this.requireAgreementForRead(actor, id);

apps/platform-api/src/modules/services/controllers/services-v1.controller.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
CreateServiceDto,
77
DefinitionDto,
88
FormCatalogListDto,
9+
ListServicesPageQueryDto,
910
ListServicesQueryDto,
1011
ServiceDetailDto,
1112
ServiceListDto,
@@ -30,8 +31,8 @@ export class ServicesV1Controller {
3031

3132
@Get()
3233
@ZodSerializerDto(ServiceListDto)
33-
async list(@CurrentUser() user: AuthUser, @Query() query: ListServicesQueryDto) {
34-
return { items: await this.services.list(user.id, query) };
34+
list(@CurrentUser() user: AuthUser, @Query() query: ListServicesPageQueryDto) {
35+
return this.services.list(user.id, query);
3536
}
3637

3738
@Post()

apps/platform-api/src/modules/services/dtos/service.dtos.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,23 @@ export const listServicesQuerySchema = z.object({ workspaceId: z.uuid() });
4848
export class ListServicesQueryDto extends createZodDto(listServicesQuerySchema) {}
4949
export type ListServicesQuery = z.infer<typeof listServicesQuerySchema>;
5050

51+
/**
52+
* Paginated, sortable, searchable services list query (initiative `staff-list-query`). Extends the
53+
* workspaces list convention: `sort` → column, `order` asc/desc, `limit`/`offset` window, `q` an
54+
* ILIKE substring over title/description. `sort: 'status'` orders by the derived status precedence
55+
* (published → draft → archived → none).
56+
*/
57+
export const listServicesPageQuerySchema = z.object({
58+
workspaceId: z.uuid(),
59+
q: z.string().trim().max(255).optional(),
60+
sort: z.enum(['title', 'updated', 'status']).default('updated'),
61+
order: z.enum(['asc', 'desc']).default('desc'),
62+
limit: z.coerce.number().int().min(1).max(100).default(20),
63+
offset: z.coerce.number().int().min(0).default(0),
64+
});
65+
export class ListServicesPageQueryDto extends createZodDto(listServicesPageQuerySchema) {}
66+
export type ListServicesPageQuery = z.infer<typeof listServicesPageQuerySchema>;
67+
5168
/** Composite save of a draft version: form data + (optional) reconciled application references. */
5269
export const updateVersionDataSchema = z.object({
5370
data: z.record(z.string(), z.unknown()),
@@ -81,6 +98,7 @@ export const serviceSchema = z.object({
8198
title: z.string(),
8299
description: z.string(),
83100
createdAt: z.string(),
101+
updatedAt: z.string(),
84102
});
85103
export type ServiceResponse = z.infer<typeof serviceSchema>;
86104

@@ -107,9 +125,15 @@ export const serviceSummarySchema = serviceSchema.extend({
107125
latestPublished: z.boolean(),
108126
});
109127
export type ServiceSummary = z.infer<typeof serviceSummarySchema>;
110-
export class ServiceListDto extends createZodDto(
111-
z.object({ items: z.array(serviceSummarySchema) }),
112-
) {}
128+
/** Paginated services list envelope (initiative `staff-list-query`). */
129+
export const serviceListResponseSchema = z.object({
130+
items: z.array(serviceSummarySchema),
131+
total: z.number().int(),
132+
limit: z.number().int(),
133+
offset: z.number().int(),
134+
});
135+
export type ServiceListResponse = z.infer<typeof serviceListResponseSchema>;
136+
export class ServiceListDto extends createZodDto(serviceListResponseSchema) {}
113137

114138
/** Create response: the service + its versions. */
115139
export const serviceWithVersionsSchema = z.object({
@@ -150,6 +174,7 @@ export function toServiceDto(row: Document): ServiceResponse {
150174
title: row.title,
151175
description: row.description,
152176
createdAt: row.createdAt.toISOString(),
177+
updatedAt: row.updatedAt.toISOString(),
153178
};
154179
}
155180

apps/platform-api/src/modules/services/services/services.service.ts

Lines changed: 95 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ import {
99
workspaceMembers,
1010
} from '@repo/database';
1111
import { InjectDatabase } from '@repo/nestjs/database';
12-
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
12+
import { and, asc, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm';
1313
import {
1414
type CreateServiceInput,
1515
type FormCatalogEntry,
16-
type ListServicesQuery,
16+
type ListServicesPageQuery,
1717
type ServiceDetail,
18+
type ServiceListResponse,
1819
type ServiceSummary,
1920
type ServiceWithVersions,
2021
toServiceDto,
@@ -129,29 +130,101 @@ export class ServicesService {
129130
return entries.filter((entry): entry is FormCatalogEntry => entry !== null);
130131
}
131132

132-
/** List a workspace's services with a representative status. */
133-
async list(userId: string, query: ListServicesQuery): Promise<ServiceSummary[]> {
133+
/**
134+
* List a workspace's services — paginated, sortable, searchable (initiative `staff-list-query`).
135+
* Paging/sort/search run in SQL; the page's derived fields (status, version count, submissions) are
136+
* computed in two batched follow-up queries (not per-row), so cost is bounded by the page size.
137+
*/
138+
async list(userId: string, query: ListServicesPageQuery): Promise<ServiceListResponse> {
134139
await this.requireMembership(userId, query.workspaceId);
135140
const type = await this.serviceType.resolve();
136-
const docs = await this.db
137-
.select()
138-
.from(documents)
139-
.where(and(eq(documents.workspaceId, query.workspaceId), eq(documents.typeId, type.typeId)))
140-
.orderBy(desc(documents.createdAt));
141-
return Promise.all(
142-
docs.map(async (doc) => {
143-
const versions = await this.versionsOf(doc.id);
144-
// versionsOf is ordered asc by version, so the last row is the latest version.
145-
const latest = versions[versions.length - 1];
146-
// Object.assign onto the fresh DTO (not a spread) keeps oxlint's no-map-spread happy.
147-
return Object.assign(toServiceDto(doc), {
148-
status: summarizeStatus(versions),
149-
versionCount: versions.length,
150-
hasSubmissions: await this.hasSubmissions(doc.id),
151-
latestPublished: latest?.publishedAt != null,
152-
});
153-
}),
141+
const q = query.q?.trim();
142+
const search =
143+
q !== undefined && q !== ''
144+
? or(ilike(documents.title, `%${q}%`), ilike(documents.description, `%${q}%`))
145+
: undefined;
146+
const where = and(
147+
eq(documents.workspaceId, query.workspaceId),
148+
eq(documents.typeId, type.typeId),
149+
search,
154150
);
151+
// Derived status precedence for `sort: 'status'` — published(0) → draft(1) → archived(2) → none(3).
152+
const statusRank = sql`(CASE
153+
WHEN EXISTS (SELECT 1 FROM ${documentVersions} dv WHERE dv.document_id = ${documents.id} AND dv.status = 'published') THEN 0
154+
WHEN EXISTS (SELECT 1 FROM ${documentVersions} dv WHERE dv.document_id = ${documents.id} AND dv.status = 'draft') THEN 1
155+
WHEN EXISTS (SELECT 1 FROM ${documentVersions} dv WHERE dv.document_id = ${documents.id}) THEN 2
156+
ELSE 3 END)`;
157+
const sortExpr =
158+
query.sort === 'title'
159+
? documents.title
160+
: query.sort === 'status'
161+
? statusRank
162+
: documents.updatedAt;
163+
const direction = query.order === 'asc' ? asc : desc;
164+
const [docs, totals] = await Promise.all([
165+
this.db
166+
.select()
167+
.from(documents)
168+
.where(where)
169+
.orderBy(direction(sortExpr), desc(documents.createdAt))
170+
.limit(query.limit)
171+
.offset(query.offset),
172+
this.db
173+
.select({ count: sql<number>`count(*)::int` })
174+
.from(documents)
175+
.where(where),
176+
]);
177+
const docIds = docs.map((doc) => doc.id);
178+
const [versionRows, submissionRows] = await Promise.all([
179+
docIds.length === 0
180+
? []
181+
: this.db
182+
.select({
183+
documentId: documentVersions.documentId,
184+
version: documentVersions.version,
185+
status: documentVersions.status,
186+
publishedAt: documentVersions.publishedAt,
187+
})
188+
.from(documentVersions)
189+
.where(inArray(documentVersions.documentId, docIds))
190+
.orderBy(asc(documentVersions.version)),
191+
docIds.length === 0
192+
? []
193+
: this.db
194+
.select({
195+
ownerDocumentId: documentReferences.ownerDocumentId,
196+
n: sql<number>`count(*)::int`,
197+
})
198+
.from(documentReferences)
199+
.innerJoin(submissions, eq(submissions.documentId, documentReferences.targetDocumentId))
200+
.where(
201+
and(
202+
inArray(documentReferences.ownerDocumentId, docIds),
203+
eq(documentReferences.relation, 'application_form'),
204+
),
205+
)
206+
.groupBy(documentReferences.ownerDocumentId),
207+
]);
208+
const versionsByDoc = new Map<string, Array<(typeof versionRows)[number]>>();
209+
for (const row of versionRows) {
210+
const list = versionsByDoc.get(row.documentId);
211+
if (list) list.push(row);
212+
else versionsByDoc.set(row.documentId, [row]);
213+
}
214+
const submissionDocIds = new Set(submissionRows.map((row) => row.ownerDocumentId));
215+
const items = docs.map((doc) => {
216+
const versions = versionsByDoc.get(doc.id) ?? [];
217+
// versions are ordered asc by version, so the last row is the latest version.
218+
const latest = versions[versions.length - 1];
219+
// Object.assign onto the fresh DTO (not a spread) keeps oxlint's no-map-spread happy.
220+
return Object.assign(toServiceDto(doc), {
221+
status: summarizeStatus(versions),
222+
versionCount: versions.length,
223+
hasSubmissions: submissionDocIds.has(doc.id),
224+
latestPublished: latest?.publishedAt != null,
225+
});
226+
});
227+
return { items, total: totals[0]?.count ?? 0, limit: query.limit, offset: query.offset };
155228
}
156229

157230
/** A service + its versions + the Service form definition to render. */

0 commit comments

Comments
 (0)