@stamhoofd/backend-sgv-mock 2.122.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +32 -0
- package/package.json +37 -0
- package/src/boot.ts +73 -0
- package/src/endpoints/SGVMockEndpoint.test.ts +406 -0
- package/src/endpoints/SGVMockEndpoint.ts +596 -0
- package/src/index.ts +6 -0
- package/src/state/mock-state.ts +329 -0
- package/tests/vitest.setup.ts +11 -0
- package/tsconfig.build.json +18 -0
- package/tsconfig.json +12 -0
- package/tsconfig.test.json +16 -0
- package/vitest.config.js +12 -0
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
import type { Decoder } from '@simonbackx/simple-encoding';
|
|
2
|
+
import { AutoEncoder, encodeObject, field, ObjectData, StringDecoder } from '@simonbackx/simple-encoding';
|
|
3
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
4
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
5
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
6
|
+
import {
|
|
7
|
+
SGV_FUNCTION_PATH,
|
|
8
|
+
SGV_GROUP_PATH,
|
|
9
|
+
SGV_LOGIN_AUTHORIZE_PATH,
|
|
10
|
+
SGV_LOGIN_TOKEN_PATH,
|
|
11
|
+
SGV_MEMBER_LIST_FILTER_PATH,
|
|
12
|
+
SGV_MEMBER_LIST_PATH,
|
|
13
|
+
SGV_MEMBER_PATH,
|
|
14
|
+
SGV_PROFILE_PATH,
|
|
15
|
+
SGV_SEARCH_SIMILAR_PATH,
|
|
16
|
+
SGVFunctieCreateRequest,
|
|
17
|
+
SGVGFunctieResponse,
|
|
18
|
+
SGVGroepResponse,
|
|
19
|
+
SGVLedenLijstFilterRequest,
|
|
20
|
+
SGVLedenLijstMockResponse,
|
|
21
|
+
SGVLidPatch,
|
|
22
|
+
SGVZoekenResponse,
|
|
23
|
+
} from '@stamhoofd/sgv';
|
|
24
|
+
import { sgvMockState } from '../state/mock-state.js';
|
|
25
|
+
|
|
26
|
+
type Params = { path: string };
|
|
27
|
+
type Query = undefined;
|
|
28
|
+
type Body = unknown;
|
|
29
|
+
type ResponseBody = any;
|
|
30
|
+
|
|
31
|
+
const SGV_DEBUG_PATH = '/';
|
|
32
|
+
const SGV_TEST_RESET_PATH = '/__test/reset';
|
|
33
|
+
const SGV_TEST_STATE_PATH = '/__test/state';
|
|
34
|
+
const SGV_TEST_FAILURES_PATH = '/__test/failures';
|
|
35
|
+
const SGV_MOCK_CLIENT_ID = 'groep-O2209G-Prins-Boudewijn-Wetteren';
|
|
36
|
+
const SGV_MOCK_REFRESH_TOKEN = 'sgv-mock-refresh-token';
|
|
37
|
+
|
|
38
|
+
class SGVMockTokenRequest extends AutoEncoder {
|
|
39
|
+
@field({ decoder: StringDecoder, field: 'grant_type' })
|
|
40
|
+
grantType = '';
|
|
41
|
+
|
|
42
|
+
@field({ decoder: StringDecoder, field: 'client_id' })
|
|
43
|
+
clientId = '';
|
|
44
|
+
|
|
45
|
+
@field({ decoder: StringDecoder, optional: true })
|
|
46
|
+
code?: string;
|
|
47
|
+
|
|
48
|
+
@field({ decoder: StringDecoder, optional: true, field: 'redirect_uri' })
|
|
49
|
+
redirectUri?: string;
|
|
50
|
+
|
|
51
|
+
@field({ decoder: StringDecoder, optional: true, field: 'refresh_token' })
|
|
52
|
+
refreshToken?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Implements the subset of SGV endpoints needed by local sync development and Playwright tests. */
|
|
56
|
+
export class SGVMockEndpoint extends Endpoint<
|
|
57
|
+
Params,
|
|
58
|
+
Query,
|
|
59
|
+
Body,
|
|
60
|
+
ResponseBody
|
|
61
|
+
> {
|
|
62
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
63
|
+
if (!['GET', 'POST', 'PATCH'].includes(request.method)) {
|
|
64
|
+
return [false];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const url = parseUrl(request.url);
|
|
68
|
+
if (
|
|
69
|
+
(request.method === 'GET' && url.pathname === SGV_DEBUG_PATH)
|
|
70
|
+
|| matchesTestPath(url.pathname)
|
|
71
|
+
|| matchesKnownPath(url.pathname)
|
|
72
|
+
) {
|
|
73
|
+
return [true, { path: url.pathname }];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return [false];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
80
|
+
const url = parseUrl(request.url);
|
|
81
|
+
addRequestQuery(url, request.request.query);
|
|
82
|
+
const path = url.pathname;
|
|
83
|
+
const body = await bodyObject(request);
|
|
84
|
+
|
|
85
|
+
sgvMockState.recordCall({
|
|
86
|
+
method: request.method,
|
|
87
|
+
path,
|
|
88
|
+
query: queryObject(url),
|
|
89
|
+
body,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const failure = sgvMockState.consumeFailure(request.method, path);
|
|
93
|
+
if (failure) {
|
|
94
|
+
return json(failure.body ?? { error: 'SGV mock failure' }, failure.status);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (request.method === 'GET' && path === SGV_DEBUG_PATH) {
|
|
98
|
+
return html(renderDebugPage());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (request.method === 'POST' && path === SGV_TEST_RESET_PATH) {
|
|
102
|
+
sgvMockState.reset();
|
|
103
|
+
return json(sgvMockState.snapshot());
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (request.method === 'GET' && path === SGV_TEST_STATE_PATH) {
|
|
107
|
+
return json(sgvMockState.snapshot());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (request.method === 'POST' && path === SGV_TEST_STATE_PATH) {
|
|
111
|
+
try {
|
|
112
|
+
sgvMockState.setState(body);
|
|
113
|
+
return json(sgvMockState.snapshot());
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (request.method === 'POST' && path === SGV_TEST_FAILURES_PATH) {
|
|
120
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
121
|
+
sgvMockState.addFailure(body as any);
|
|
122
|
+
return json(sgvMockState.snapshot());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (request.method === 'GET' && path === SGV_LOGIN_AUTHORIZE_PATH) {
|
|
126
|
+
return redirectToOAuthCallback(url);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (request.method === 'POST' && path === SGV_LOGIN_TOKEN_PATH) {
|
|
130
|
+
return json(tokenResponse(body));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (request.method === 'GET' && path === SGV_GROUP_PATH) {
|
|
134
|
+
return json(SGVGroepResponse.create({ groepen: sgvMockState.groups }));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (request.method === 'GET' && path === SGV_FUNCTION_PATH) {
|
|
138
|
+
return json(SGVGFunctieResponse.create({ functies: sgvMockState.functions }));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (request.method === 'POST' && path === SGV_FUNCTION_PATH) {
|
|
142
|
+
return json(
|
|
143
|
+
sgvMockState.createFunction(decodeObject(body, SGVFunctieCreateRequest as Decoder<SGVFunctieCreateRequest>)),
|
|
144
|
+
201,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (request.method === 'GET' && path === SGV_PROFILE_PATH) {
|
|
149
|
+
return json(sgvMockState.profile());
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (
|
|
153
|
+
request.method === 'PATCH'
|
|
154
|
+
&& path === SGV_MEMBER_LIST_FILTER_PATH
|
|
155
|
+
) {
|
|
156
|
+
sgvMockState.currentFilter = decodeObject(
|
|
157
|
+
body,
|
|
158
|
+
SGVLedenLijstFilterRequest as Decoder<SGVLedenLijstFilterRequest>,
|
|
159
|
+
);
|
|
160
|
+
return json({});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (request.method === 'GET' && path === SGV_MEMBER_LIST_PATH) {
|
|
164
|
+
const offset = numberQuery(url, 'offset', 0);
|
|
165
|
+
const aantal = numberQuery(url, 'aantal', 100);
|
|
166
|
+
return json(SGVLedenLijstMockResponse.create({
|
|
167
|
+
aantal: Math.min(
|
|
168
|
+
aantal,
|
|
169
|
+
Math.max(sgvMockState.members.length - offset, 0),
|
|
170
|
+
),
|
|
171
|
+
offset,
|
|
172
|
+
totaal: sgvMockState.members.length,
|
|
173
|
+
leden: sgvMockState.listMembers(offset, aantal),
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (request.method === 'GET' && path === SGV_SEARCH_SIMILAR_PATH) {
|
|
178
|
+
return json(SGVZoekenResponse.create({
|
|
179
|
+
leden: sgvMockState.searchSimilar(
|
|
180
|
+
url.searchParams.get('voornaam') ?? '',
|
|
181
|
+
url.searchParams.get('achternaam') ?? '',
|
|
182
|
+
),
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (request.method === 'POST' && path === SGV_MEMBER_PATH) {
|
|
187
|
+
return json(
|
|
188
|
+
sgvMockState.createMember(
|
|
189
|
+
decodeObject(body, SGVLidPatch as Decoder<SGVLidPatch>),
|
|
190
|
+
),
|
|
191
|
+
201,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const memberId = memberIdFromPath(path);
|
|
196
|
+
if (memberId && request.method === 'GET') {
|
|
197
|
+
const member = sgvMockState.getMember(memberId);
|
|
198
|
+
if (!member) {
|
|
199
|
+
throw notFound(memberId);
|
|
200
|
+
}
|
|
201
|
+
return json(member);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (memberId && request.method === 'PATCH') {
|
|
205
|
+
const member = sgvMockState.patchMember(
|
|
206
|
+
memberId,
|
|
207
|
+
decodeObject(body, SGVLidPatch as Decoder<SGVLidPatch>),
|
|
208
|
+
);
|
|
209
|
+
if (!member) {
|
|
210
|
+
throw notFound(memberId);
|
|
211
|
+
}
|
|
212
|
+
return json(member);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
throw new SimpleError({
|
|
216
|
+
code: 'not_found',
|
|
217
|
+
message: `Unsupported SGV mock endpoint: ${request.method} ${path}`,
|
|
218
|
+
statusCode: 404,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function matchesTestPath(path: string): boolean {
|
|
224
|
+
return [SGV_TEST_RESET_PATH, SGV_TEST_STATE_PATH, SGV_TEST_FAILURES_PATH].includes(path);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function matchesKnownPath(path: string): boolean {
|
|
228
|
+
return (
|
|
229
|
+
[
|
|
230
|
+
SGV_LOGIN_AUTHORIZE_PATH,
|
|
231
|
+
SGV_LOGIN_TOKEN_PATH,
|
|
232
|
+
SGV_GROUP_PATH,
|
|
233
|
+
SGV_FUNCTION_PATH,
|
|
234
|
+
SGV_PROFILE_PATH,
|
|
235
|
+
SGV_MEMBER_LIST_FILTER_PATH,
|
|
236
|
+
SGV_MEMBER_LIST_PATH,
|
|
237
|
+
SGV_SEARCH_SIMILAR_PATH,
|
|
238
|
+
SGV_MEMBER_PATH,
|
|
239
|
+
].includes(path) || memberIdFromPath(path) !== null
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function memberIdFromPath(path: string): string | null {
|
|
244
|
+
if (!path.startsWith(`${SGV_MEMBER_PATH}/`)) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
const id = path.slice(SGV_MEMBER_PATH.length + 1);
|
|
248
|
+
return id.length > 0 ? decodeURIComponent(id) : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function parseUrl(url: string): URL {
|
|
252
|
+
return new URL(url, 'https://admin.sgv.stamhoofd');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function addRequestQuery(url: URL, query: Record<string, unknown>) {
|
|
256
|
+
for (const [key, value] of Object.entries(query)) {
|
|
257
|
+
if (Array.isArray(value)) {
|
|
258
|
+
for (const item of value) {
|
|
259
|
+
url.searchParams.append(key, `${item}`);
|
|
260
|
+
}
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (value !== undefined && value !== null) {
|
|
265
|
+
// eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions
|
|
266
|
+
url.searchParams.set(key, `${value}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function numberQuery(url: URL, key: string, fallback: number): number {
|
|
272
|
+
const value = Number.parseInt(url.searchParams.get(key) ?? '', 10);
|
|
273
|
+
return Number.isFinite(value) ? value : fallback;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function requiredQuery(url: URL, key: string): string {
|
|
277
|
+
const value = url.searchParams.get(key);
|
|
278
|
+
if (!value) {
|
|
279
|
+
throw new SimpleError({
|
|
280
|
+
code: 'missing_oauth_parameter',
|
|
281
|
+
message: `Missing OAuth parameter: ${key}`,
|
|
282
|
+
statusCode: 400,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return value;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function assertValue(value: string, expected: string, key: string) {
|
|
289
|
+
if (value !== expected) {
|
|
290
|
+
throw oauthError(`Invalid OAuth parameter: ${key}, expected ${expected}, got ${value}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Simulates SGV OAuth by validating required parameters and redirecting directly with a deterministic mock code. */
|
|
295
|
+
function redirectToOAuthCallback(url: URL): Response<undefined> {
|
|
296
|
+
const clientId = requiredQuery(url, 'client_id');
|
|
297
|
+
const redirectUri = requiredQuery(url, 'redirect_uri');
|
|
298
|
+
const state = requiredQuery(url, 'state');
|
|
299
|
+
assertValue(clientId, SGV_MOCK_CLIENT_ID, 'client_id');
|
|
300
|
+
assertValue(requiredQuery(url, 'response_type'), 'code', 'response_type');
|
|
301
|
+
assertValue(requiredQuery(url, 'response_mode'), 'query', 'response_mode');
|
|
302
|
+
assertValue(requiredQuery(url, 'scope'), 'openid', 'scope');
|
|
303
|
+
|
|
304
|
+
const callback = new URL(redirectUri);
|
|
305
|
+
const code = sgvMockState.createAuthorizationCode({ clientId, redirectUri });
|
|
306
|
+
callback.searchParams.set('code', code);
|
|
307
|
+
callback.searchParams.set('state', state);
|
|
308
|
+
|
|
309
|
+
return new Response(undefined, 302, { Location: callback.toString() });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function tokenResponse(body: Record<string, any>) {
|
|
313
|
+
const request = decodeObject(body, SGVMockTokenRequest as Decoder<SGVMockTokenRequest>);
|
|
314
|
+
assertValue(request.clientId, SGV_MOCK_CLIENT_ID, 'client_id');
|
|
315
|
+
|
|
316
|
+
if (request.grantType === 'authorization_code') {
|
|
317
|
+
if (!request.code || !request.redirectUri) {
|
|
318
|
+
throw oauthError('Missing OAuth authorization code parameters');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const details = sgvMockState.consumeAuthorizationCode(request.code);
|
|
322
|
+
if (!details || details.clientId !== request.clientId || details.redirectUri !== request.redirectUri) {
|
|
323
|
+
throw oauthError('Invalid OAuth authorization code', 401);
|
|
324
|
+
}
|
|
325
|
+
return sgvMockState.token();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (request.grantType === 'refresh_token') {
|
|
329
|
+
if (request.refreshToken !== SGV_MOCK_REFRESH_TOKEN) {
|
|
330
|
+
throw oauthError('Invalid OAuth refresh token', 401);
|
|
331
|
+
}
|
|
332
|
+
return sgvMockState.token();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
throw oauthError(`Unsupported OAuth grant type: ${request.grantType}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function oauthError(message: string, statusCode = 400): SimpleError {
|
|
339
|
+
return new SimpleError({
|
|
340
|
+
code: 'invalid_oauth_request',
|
|
341
|
+
message,
|
|
342
|
+
statusCode,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function bodyObject(
|
|
347
|
+
request: DecodedRequest<Params, Query, Body>,
|
|
348
|
+
): Promise<Record<string, any>> {
|
|
349
|
+
const raw = await request.request.body;
|
|
350
|
+
if (!raw) {
|
|
351
|
+
return {};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
356
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
357
|
+
return {};
|
|
358
|
+
}
|
|
359
|
+
return parsed;
|
|
360
|
+
} catch (e) {
|
|
361
|
+
const parsed = new URLSearchParams(raw);
|
|
362
|
+
return Object.fromEntries(parsed.entries());
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function queryObject(url: URL): Record<string, string | string[]> {
|
|
367
|
+
const query: Record<string, string | string[]> = {};
|
|
368
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
369
|
+
const current = query[key];
|
|
370
|
+
if (current === undefined) {
|
|
371
|
+
query[key] = value;
|
|
372
|
+
} else if (Array.isArray(current)) {
|
|
373
|
+
current.push(value);
|
|
374
|
+
} else {
|
|
375
|
+
query[key] = [current, value];
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return query;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function decodeObject<T>(
|
|
382
|
+
body: Record<string, any>,
|
|
383
|
+
decoder: Decoder<T>,
|
|
384
|
+
): T {
|
|
385
|
+
return new ObjectData(body, { version: 0 }).decode(decoder);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function notFound(memberId: string): SimpleError {
|
|
389
|
+
return new SimpleError({
|
|
390
|
+
code: 'not_found',
|
|
391
|
+
message: `SGV member not found: ${memberId}`,
|
|
392
|
+
statusCode: 404,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function json(body: any, status?: number): Response<string> {
|
|
397
|
+
return new Response(JSON.stringify(encodeObject(body, { version: 0 })), status, {
|
|
398
|
+
'Content-Type': 'application/json',
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function html(body: string): Response<string> {
|
|
403
|
+
return new Response(body, 200, {
|
|
404
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Renders the current in-memory mock state for manual debugging during local development. */
|
|
409
|
+
function renderDebugPage(): string {
|
|
410
|
+
const state = JSON.stringify({
|
|
411
|
+
groups: sgvMockState.groups,
|
|
412
|
+
functions: sgvMockState.functions,
|
|
413
|
+
members: sgvMockState.members,
|
|
414
|
+
currentFilter: sgvMockState.currentFilter,
|
|
415
|
+
}).replace(/</g, '\\u003c');
|
|
416
|
+
|
|
417
|
+
return `<!doctype html>
|
|
418
|
+
<html lang="en">
|
|
419
|
+
<head>
|
|
420
|
+
<meta charset="utf-8">
|
|
421
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
422
|
+
<title>SGV mock data</title>
|
|
423
|
+
<style>
|
|
424
|
+
:root { color-scheme: light dark; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
425
|
+
body { margin: 0; background: #f5f5f4; color: #1c1917; }
|
|
426
|
+
main { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
|
427
|
+
h1 { margin: 0 0 8px; font-size: 28px; }
|
|
428
|
+
h2 { margin: 32px 0 12px; font-size: 18px; }
|
|
429
|
+
.muted { color: #78716c; }
|
|
430
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin: 20px 0; }
|
|
431
|
+
.card { padding: 16px; background: white; border: 1px solid #e7e5e4; border-radius: 10px; }
|
|
432
|
+
.card strong { display: block; font-size: 26px; }
|
|
433
|
+
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 12px 0; }
|
|
434
|
+
input, button { font: inherit; border: 1px solid #d6d3d1; border-radius: 8px; padding: 8px 10px; background: white; color: inherit; }
|
|
435
|
+
input { min-width: min(360px, 100%); }
|
|
436
|
+
button { cursor: pointer; }
|
|
437
|
+
table { width: 100%; border-collapse: collapse; background: white; border: 1px solid #e7e5e4; border-radius: 10px; overflow: hidden; }
|
|
438
|
+
th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid #e7e5e4; vertical-align: top; }
|
|
439
|
+
th { background: #fafaf9; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #57534e; }
|
|
440
|
+
tr[data-json] { cursor: pointer; }
|
|
441
|
+
tr[data-json]:hover td { background: #fafaf9; }
|
|
442
|
+
pre { overflow: auto; padding: 12px; background: #1c1917; color: #fafaf9; border-radius: 8px; font-size: 12px; line-height: 1.45; }
|
|
443
|
+
.hidden { display: none; }
|
|
444
|
+
@media (prefers-color-scheme: dark) {
|
|
445
|
+
body { background: #1c1917; color: #fafaf9; }
|
|
446
|
+
.card, input, button, table { background: #292524; border-color: #44403c; }
|
|
447
|
+
th { background: #1c1917; color: #d6d3d1; }
|
|
448
|
+
td, th { border-color: #44403c; }
|
|
449
|
+
tr[data-json]:hover td { background: #44403c; }
|
|
450
|
+
.muted { color: #a8a29e; }
|
|
451
|
+
}
|
|
452
|
+
</style>
|
|
453
|
+
</head>
|
|
454
|
+
<body>
|
|
455
|
+
<main>
|
|
456
|
+
<h1>SGV mock data</h1>
|
|
457
|
+
<p class="muted">Live in-memory state for this mock process. Click rows to inspect full JSON.</p>
|
|
458
|
+
<section class="cards" id="summary"></section>
|
|
459
|
+
<section>
|
|
460
|
+
<h2>Members</h2>
|
|
461
|
+
<div class="toolbar"><input id="memberSearch" type="search" placeholder="Search members by name, id or member number"></div>
|
|
462
|
+
<div id="members"></div>
|
|
463
|
+
</section>
|
|
464
|
+
<section>
|
|
465
|
+
<h2>Functions</h2>
|
|
466
|
+
<div id="functions"></div>
|
|
467
|
+
</section>
|
|
468
|
+
<section>
|
|
469
|
+
<h2>Groups</h2>
|
|
470
|
+
<div id="groups"></div>
|
|
471
|
+
</section>
|
|
472
|
+
<section>
|
|
473
|
+
<h2>Current filter</h2>
|
|
474
|
+
<pre id="currentFilter"></pre>
|
|
475
|
+
</section>
|
|
476
|
+
<section>
|
|
477
|
+
<h2>Raw state</h2>
|
|
478
|
+
<div class="toolbar"><button id="copyRaw" type="button">Copy JSON</button></div>
|
|
479
|
+
<pre id="rawState"></pre>
|
|
480
|
+
</section>
|
|
481
|
+
</main>
|
|
482
|
+
<script>
|
|
483
|
+
const state = ${state};
|
|
484
|
+
const columns = {
|
|
485
|
+
members: [
|
|
486
|
+
['id', item => item.id],
|
|
487
|
+
['First name', item => item.vgagegevens?.voornaam],
|
|
488
|
+
['Last name', item => item.vgagegevens?.achternaam],
|
|
489
|
+
['Birth date', item => item.vgagegevens?.geboortedatum],
|
|
490
|
+
['Member number', item => item.verbondsgegevens?.lidnummer],
|
|
491
|
+
['Functions', item => (item.functies || []).length],
|
|
492
|
+
],
|
|
493
|
+
functions: [
|
|
494
|
+
['id', item => item.id],
|
|
495
|
+
['Description', item => item.beschrijving],
|
|
496
|
+
['Type', item => item.type],
|
|
497
|
+
['Code', item => item.code],
|
|
498
|
+
['Groups', item => (item.groepen || []).join(', ')],
|
|
499
|
+
],
|
|
500
|
+
groups: [
|
|
501
|
+
['Group number', item => item.groepsnummer],
|
|
502
|
+
['Name', item => item.naam || item.naamKort || item.beschrijving],
|
|
503
|
+
['Raw keys', item => Object.keys(item).join(', ')],
|
|
504
|
+
],
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
function text(value) {
|
|
508
|
+
if (value === null || value === undefined || value === '') return '-';
|
|
509
|
+
return String(value);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function renderSummary() {
|
|
513
|
+
const items = [
|
|
514
|
+
['Members', state.members.length],
|
|
515
|
+
['Functions', state.functions.length],
|
|
516
|
+
['Groups', state.groups.length],
|
|
517
|
+
['Current filter', state.currentFilter ? 'set' : 'none'],
|
|
518
|
+
];
|
|
519
|
+
document.getElementById('summary').replaceChildren(...items.map(([label, value]) => {
|
|
520
|
+
const card = document.createElement('div');
|
|
521
|
+
card.className = 'card';
|
|
522
|
+
const strong = document.createElement('strong');
|
|
523
|
+
strong.textContent = value;
|
|
524
|
+
const span = document.createElement('span');
|
|
525
|
+
span.textContent = label;
|
|
526
|
+
card.append(strong, span);
|
|
527
|
+
return card;
|
|
528
|
+
}));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function renderTable(targetId, rows, tableColumns) {
|
|
532
|
+
const table = document.createElement('table');
|
|
533
|
+
const thead = document.createElement('thead');
|
|
534
|
+
const tbody = document.createElement('tbody');
|
|
535
|
+
const header = document.createElement('tr');
|
|
536
|
+
for (const [label] of tableColumns) {
|
|
537
|
+
const th = document.createElement('th');
|
|
538
|
+
th.textContent = label;
|
|
539
|
+
header.append(th);
|
|
540
|
+
}
|
|
541
|
+
thead.append(header);
|
|
542
|
+
|
|
543
|
+
for (const row of rows) {
|
|
544
|
+
const tr = document.createElement('tr');
|
|
545
|
+
tr.dataset.json = JSON.stringify(row, null, 2);
|
|
546
|
+
for (const [, getValue] of tableColumns) {
|
|
547
|
+
const td = document.createElement('td');
|
|
548
|
+
td.textContent = text(getValue(row));
|
|
549
|
+
tr.append(td);
|
|
550
|
+
}
|
|
551
|
+
tbody.append(tr);
|
|
552
|
+
|
|
553
|
+
const detail = document.createElement('tr');
|
|
554
|
+
detail.className = 'hidden';
|
|
555
|
+
const td = document.createElement('td');
|
|
556
|
+
td.colSpan = tableColumns.length;
|
|
557
|
+
const pre = document.createElement('pre');
|
|
558
|
+
pre.textContent = tr.dataset.json;
|
|
559
|
+
td.append(pre);
|
|
560
|
+
detail.append(td);
|
|
561
|
+
tbody.append(detail);
|
|
562
|
+
|
|
563
|
+
tr.addEventListener('click', () => detail.classList.toggle('hidden'));
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
table.append(thead, tbody);
|
|
567
|
+
document.getElementById(targetId).replaceChildren(table);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function memberMatches(member, query) {
|
|
571
|
+
const haystack = [
|
|
572
|
+
member.id,
|
|
573
|
+
member.vgagegevens?.voornaam,
|
|
574
|
+
member.vgagegevens?.achternaam,
|
|
575
|
+
member.vgagegevens?.geboortedatum,
|
|
576
|
+
member.verbondsgegevens?.lidnummer,
|
|
577
|
+
].join(' ').toLowerCase();
|
|
578
|
+
return haystack.includes(query.toLowerCase());
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
renderSummary();
|
|
582
|
+
renderTable('members', state.members, columns.members);
|
|
583
|
+
renderTable('functions', state.functions, columns.functions);
|
|
584
|
+
renderTable('groups', state.groups, columns.groups);
|
|
585
|
+
document.getElementById('currentFilter').textContent = JSON.stringify(state.currentFilter, null, 2);
|
|
586
|
+
document.getElementById('rawState').textContent = JSON.stringify(state, null, 2);
|
|
587
|
+
document.getElementById('memberSearch').addEventListener('input', event => {
|
|
588
|
+
renderTable('members', state.members.filter(member => memberMatches(member, event.target.value)), columns.members);
|
|
589
|
+
});
|
|
590
|
+
document.getElementById('copyRaw').addEventListener('click', async () => {
|
|
591
|
+
await navigator.clipboard.writeText(JSON.stringify(state, null, 2));
|
|
592
|
+
});
|
|
593
|
+
</script>
|
|
594
|
+
</body>
|
|
595
|
+
</html>`;
|
|
596
|
+
}
|