@sdcorejs/angular 21.2.0 → 21.2.1

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.
@@ -0,0 +1,3409 @@
1
+ import * as i0 from '@angular/core';
2
+ import { InjectionToken, makeEnvironmentProviders, input, output, computed, ChangeDetectionStrategy, Component, inject, booleanAttribute, linkedSignal, viewChild, signal, model, effect, untracked, HostBinding } from '@angular/core';
3
+ import { sdIsTemporalValueTransform } from '@sdcorejs/angular/forms/models';
4
+ import { SdTranslatePipe, I18nService } from '@sdcorejs/angular/i18n';
5
+ import { SdButton } from '@sdcorejs/angular/components/button';
6
+ import { SdCodeEditor } from '@sdcorejs/angular/components/code-editor';
7
+ import { SdIcon } from '@sdcorejs/angular/modules/icon';
8
+ import { SdInput } from '@sdcorejs/angular/forms/input';
9
+ import { SdSelect } from '@sdcorejs/angular/forms/select';
10
+ import { SdSideDrawer } from '@sdcorejs/angular/components/side-drawer';
11
+ import { SdDate } from '@sdcorejs/angular/forms/date';
12
+ import { SdDatetime } from '@sdcorejs/angular/forms/datetime';
13
+ import { SdInputNumber } from '@sdcorejs/angular/forms/input-number';
14
+
15
+ const SD_API_CONTRACT_DATA_TYPES = [
16
+ 'string',
17
+ 'number',
18
+ 'boolean',
19
+ 'date',
20
+ 'datetime',
21
+ 'object',
22
+ 'array',
23
+ ];
24
+ const SD_API_CONTRACT_SCALAR_DATA_TYPES = [
25
+ 'string',
26
+ 'number',
27
+ 'boolean',
28
+ 'date',
29
+ 'datetime',
30
+ ];
31
+ function sdIsApiContractDataType(value) {
32
+ return typeof value === 'string' && SD_API_CONTRACT_DATA_TYPES.includes(value);
33
+ }
34
+ function sdIsApiContractScalarDataType(value) {
35
+ return typeof value === 'string' && SD_API_CONTRACT_SCALAR_DATA_TYPES.includes(value);
36
+ }
37
+ function sdIsApiContractTemporalDataType(value) {
38
+ return value === 'date' || value === 'datetime';
39
+ }
40
+ const SD_API_CONTRACT_HTTP_METHODS = [
41
+ 'GET',
42
+ 'POST',
43
+ 'PUT',
44
+ 'PATCH',
45
+ 'DELETE',
46
+ 'HEAD',
47
+ 'OPTIONS',
48
+ ];
49
+ function sdIsApiContractHttpMethod(value) {
50
+ return typeof value === 'string' && SD_API_CONTRACT_HTTP_METHODS.includes(value);
51
+ }
52
+ // ---------------------------------------------------------------------------
53
+ // Contract
54
+ // ---------------------------------------------------------------------------
55
+ /** The only `contractVersion` this release understands. */
56
+ const SD_API_CONTRACT_VERSION = 1;
57
+ const SD_API_CONTRACT_EXPRESSION_ROOTS = ['input', 'env', 'res'];
58
+ const SD_API_CONTRACT_ALLOWED_ROOTS = {
59
+ request: ['input', 'env'],
60
+ output: ['res', 'input', 'env'],
61
+ };
62
+
63
+ /** What the builder falls back to when the host application provides no configuration. */
64
+ const SD_API_CONTRACT_EMPTY_CONFIGURATION = Object.freeze({ env: Object.freeze({}) });
65
+ const SD_API_CONTRACT_CONFIGURATION = new InjectionToken('sd-api-contract.configuration');
66
+ /**
67
+ * Registers the env catalog available to every `<sd-api-contract-builder>` in the injector.
68
+ *
69
+ * ```ts
70
+ * provideSdApiContract({
71
+ * env: {
72
+ * baseUrl: { type: 'string', label: 'Backend base URL' },
73
+ * token: { type: 'string', label: 'Access token', sensitive: true },
74
+ * },
75
+ * });
76
+ * ```
77
+ */
78
+ function provideSdApiContract(configuration) {
79
+ return makeEnvironmentProviders([{ provide: SD_API_CONTRACT_CONFIGURATION, useValue: configuration }]);
80
+ }
81
+ /** Normalizes an optionally-injected configuration into one that is always safe to read. */
82
+ function resolveSdApiContractConfiguration(configuration) {
83
+ if (!configuration || typeof configuration !== 'object' || !configuration.env || typeof configuration.env !== 'object') {
84
+ return SD_API_CONTRACT_EMPTY_CONFIGURATION;
85
+ }
86
+ return configuration;
87
+ }
88
+
89
+ const OPEN = '${';
90
+ const CLOSE = '}';
91
+ // why: chỉ chấp nhận identifier thuần. Mọi thứ khác — `a[0]`, `a.toUpperCase()`, `1 + 1`, `a ? b : c`
92
+ // — trượt regex này và bị từ chối NGAY tại parser, nên không có nhánh nào có thể chạy JavaScript.
93
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
94
+ // why: ba tên này là đường vào prototype chain. Chặn ở parser (không phải ở resolver) để mọi
95
+ // consumer của reference — validator, autocomplete, executor tương lai — đều được bảo vệ như nhau.
96
+ const FORBIDDEN_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
97
+ function isKnownRoot(value) {
98
+ return SD_API_CONTRACT_EXPRESSION_ROOTS.includes(value);
99
+ }
100
+ /**
101
+ * Parses a `source` / URL template into references and errors.
102
+ *
103
+ * Pure string scanning — no `eval`, no `new Function`, no expression evaluation of any kind. The
104
+ * grammar accepts exactly `${<root>.<identifier>(.<identifier>)*}` and nothing else.
105
+ */
106
+ function parseSdApiContractTemplate(source) {
107
+ if (typeof source !== 'string') {
108
+ return { kind: 'literal', valid: true, references: [], errors: [] };
109
+ }
110
+ const spans = [];
111
+ const errors = [];
112
+ let cursor = 0;
113
+ while (cursor <= source.length) {
114
+ const open = source.indexOf(OPEN, cursor);
115
+ if (open < 0)
116
+ break;
117
+ const close = source.indexOf(CLOSE, open + OPEN.length);
118
+ if (close < 0) {
119
+ const raw = source.slice(open);
120
+ spans.push({ open, close: source.length - 1, raw, reference: null });
121
+ errors.push({ code: 'template.unterminated', message: `Expression "${raw}" is missing its closing "}".`, index: open, raw });
122
+ break;
123
+ }
124
+ const raw = source.slice(open, close + 1);
125
+ const inner = source.slice(open + OPEN.length, close);
126
+ const error = validateInner(inner, raw, open);
127
+ if (error) {
128
+ errors.push(error);
129
+ spans.push({ open, close, raw, reference: null });
130
+ }
131
+ else {
132
+ const segments = inner.split('.');
133
+ spans.push({
134
+ open,
135
+ close,
136
+ raw,
137
+ reference: {
138
+ root: segments[0],
139
+ path: segments.slice(1),
140
+ expression: inner,
141
+ raw,
142
+ start: open,
143
+ end: close + 1,
144
+ },
145
+ });
146
+ }
147
+ cursor = close + 1;
148
+ }
149
+ return {
150
+ kind: resolveKind(source, spans),
151
+ valid: errors.length === 0,
152
+ references: spans.map(span => span.reference).filter((reference) => reference !== null),
153
+ errors,
154
+ };
155
+ }
156
+ /** The well-formed references of a template. Malformed expressions are dropped, not thrown. */
157
+ function extractSdApiContractReferences(source) {
158
+ return parseSdApiContractTemplate(source).references;
159
+ }
160
+ function validateInner(inner, raw, index) {
161
+ if (inner.includes(OPEN)) {
162
+ return { code: 'template.nested', message: `Expression "${raw}" nests another "\${".`, index, raw };
163
+ }
164
+ if (inner.trim() === '') {
165
+ return { code: 'template.empty', message: 'Expression "${}" declares no path.', index, raw };
166
+ }
167
+ const segments = inner.split('.');
168
+ // why: kiểm identifier TRƯỚC root — `${input["a"]}` phải báo là path sai, không phải root lạ.
169
+ if (segments.some(segment => !IDENTIFIER.test(segment))) {
170
+ return {
171
+ code: 'template.invalid-path',
172
+ message: `Expression "${raw}" is not a plain dotted path of identifiers.`,
173
+ index,
174
+ raw,
175
+ };
176
+ }
177
+ if (segments.length < 2) {
178
+ return {
179
+ code: 'template.invalid-path',
180
+ message: `Expression "${raw}" needs a root and at least one segment, e.g. "\${env.baseUrl}".`,
181
+ index,
182
+ raw,
183
+ };
184
+ }
185
+ if (!isKnownRoot(segments[0])) {
186
+ return {
187
+ code: 'template.unknown-root',
188
+ message: `Unknown expression root "${segments[0]}" — expected one of ${SD_API_CONTRACT_EXPRESSION_ROOTS.join(', ')}.`,
189
+ index,
190
+ raw,
191
+ };
192
+ }
193
+ const forbidden = segments.slice(1).find(segment => FORBIDDEN_SEGMENTS.has(segment));
194
+ if (forbidden) {
195
+ return { code: 'template.forbidden-segment', message: `Segment "${forbidden}" is not addressable.`, index, raw };
196
+ }
197
+ if (segments[0] === 'env' && segments.length !== 2) {
198
+ return {
199
+ code: 'template.invalid-path',
200
+ message: `Environment references address a single key, e.g. "\${env.baseUrl}" — got "${raw}".`,
201
+ index,
202
+ raw,
203
+ };
204
+ }
205
+ return null;
206
+ }
207
+ function resolveKind(source, spans) {
208
+ if (spans.length === 0)
209
+ return 'literal';
210
+ const only = spans[0];
211
+ const isWholeString = spans.length === 1 && only.reference !== null && only.open === 0 && only.close === source.length - 1;
212
+ return isWholeString ? 'exact' : 'interpolated';
213
+ }
214
+
215
+ // why: `in` và truy cập trực tiếp đều đi qua prototype chain — `properties['constructor']` trả về
216
+ // hàm dựng của Object chứ không phải undefined. Mọi lần đọc key động trong file này phải qua đây.
217
+ function hasOwn$1(record, key) {
218
+ return Object.prototype.hasOwnProperty.call(record, key);
219
+ }
220
+ const PLACEHOLDER_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
221
+ // ---------------------------------------------------------------------------
222
+ // Traversal
223
+ // ---------------------------------------------------------------------------
224
+ /** Flattens a node tree into addressable fields. See `SdApiContractSchemaField` for the convention. */
225
+ function listSdApiContractSchemaFields(node, options) {
226
+ const flatten = (options?.arrays ?? 'flatten') === 'flatten';
227
+ const base = options?.basePath ? options.basePath.split('.') : [];
228
+ const fields = [];
229
+ const descendsInto = (candidate) => {
230
+ if (candidate.type === 'object')
231
+ return true;
232
+ if (candidate.type !== 'array' || !flatten)
233
+ return false;
234
+ const items = candidate.items;
235
+ return !!items && (items.type === 'object' || items.type === 'array');
236
+ };
237
+ const walk = (current, segments, arrayItem) => {
238
+ if (current.type === 'object') {
239
+ const properties = current.properties;
240
+ if (!properties)
241
+ return;
242
+ for (const key of Object.keys(properties)) {
243
+ const child = properties[key];
244
+ if (!child)
245
+ continue;
246
+ const childSegments = [...segments, key];
247
+ fields.push({
248
+ path: childSegments.join('.'),
249
+ segments: childSegments,
250
+ type: child.type,
251
+ required: child.required,
252
+ label: child.label,
253
+ description: child.description,
254
+ leaf: !descendsInto(child),
255
+ arrayItem,
256
+ });
257
+ walk(child, childSegments, arrayItem);
258
+ }
259
+ return;
260
+ }
261
+ if (current.type === 'array' && flatten && current.items) {
262
+ walk(current.items, segments, true);
263
+ }
264
+ };
265
+ walk(node, base, false);
266
+ return fields;
267
+ }
268
+ /** Every `${res.…}` path the output layer may address, in a stable order. */
269
+ function listSdApiContractResponseFields(response) {
270
+ const fields = [
271
+ { path: 'status', segments: ['status'], type: 'number', required: true, leaf: true, arrayItem: false },
272
+ ];
273
+ const headers = response.headers;
274
+ if (headers) {
275
+ for (const key of Object.keys(headers)) {
276
+ const node = headers[key];
277
+ if (!node)
278
+ continue;
279
+ fields.push({
280
+ path: `headers.${key}`,
281
+ segments: ['headers', key],
282
+ type: node.type,
283
+ required: node.required,
284
+ label: node.label,
285
+ description: node.description,
286
+ leaf: true,
287
+ arrayItem: false,
288
+ });
289
+ }
290
+ }
291
+ const body = response.body;
292
+ if (body) {
293
+ fields.push({
294
+ path: 'body',
295
+ segments: ['body'],
296
+ type: body.type,
297
+ required: body.required,
298
+ label: body.label,
299
+ description: body.description,
300
+ leaf: body.type !== 'object',
301
+ arrayItem: false,
302
+ });
303
+ fields.push(...listSdApiContractSchemaFields(body, { arrays: 'stop', basePath: 'body' }));
304
+ }
305
+ return fields;
306
+ }
307
+ /**
308
+ * Resolves a *logical* reference path (`customer.id`) against a schema.
309
+ *
310
+ * Arrays are terminal: `${res.body.items}` addresses the whole array, `${res.body.items.id}` does
311
+ * not exist because there is no element to address. Per-item projection is out of scope.
312
+ */
313
+ function resolveSdApiContractSchemaPath(root, path) {
314
+ let current = root;
315
+ for (const segment of path) {
316
+ if (!current || current.type !== 'object')
317
+ return null;
318
+ const properties = current.properties;
319
+ if (!properties || !hasOwn$1(properties, segment))
320
+ return null;
321
+ current = properties[segment];
322
+ }
323
+ return current ?? null;
324
+ }
325
+ /** Resolves `status` / `headers.<name>` / `body.<path>` against a response declaration. */
326
+ function resolveSdApiContractResponsePath(response, path) {
327
+ if (path.length === 0)
328
+ return null;
329
+ const [section, ...rest] = path;
330
+ if (section === 'status') {
331
+ return rest.length === 0 ? { type: 'number', required: true, node: null } : null;
332
+ }
333
+ if (section === 'headers') {
334
+ const headers = response.headers;
335
+ if (rest.length !== 1 || !headers || !hasOwn$1(headers, rest[0]))
336
+ return null;
337
+ return describe(headers[rest[0]]);
338
+ }
339
+ if (section === 'body') {
340
+ const body = response.body;
341
+ if (!body)
342
+ return null;
343
+ const node = resolveSdApiContractSchemaPath(body, rest);
344
+ return node ? describe(node) : null;
345
+ }
346
+ return null;
347
+ }
348
+ function describe(node) {
349
+ return { type: node.type, required: node.required, label: node.label, description: node.description, node };
350
+ }
351
+ // ---------------------------------------------------------------------------
352
+ // Immutable structural editing
353
+ // ---------------------------------------------------------------------------
354
+ /** Reads the node a structural pointer addresses, or `null` when the pointer does not resolve. */
355
+ function getSdApiContractNodeAt(root, pointer) {
356
+ let current = root;
357
+ for (let index = 0; index < pointer.length; index += 1) {
358
+ if (!current)
359
+ return null;
360
+ const segment = pointer[index];
361
+ if (segment === 'properties') {
362
+ const key = pointer[index + 1];
363
+ const properties = current.properties;
364
+ if (key === undefined || !properties || !hasOwn$1(properties, key))
365
+ return null;
366
+ current = properties[key];
367
+ index += 1;
368
+ }
369
+ else if (segment === 'items') {
370
+ current = current.items;
371
+ }
372
+ else {
373
+ return null;
374
+ }
375
+ }
376
+ return current ?? null;
377
+ }
378
+ /** Replaces the node a pointer addresses, rebuilding only the spine. Never mutates `root`. */
379
+ function setSdApiContractNodeAt(root, pointer, node) {
380
+ return replaceAt(root, pointer, 0, node);
381
+ }
382
+ function replaceAt(current, pointer, index, next) {
383
+ if (index >= pointer.length)
384
+ return next;
385
+ const segment = pointer[index];
386
+ if (segment === 'properties') {
387
+ const key = pointer[index + 1];
388
+ const properties = current.properties;
389
+ // why: pointer trỏ vào chỗ không tồn tại thì trả nguyên object cũ — im lặng bỏ qua an toàn hơn
390
+ // là dựng ra nhánh rỗng mà người dùng không hề khai báo.
391
+ if (key === undefined || !properties || !hasOwn$1(properties, key))
392
+ return current;
393
+ return { ...current, properties: { ...properties, [key]: replaceAt(properties[key], pointer, index + 2, next) } };
394
+ }
395
+ if (segment === 'items') {
396
+ if (!current.items)
397
+ return current;
398
+ return { ...current, items: replaceAt(current.items, pointer, index + 1, next) };
399
+ }
400
+ return current;
401
+ }
402
+ /** Appends a property. A key that already exists is left untouched — the caller must dedupe first. */
403
+ function addSdApiContractProperty(node, key, child) {
404
+ const properties = node.properties ?? {};
405
+ if (!key || hasOwn$1(properties, key))
406
+ return asObjectShape(node, properties);
407
+ return { ...node, type: 'object', properties: { ...properties, [key]: child } };
408
+ }
409
+ /** Renames a property **in place in the key order**, so the JSON diff stays readable. */
410
+ function renameSdApiContractProperty(node, from, to) {
411
+ const properties = node.properties;
412
+ if (!properties || !hasOwn$1(properties, from) || !to || from === to || hasOwn$1(properties, to)) {
413
+ return node;
414
+ }
415
+ const next = {};
416
+ for (const key of Object.keys(properties))
417
+ next[key === from ? to : key] = properties[key];
418
+ return { ...node, type: 'object', properties: next };
419
+ }
420
+ function removeSdApiContractProperty(node, key) {
421
+ const properties = node.properties;
422
+ if (!properties || !hasOwn$1(properties, key))
423
+ return node;
424
+ const next = {};
425
+ for (const existing of Object.keys(properties)) {
426
+ if (existing !== key)
427
+ next[existing] = properties[existing];
428
+ }
429
+ return { ...node, type: 'object', properties: next };
430
+ }
431
+ function asObjectShape(node, properties) {
432
+ return node.type === 'object' && node.properties ? node : { ...node, type: 'object', properties };
433
+ }
434
+ /** A minimal well-formed node of the given type. */
435
+ function createSdApiContractNode(type) {
436
+ if (type === 'object')
437
+ return { type: 'object', properties: {} };
438
+ if (type === 'array')
439
+ return { type: 'array', items: { type: 'string' } };
440
+ return { type };
441
+ }
442
+ /**
443
+ * Retypes a node, dropping the members the new type cannot carry.
444
+ *
445
+ * Returns the same reference when the type is unchanged, so an idempotent UI write never produces a
446
+ * spurious `modelChange`.
447
+ */
448
+ function changeSdApiContractNodeType(node, type) {
449
+ if (node.type === type)
450
+ return node;
451
+ const next = { type };
452
+ if (node.required !== undefined)
453
+ next.required = node.required;
454
+ if (node.label !== undefined)
455
+ next.label = node.label;
456
+ if (node.description !== undefined)
457
+ next.description = node.description;
458
+ if (node.source !== undefined)
459
+ next.source = node.source;
460
+ if (node.value !== undefined)
461
+ next.value = node.value;
462
+ if (node.transform !== undefined && sdIsApiContractTemporalDataType(type))
463
+ next.transform = node.transform;
464
+ if (type === 'object')
465
+ next.properties = {};
466
+ if (type === 'array')
467
+ next.items = { type: 'string' };
468
+ return next;
469
+ }
470
+ /** Deep copy of a node subtree. Used when a response subtree is adopted as the output schema. */
471
+ function cloneSdApiContractNode(node) {
472
+ return deepClone(node);
473
+ }
474
+ /**
475
+ * Deep copy of a whole contract.
476
+ *
477
+ * The builder clones on the way in so the object a parent owns is never reachable from an edit, and
478
+ * a consumer can do the same before handing a contract to anything that might mutate it.
479
+ */
480
+ function cloneSdApiContract(contract) {
481
+ return deepClone(contract);
482
+ }
483
+ /** Deep copy of any JSON-shaped value. Own enumerable keys only, so nothing inherited leaks in. */
484
+ function deepClone(value) {
485
+ if (Array.isArray(value))
486
+ return value.map(item => deepClone(item));
487
+ if (value && typeof value === 'object') {
488
+ const source = value;
489
+ const out = {};
490
+ for (const key of Object.keys(source))
491
+ out[key] = deepClone(source[key]);
492
+ return out;
493
+ }
494
+ return value;
495
+ }
496
+ // ---------------------------------------------------------------------------
497
+ // Record helpers (`req.path` / `req.query` / `req.headers` / `res.headers`)
498
+ // ---------------------------------------------------------------------------
499
+ function sdApiContractRecordSet(record, key, value) {
500
+ return { ...(record ?? {}), [key]: value };
501
+ }
502
+ function sdApiContractRecordRemove(record, key) {
503
+ if (!hasOwn$1(record, key))
504
+ return record;
505
+ const next = {};
506
+ for (const existing of Object.keys(record)) {
507
+ if (existing !== key)
508
+ next[existing] = record[existing];
509
+ }
510
+ return next;
511
+ }
512
+ /** Renames a key in place. A collision or an empty target is a no-op — the caller reports it. */
513
+ function sdApiContractRecordRename(record, from, to) {
514
+ if (!hasOwn$1(record, from) || !to || from === to || hasOwn$1(record, to))
515
+ return record;
516
+ const next = {};
517
+ for (const key of Object.keys(record))
518
+ next[key === from ? to : key] = record[key];
519
+ return next;
520
+ }
521
+ // ---------------------------------------------------------------------------
522
+ // Formatting
523
+ // ---------------------------------------------------------------------------
524
+ /** Builds the canonical expression text — the inverse of `parseSdApiContractTemplate`. */
525
+ function formatSdApiContractExpression(root, path) {
526
+ return `\${${[root, ...path].join('.')}}`;
527
+ }
528
+ /** Joins a diagnostic base path with a structural pointer, e.g. `req.body` + `properties.x`. */
529
+ function formatSdApiContractPointer(base, pointer) {
530
+ return pointer.length === 0 ? base : `${base}.${pointer.join('.')}`;
531
+ }
532
+ /**
533
+ * Reads REST placeholders out of a URL template.
534
+ *
535
+ * `${…}` interpolation is masked out first, so `${env.baseUrl}` is never mistaken for a `{…}`
536
+ * path placeholder.
537
+ */
538
+ function parseSdApiContractUrlPlaceholders(url) {
539
+ if (typeof url !== 'string')
540
+ return { names: [], duplicates: [], malformed: [] };
541
+ const masked = url.replace(/\$\{[^}]*\}/g, match => ' '.repeat(match.length));
542
+ const names = [];
543
+ const seen = new Set();
544
+ const duplicates = [];
545
+ const malformed = [];
546
+ let cursor = 0;
547
+ while (cursor < masked.length) {
548
+ const open = masked.indexOf('{', cursor);
549
+ if (open < 0)
550
+ break;
551
+ const close = masked.indexOf('}', open + 1);
552
+ if (close < 0) {
553
+ malformed.push(url.slice(open));
554
+ break;
555
+ }
556
+ const inner = masked.slice(open + 1, close);
557
+ if (!PLACEHOLDER_NAME.test(inner)) {
558
+ malformed.push(url.slice(open, close + 1));
559
+ }
560
+ else if (seen.has(inner)) {
561
+ if (!duplicates.includes(inner))
562
+ duplicates.push(inner);
563
+ }
564
+ else {
565
+ seen.add(inner);
566
+ names.push(inner);
567
+ }
568
+ cursor = close + 1;
569
+ }
570
+ return { names, duplicates, malformed };
571
+ }
572
+
573
+ /**
574
+ * Deterministic JSON for an API contract.
575
+ *
576
+ * Three guarantees the persisted file depends on:
577
+ *
578
+ * 1. **System keys are ordered**, so two authors editing the same contract produce the same bytes
579
+ * and a `git diff` shows the semantic change instead of a reshuffle.
580
+ * 2. **User-declared keys keep their order** (`properties`, `query`, `headers`, …) — that order is
581
+ * authored information, and sorting it would churn every diff.
582
+ * 3. **Only contract vocabulary survives.** The builder's transient UI state (expansion, selection,
583
+ * internal ids) is dropped by construction: the serializer copies a fixed key whitelist rather
584
+ * than the object it was handed, so a new piece of UI state can never leak into the file.
585
+ *
586
+ * `undefined` members are omitted; declared `false`, `0`, `null` and `""` are kept.
587
+ */
588
+ function serializeSdApiContract(contract) {
589
+ return JSON.stringify(normalizeContract(contract), null, 2);
590
+ }
591
+ function isRecord$1(value) {
592
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
593
+ }
594
+ function put(target, key, value) {
595
+ if (value !== undefined)
596
+ target[key] = value;
597
+ }
598
+ function normalizeContract(contract) {
599
+ if (!isRecord$1(contract))
600
+ return null;
601
+ const out = {};
602
+ put(out, 'contractVersion', contract['contractVersion']);
603
+ put(out, 'code', contract['code']);
604
+ put(out, 'name', contract['name']);
605
+ put(out, 'description', contract['description']);
606
+ put(out, 'input', normalizeSchemaHolder(contract['input']));
607
+ put(out, 'req', normalizeRequest(contract['req']));
608
+ put(out, 'res', normalizeResponse(contract['res']));
609
+ put(out, 'output', normalizeSchemaHolder(contract['output']));
610
+ return out;
611
+ }
612
+ function normalizeSchemaHolder(holder) {
613
+ if (!isRecord$1(holder))
614
+ return undefined;
615
+ const out = {};
616
+ put(out, 'schema', normalizeNode(holder['schema']));
617
+ return out;
618
+ }
619
+ function normalizeRequest(request) {
620
+ if (!isRecord$1(request))
621
+ return undefined;
622
+ const out = {};
623
+ put(out, 'method', request['method']);
624
+ put(out, 'url', request['url']);
625
+ put(out, 'path', normalizeNodeRecord(request['path']));
626
+ put(out, 'query', normalizeNodeRecord(request['query']));
627
+ put(out, 'headers', normalizeNodeRecord(request['headers']));
628
+ put(out, 'body', normalizeNode(request['body']));
629
+ return out;
630
+ }
631
+ function normalizeResponse(response) {
632
+ if (!isRecord$1(response))
633
+ return undefined;
634
+ const out = {};
635
+ put(out, 'status', Array.isArray(response['status']) ? [...response['status']] : response['status']);
636
+ put(out, 'headers', normalizeNodeRecord(response['headers']));
637
+ put(out, 'body', normalizeNode(response['body']));
638
+ return out;
639
+ }
640
+ function normalizeNode(node) {
641
+ if (!isRecord$1(node))
642
+ return undefined;
643
+ const out = {};
644
+ put(out, 'type', node['type']);
645
+ put(out, 'required', node['required']);
646
+ put(out, 'label', node['label']);
647
+ put(out, 'description', node['description']);
648
+ put(out, 'transform', node['transform']);
649
+ put(out, 'source', node['source']);
650
+ put(out, 'value', normalizeJsonValue(node['value']));
651
+ put(out, 'properties', normalizeNodeRecord(node['properties']));
652
+ put(out, 'items', normalizeNode(node['items']));
653
+ return out;
654
+ }
655
+ function normalizeNodeRecord(record) {
656
+ if (!isRecord$1(record))
657
+ return undefined;
658
+ const out = {};
659
+ for (const key of Object.keys(record)) {
660
+ const node = normalizeNode(record[key]);
661
+ if (node !== undefined)
662
+ out[key] = node;
663
+ }
664
+ return out;
665
+ }
666
+ // why: static literal là dữ liệu tự do của người dùng — copy nguyên hình dạng (giữ null, 0, '',
667
+ // thứ tự key) thay vì lọc theo whitelist như node.
668
+ function normalizeJsonValue(value) {
669
+ if (Array.isArray(value))
670
+ return value.map(item => normalizeJsonValue(item));
671
+ if (isRecord$1(value)) {
672
+ const out = {};
673
+ for (const key of Object.keys(value)) {
674
+ const normalized = normalizeJsonValue(value[key]);
675
+ if (normalized !== undefined)
676
+ out[key] = normalized;
677
+ }
678
+ return out;
679
+ }
680
+ return value;
681
+ }
682
+
683
+ /**
684
+ * Validates a contract against the grammar, the schema rules, the REST rules and the injected env
685
+ * catalog.
686
+ *
687
+ * Pure and UI-free: it takes `unknown` because an externally supplied contract may be malformed,
688
+ * and it **never repairs anything** — a silent fix would hide the very mistake the author needs to
689
+ * see. Diagnostics come back in a fixed traversal order (metadata → `input` → `req` → `res` →
690
+ * `output`, declaration order within each), so the same contract always yields the same list.
691
+ */
692
+ function validateSdApiContract(contract, configuration) {
693
+ const diagnostics = [];
694
+ if (!isRecord(contract)) {
695
+ return [{ code: 'contract.invalid', severity: 'error', path: '', message: 'A contract must be an object.' }];
696
+ }
697
+ const env = resolveSdApiContractConfiguration(configuration).env;
698
+ const inputSchema = readSchema(contract['input']);
699
+ const response = isRecord(contract['res']) ? contract['res'] : null;
700
+ const push = (code, severity, path, message) => {
701
+ diagnostics.push({ code, severity, path, message });
702
+ };
703
+ // -------------------------------------------------------------------------
704
+ // Reference resolution
705
+ // -------------------------------------------------------------------------
706
+ const resolveReference = (reference) => {
707
+ if (reference.root === 'env') {
708
+ const key = reference.path[0];
709
+ if (!key || !hasOwn(env, key))
710
+ return null;
711
+ const variable = env[key];
712
+ return { type: variable.type, required: true, label: variable.label, description: variable.description, node: null };
713
+ }
714
+ if (reference.root === 'input') {
715
+ if (!inputSchema)
716
+ return null;
717
+ const node = resolveSdApiContractSchemaPath(inputSchema, reference.path);
718
+ return node ? { type: node.type, required: node.required, label: node.label, description: node.description, node } : null;
719
+ }
720
+ if (!response)
721
+ return null;
722
+ return resolveSdApiContractResponsePath(response, reference.path);
723
+ };
724
+ /** Checks roots and existence for every reference. Returns `false` when a root was rejected. */
725
+ const checkReferences = (references, path, context) => {
726
+ const allowed = SD_API_CONTRACT_ALLOWED_ROOTS[context];
727
+ let rootsOk = true;
728
+ for (const reference of references) {
729
+ if (!allowed.includes(reference.root)) {
730
+ rootsOk = false;
731
+ push('mapping.root.forbidden', 'error', path, `"${reference.raw}" is not readable here — this position accepts ${allowed.map(root => `\${${root}.…}`).join(', ')}.`);
732
+ continue;
733
+ }
734
+ if (resolveReference(reference))
735
+ continue;
736
+ if (reference.root === 'env') {
737
+ push('mapping.env.unknown', 'error', path, `Environment variable "${reference.path.join('.')}" is not declared in the injected configuration.`);
738
+ }
739
+ else {
740
+ push('mapping.reference.missing', 'error', path, `"${reference.raw}" does not resolve to a declared field.`);
741
+ }
742
+ }
743
+ return rootsOk;
744
+ };
745
+ // -------------------------------------------------------------------------
746
+ // Node structure — shared by every layer
747
+ // -------------------------------------------------------------------------
748
+ const validateStructure = (node, path, allowTransform, requireContainerMembers) => {
749
+ const type = node['type'];
750
+ if (!sdIsApiContractDataType(type)) {
751
+ push('schema.type.invalid', 'error', path, `"${String(type)}" is not a supported data type.`);
752
+ return false;
753
+ }
754
+ if (type === 'object' && requireContainerMembers && !isRecord(node['properties'])) {
755
+ push('schema.object.properties.missing', 'error', path, 'An object node must declare "properties".');
756
+ }
757
+ if (type === 'array' && !isRecord(node['items'])) {
758
+ push('schema.array.items.missing', 'error', path, 'An array node must declare "items".');
759
+ }
760
+ if (sdIsApiContractScalarDataType(type)) {
761
+ if (node['properties'] !== undefined)
762
+ push('schema.scalar.properties.forbidden', 'error', path, `A "${type}" node cannot declare "properties".`);
763
+ if (node['items'] !== undefined)
764
+ push('schema.scalar.items.forbidden', 'error', path, `A "${type}" node cannot declare "items".`);
765
+ }
766
+ const required = node['required'];
767
+ if (required !== undefined && typeof required !== 'boolean') {
768
+ push('schema.required.invalid', 'error', path, '"required" must be true, false, or omitted.');
769
+ }
770
+ const transform = node['transform'];
771
+ if (transform !== undefined) {
772
+ if (!allowTransform || !sdIsApiContractTemporalDataType(type)) {
773
+ push('schema.transform.invalid', 'error', path, '"transform" is only valid on a date or datetime node in input.schema / output.schema.');
774
+ }
775
+ else if (!sdIsTemporalValueTransform(transform)) {
776
+ push('schema.transform.unknown', 'error', path, `"${String(transform)}" is not a known temporal transform.`);
777
+ }
778
+ }
779
+ return true;
780
+ };
781
+ const checkDuplicateKeysIgnoringCase = (record, basePath) => {
782
+ const seen = new Map();
783
+ for (const key of Object.keys(record)) {
784
+ const normalized = key.trim().toLowerCase();
785
+ if (!normalized)
786
+ continue;
787
+ const previous = seen.get(normalized);
788
+ if (previous !== undefined) {
789
+ push('schema.property.key.duplicate', 'error', `${basePath}.${key}`, `"${key}" collides with "${previous}" — header names are case-insensitive.`);
790
+ }
791
+ else {
792
+ seen.set(normalized, key);
793
+ }
794
+ }
795
+ };
796
+ // -------------------------------------------------------------------------
797
+ // Declaration layers — `input.schema`, `res.headers`, `res.body`
798
+ // -------------------------------------------------------------------------
799
+ const validateDeclaration = (node, path, allowTransform) => {
800
+ if (!isRecord(node))
801
+ return;
802
+ if (!validateStructure(node, path, allowTransform, true))
803
+ return;
804
+ if (node['source'] !== undefined || node['value'] !== undefined) {
805
+ push('schema.mapping.forbidden', 'error', path, 'A declaration cannot carry "source" or "value" — nothing maps into it.');
806
+ }
807
+ const type = node['type'];
808
+ const properties = node['properties'];
809
+ if (type === 'object' && isRecord(properties)) {
810
+ for (const key of Object.keys(properties)) {
811
+ const childPath = `${path}.properties.${key}`;
812
+ if (!key.trim())
813
+ push('schema.property.key.empty', 'error', childPath, 'A property name cannot be empty.');
814
+ validateDeclaration(properties[key], childPath, allowTransform);
815
+ }
816
+ }
817
+ if (type === 'array' && isRecord(node['items'])) {
818
+ validateDeclaration(node['items'], `${path}.items`, allowTransform);
819
+ }
820
+ };
821
+ // -------------------------------------------------------------------------
822
+ // Mapped layers — `req.*`, `output.schema`
823
+ // -------------------------------------------------------------------------
824
+ const validateStaticValue = (value, type, path) => {
825
+ if (value === null)
826
+ return;
827
+ const ok = type === 'string' || type === 'date' || type === 'datetime'
828
+ ? typeof value === 'string'
829
+ : type === 'number'
830
+ ? typeof value === 'number' && Number.isFinite(value)
831
+ : type === 'boolean'
832
+ ? typeof value === 'boolean'
833
+ : type === 'object'
834
+ ? isRecord(value)
835
+ : Array.isArray(value);
836
+ if (!ok)
837
+ push('mapping.value.type-mismatch', 'error', path, `The static value does not fit the declared type "${type}".`);
838
+ };
839
+ const validateSource = (node, type, path, context) => {
840
+ const source = node['source'];
841
+ if (typeof source !== 'string') {
842
+ push('mapping.template.invalid', 'error', path, '"source" must be a string template.');
843
+ return;
844
+ }
845
+ const template = parseSdApiContractTemplate(source);
846
+ if (!template.valid) {
847
+ push('mapping.template.invalid', 'error', path, template.errors[0].message);
848
+ return;
849
+ }
850
+ if (!checkReferences(template.references, path, context))
851
+ return;
852
+ if (template.kind === 'exact') {
853
+ const resolved = resolveReference(template.references[0]);
854
+ if (!resolved)
855
+ return;
856
+ if (!isTypeCompatible(resolved.type, type)) {
857
+ push('mapping.type.mismatch', 'error', path, `"${source}" resolves to "${resolved.type}", which cannot fill a "${type}" node.`);
858
+ }
859
+ if (context === 'output' && node['required'] === true && resolved.required !== true) {
860
+ push('mapping.required.optional-source', 'warning', path, `A required output field is fed by "${source}", which is not declared required.`);
861
+ }
862
+ return;
863
+ }
864
+ // why: literal + interpolated đều cho ra string. `req.url` không đi qua đây nên vẫn nội suy tự do.
865
+ if (type !== 'string') {
866
+ push('mapping.interpolation.forbidden', 'error', path, `String interpolation can only fill a "string" node, not "${type}".`);
867
+ }
868
+ };
869
+ const validateMapped = (node, path, context, covered, allowTransform) => {
870
+ if (!isRecord(node))
871
+ return;
872
+ const hasSource = node['source'] !== undefined;
873
+ const hasValue = node['value'] !== undefined;
874
+ const wholeNodeMapped = hasSource || hasValue;
875
+ if (!validateStructure(node, path, allowTransform, !wholeNodeMapped))
876
+ return;
877
+ const type = node['type'];
878
+ if (hasSource && hasValue) {
879
+ push('mapping.source-and-value', 'error', path, '"source" and "value" are mutually exclusive.');
880
+ }
881
+ const properties = node['properties'];
882
+ const childCount = type === 'object' && isRecord(properties) ? Object.keys(properties).length : 0;
883
+ if (wholeNodeMapped && childCount > 0) {
884
+ push('mapping.object.conflict', 'error', path, 'An object mapped as a whole cannot also map its properties.');
885
+ }
886
+ if (hasSource)
887
+ validateSource(node, type, path, context);
888
+ else if (hasValue)
889
+ validateStaticValue(node['value'], type, path);
890
+ else if (!covered && (type !== 'object' || childCount === 0)) {
891
+ push('mapping.node.unmapped', 'warning', path, 'This node receives no value — declare a "source" or a static "value".');
892
+ }
893
+ const childCovered = covered || wholeNodeMapped;
894
+ if (type === 'object' && isRecord(properties)) {
895
+ for (const key of Object.keys(properties)) {
896
+ const childPath = `${path}.properties.${key}`;
897
+ if (!key.trim())
898
+ push('schema.property.key.empty', 'error', childPath, 'A property name cannot be empty.');
899
+ validateMapped(properties[key], childPath, context, childCovered, allowTransform);
900
+ }
901
+ }
902
+ if (type === 'array' && isRecord(node['items'])) {
903
+ validateMapped(node['items'], `${path}.items`, context, childCovered, allowTransform);
904
+ }
905
+ };
906
+ // -------------------------------------------------------------------------
907
+ // 1. Contract metadata
908
+ // -------------------------------------------------------------------------
909
+ if (contract['contractVersion'] !== SD_API_CONTRACT_VERSION) {
910
+ push('contract.version.invalid', 'error', 'contractVersion', `"contractVersion" must be ${SD_API_CONTRACT_VERSION}.`);
911
+ }
912
+ if (!isFilledString(contract['code']))
913
+ push('contract.code.empty', 'error', 'code', 'A contract needs a non-empty "code".');
914
+ if (!isFilledString(contract['name']))
915
+ push('contract.name.empty', 'error', 'name', 'A contract needs a non-empty "name".');
916
+ // -------------------------------------------------------------------------
917
+ // 2. input.schema
918
+ // -------------------------------------------------------------------------
919
+ if (!inputSchema)
920
+ push('schema.missing', 'error', 'input.schema', '"input.schema" is missing.');
921
+ else
922
+ validateDeclaration(inputSchema, 'input.schema', true);
923
+ // -------------------------------------------------------------------------
924
+ // 3. req
925
+ // -------------------------------------------------------------------------
926
+ const request = isRecord(contract['req']) ? contract['req'] : null;
927
+ if (!request) {
928
+ push('req.method.invalid', 'error', 'req.method', 'The request is missing.');
929
+ push('req.url.empty', 'error', 'req.url', 'The request is missing.');
930
+ }
931
+ else {
932
+ const method = request['method'];
933
+ if (!sdIsApiContractHttpMethod(method)) {
934
+ push('req.method.invalid', 'error', 'req.method', `"${String(method)}" is not a supported HTTP method.`);
935
+ }
936
+ const url = request['url'];
937
+ const placeholders = isFilledString(url) ? parseSdApiContractUrlPlaceholders(url) : { names: [], duplicates: [], malformed: [] };
938
+ if (!isFilledString(url)) {
939
+ push('req.url.empty', 'error', 'req.url', 'A request needs a non-empty "url".');
940
+ }
941
+ else {
942
+ const template = parseSdApiContractTemplate(url);
943
+ if (!template.valid)
944
+ push('req.url.template.invalid', 'error', 'req.url', template.errors[0].message);
945
+ else
946
+ checkReferences(template.references, 'req.url', 'request');
947
+ for (const fragment of placeholders.malformed) {
948
+ push('req.url.placeholder.malformed', 'error', 'req.url', `"${fragment}" is not a valid path placeholder.`);
949
+ }
950
+ for (const name of placeholders.duplicates) {
951
+ push('req.url.placeholder.duplicate', 'error', 'req.url', `Path placeholder "{${name}}" appears more than once.`);
952
+ }
953
+ }
954
+ const pathRecord = isRecord(request['path']) ? request['path'] : null;
955
+ const declaredPathKeys = pathRecord ? Object.keys(pathRecord) : [];
956
+ for (const name of placeholders.names) {
957
+ if (!declaredPathKeys.includes(name)) {
958
+ push('req.path.missing', 'error', `req.path.${name}`, `The url declares "{${name}}" but "req.path" has no entry for it.`);
959
+ }
960
+ }
961
+ for (const key of declaredPathKeys) {
962
+ if (!placeholders.names.includes(key)) {
963
+ push('req.path.unused', 'error', `req.path.${key}`, `"req.path.${key}" has no matching "{${key}}" in the url.`);
964
+ }
965
+ }
966
+ if (pathRecord) {
967
+ for (const key of declaredPathKeys) {
968
+ const entryPath = `req.path.${key}`;
969
+ const entry = pathRecord[key];
970
+ if (!key.trim())
971
+ push('schema.property.key.empty', 'error', entryPath, 'A path parameter name cannot be empty.');
972
+ if (isRecord(entry)) {
973
+ if (entry['required'] !== true) {
974
+ push('req.path.required', 'error', entryPath, 'A path parameter is part of the url and must be declared "required": true.');
975
+ }
976
+ if (entry['type'] !== undefined && !sdIsApiContractScalarDataType(entry['type'])) {
977
+ push('req.path.type.invalid', 'error', entryPath, 'A path parameter must be a scalar.');
978
+ }
979
+ }
980
+ validateMapped(entry, entryPath, 'request', false, false);
981
+ }
982
+ }
983
+ const queryRecord = isRecord(request['query']) ? request['query'] : null;
984
+ if (queryRecord) {
985
+ for (const key of Object.keys(queryRecord)) {
986
+ const entryPath = `req.query.${key}`;
987
+ const entry = queryRecord[key];
988
+ if (!key.trim())
989
+ push('schema.property.key.empty', 'error', entryPath, 'A query parameter name cannot be empty.');
990
+ if (isRecord(entry) && entry['type'] !== undefined && !sdIsApiContractScalarDataType(entry['type']) && entry['type'] !== 'array') {
991
+ push('req.query.type.invalid', 'error', entryPath, 'A query parameter must be a scalar or an array of scalars.');
992
+ }
993
+ validateMapped(entry, entryPath, 'request', false, false);
994
+ }
995
+ }
996
+ const headerRecord = isRecord(request['headers']) ? request['headers'] : null;
997
+ if (headerRecord) {
998
+ checkDuplicateKeysIgnoringCase(headerRecord, 'req.headers');
999
+ for (const key of Object.keys(headerRecord)) {
1000
+ const entryPath = `req.headers.${key}`;
1001
+ const entry = headerRecord[key];
1002
+ if (!key.trim())
1003
+ push('req.header.name.empty', 'error', entryPath, 'A header name cannot be empty.');
1004
+ if (isRecord(entry) && entry['type'] !== undefined && !sdIsApiContractScalarDataType(entry['type'])) {
1005
+ push('req.header.type.invalid', 'error', entryPath, 'A header must be a scalar.');
1006
+ }
1007
+ validateMapped(entry, entryPath, 'request', false, false);
1008
+ }
1009
+ }
1010
+ if (request['body'] !== undefined) {
1011
+ if (method === 'GET' || method === 'HEAD') {
1012
+ push('req.body.unexpected', 'warning', 'req.body', `A ${method} request with a body is ignored by many clients and proxies.`);
1013
+ }
1014
+ validateMapped(request['body'], 'req.body', 'request', false, false);
1015
+ }
1016
+ }
1017
+ // -------------------------------------------------------------------------
1018
+ // 4. res
1019
+ // -------------------------------------------------------------------------
1020
+ if (!response) {
1021
+ push('res.status.invalid', 'error', 'res.status', 'The response declaration is missing.');
1022
+ }
1023
+ else {
1024
+ const rawStatus = response['status'];
1025
+ const statuses = Array.isArray(rawStatus) ? rawStatus : [rawStatus];
1026
+ if (Array.isArray(rawStatus) && rawStatus.length === 0) {
1027
+ push('res.status.empty', 'error', 'res.status', 'Declare at least one success status.');
1028
+ }
1029
+ const seenStatus = new Set();
1030
+ for (const status of statuses) {
1031
+ if (typeof status !== 'number' || !Number.isInteger(status) || status < 100 || status > 599) {
1032
+ push('res.status.invalid', 'error', 'res.status', `"${String(status)}" is not an HTTP status code between 100 and 599.`);
1033
+ }
1034
+ else if (seenStatus.has(status)) {
1035
+ push('res.status.duplicate', 'error', 'res.status', `Status ${status} is declared more than once.`);
1036
+ }
1037
+ else {
1038
+ seenStatus.add(status);
1039
+ }
1040
+ }
1041
+ const responseHeaders = isRecord(response['headers']) ? response['headers'] : null;
1042
+ if (responseHeaders) {
1043
+ checkDuplicateKeysIgnoringCase(responseHeaders, 'res.headers');
1044
+ for (const key of Object.keys(responseHeaders)) {
1045
+ const entryPath = `res.headers.${key}`;
1046
+ if (!key.trim())
1047
+ push('req.header.name.empty', 'error', entryPath, 'A header name cannot be empty.');
1048
+ validateDeclaration(responseHeaders[key], entryPath, false);
1049
+ }
1050
+ }
1051
+ if (response['body'] !== undefined) {
1052
+ if (seenStatus.has(204)) {
1053
+ push('res.body.unexpected', 'warning', 'res.body', 'HTTP 204 means "no content" — a declared body will never arrive.');
1054
+ }
1055
+ validateDeclaration(response['body'], 'res.body', false);
1056
+ }
1057
+ }
1058
+ // -------------------------------------------------------------------------
1059
+ // 5. output.schema
1060
+ // -------------------------------------------------------------------------
1061
+ const outputSchema = readSchema(contract['output']);
1062
+ if (!outputSchema)
1063
+ push('schema.missing', 'error', 'output.schema', '"output.schema" is missing.');
1064
+ else
1065
+ validateMapped(outputSchema, 'output.schema', 'output', false, true);
1066
+ return diagnostics;
1067
+ }
1068
+ /** `true` when a source of `sourceType` may fill a target of `targetType`. */
1069
+ function isTypeCompatible(sourceType, targetType) {
1070
+ if (sourceType === targetType)
1071
+ return true;
1072
+ // why: date/datetime là kiểu LOGIC, trên dây luôn là string — nên string ↔ temporal đi được cả hai
1073
+ // chiều. Mọi cặp khác phải khớp chính xác, vì exact expression giữ nguyên type của nguồn.
1074
+ const sourceTemporalish = sdIsApiContractTemporalDataType(sourceType) || sourceType === 'string';
1075
+ const targetTemporalish = sdIsApiContractTemporalDataType(targetType) || targetType === 'string';
1076
+ return sourceTemporalish && targetTemporalish;
1077
+ }
1078
+ function readSchema(holder) {
1079
+ if (!isRecord(holder) || !isRecord(holder['schema']))
1080
+ return null;
1081
+ return holder['schema'];
1082
+ }
1083
+ function isRecord(value) {
1084
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
1085
+ }
1086
+ function isFilledString(value) {
1087
+ return typeof value === 'string' && value.trim().length > 0;
1088
+ }
1089
+ function hasOwn(record, key) {
1090
+ return Object.prototype.hasOwnProperty.call(record, key);
1091
+ }
1092
+
1093
+ /**
1094
+ * Reference contracts, shared by the docs, the showcase and the test-suite so the canonical example
1095
+ * can never drift between them.
1096
+ *
1097
+ * Each is a **factory**, not a constant: the builder takes a two-way `[(model)]`, and handing two
1098
+ * demos the same object would let one seed the other.
1099
+ */
1100
+ /** The env catalog the samples reference. Definitions only — no secret ever has a value here. */
1101
+ const SD_API_CONTRACT_SAMPLE_ENVIRONMENT = {
1102
+ env: {
1103
+ baseUrl: { type: 'string', label: 'Backend base URL' },
1104
+ token: { type: 'string', label: 'Access token', sensitive: true },
1105
+ userId: { type: 'string', label: 'Current user ID' },
1106
+ },
1107
+ };
1108
+ /** `GET` list endpoint whose output is a root array — the dropdown / table shape. */
1109
+ function sdApiContractSearchSample() {
1110
+ return {
1111
+ contractVersion: 1,
1112
+ code: 'product.search',
1113
+ name: 'Search products',
1114
+ description: 'Search active products for dropdown or table',
1115
+ input: {
1116
+ schema: {
1117
+ type: 'object',
1118
+ properties: {
1119
+ keyword: { type: 'string', required: false },
1120
+ page: { type: 'number' },
1121
+ createdFrom: { type: 'datetime', transform: 'ISOString' },
1122
+ },
1123
+ },
1124
+ },
1125
+ req: {
1126
+ method: 'GET',
1127
+ url: '${env.baseUrl}/products',
1128
+ query: {
1129
+ keyword: { type: 'string', source: '${input.keyword}' },
1130
+ page: { type: 'number', source: '${input.page}' },
1131
+ createdFrom: { type: 'datetime', source: '${input.createdFrom}' },
1132
+ },
1133
+ headers: {
1134
+ Authorization: { type: 'string', source: 'Bearer ${env.token}' },
1135
+ 'x-user-id': { type: 'string', source: '${env.userId}' },
1136
+ },
1137
+ },
1138
+ res: {
1139
+ status: 200,
1140
+ headers: { 'x-request-id': { type: 'string', required: false } },
1141
+ body: {
1142
+ type: 'object',
1143
+ properties: {
1144
+ items: {
1145
+ type: 'array',
1146
+ required: true,
1147
+ items: {
1148
+ type: 'object',
1149
+ properties: {
1150
+ id: { type: 'string', required: true },
1151
+ name: { type: 'string', required: true },
1152
+ createdAt: { type: 'datetime' },
1153
+ },
1154
+ },
1155
+ },
1156
+ total: { type: 'number', required: true },
1157
+ },
1158
+ },
1159
+ },
1160
+ output: {
1161
+ schema: {
1162
+ type: 'array',
1163
+ source: '${res.body.items}',
1164
+ items: {
1165
+ type: 'object',
1166
+ properties: {
1167
+ id: { type: 'string', required: true },
1168
+ name: { type: 'string', required: true },
1169
+ createdAt: { type: 'datetime' },
1170
+ },
1171
+ },
1172
+ },
1173
+ },
1174
+ };
1175
+ }
1176
+ /**
1177
+ * `POST` endpoint showing every mapping flavour at once:
1178
+ * `input.a → req.body.x`, `input.b → req.body.y`, `input.c → req.body.z`,
1179
+ * `env.userId → req.body.u`, and a static literal in `req.body.v`.
1180
+ */
1181
+ function sdApiContractCreateSample() {
1182
+ return {
1183
+ contractVersion: 1,
1184
+ code: 'order.create',
1185
+ name: 'Create order',
1186
+ description: 'Maps a frontend payload onto the backend order body',
1187
+ input: {
1188
+ schema: {
1189
+ type: 'object',
1190
+ properties: {
1191
+ a: { type: 'string', required: true, label: 'Order code' },
1192
+ b: { type: 'number', label: 'Quantity' },
1193
+ c: { type: 'array', items: { type: 'string' }, label: 'Tags' },
1194
+ },
1195
+ },
1196
+ },
1197
+ req: {
1198
+ method: 'POST',
1199
+ url: '${env.baseUrl}/orders',
1200
+ headers: { Authorization: { type: 'string', source: 'Bearer ${env.token}' } },
1201
+ body: {
1202
+ type: 'object',
1203
+ properties: {
1204
+ x: { type: 'string', required: true, source: '${input.a}' },
1205
+ y: { type: 'number', source: '${input.b}' },
1206
+ z: { type: 'array', source: '${input.c}', items: { type: 'string' } },
1207
+ u: { type: 'string', source: '${env.userId}' },
1208
+ v: { type: 'string', value: 'STATIC VALUE' },
1209
+ },
1210
+ },
1211
+ },
1212
+ res: {
1213
+ status: [200, 201],
1214
+ body: {
1215
+ type: 'object',
1216
+ properties: {
1217
+ id: { type: 'string', required: true },
1218
+ createdAt: { type: 'datetime' },
1219
+ },
1220
+ },
1221
+ },
1222
+ output: {
1223
+ schema: {
1224
+ type: 'object',
1225
+ properties: {
1226
+ id: { type: 'string', required: true, source: '${res.body.id}' },
1227
+ createdAt: { type: 'datetime', source: '${res.body.createdAt}' },
1228
+ },
1229
+ },
1230
+ },
1231
+ };
1232
+ }
1233
+ /**
1234
+ * Deliberately broken contract used to demonstrate the diagnostics: an undeclared env variable, a
1235
+ * `{id}` placeholder with no `req.path` entry, a `${input.page}` that does not exist, and an output
1236
+ * source pointing at a scalar while the output declares an array.
1237
+ */
1238
+ function sdApiContractInvalidSample() {
1239
+ return {
1240
+ contractVersion: 1,
1241
+ code: 'product.broken',
1242
+ name: 'Broken product search',
1243
+ description: 'Every diagnostic class in one contract',
1244
+ input: {
1245
+ schema: {
1246
+ type: 'object',
1247
+ properties: { keyword: { type: 'string' } },
1248
+ },
1249
+ },
1250
+ req: {
1251
+ method: 'GET',
1252
+ url: '${env.baseUrl}/products/{id}',
1253
+ query: { page: { type: 'number', source: '${input.page}' } },
1254
+ headers: { Authorization: { type: 'string', source: 'Bearer ${env.unknown}' } },
1255
+ },
1256
+ res: {
1257
+ status: 200,
1258
+ body: {
1259
+ type: 'object',
1260
+ properties: {
1261
+ items: { type: 'array', required: true, items: { type: 'object', properties: { id: { type: 'string' } } } },
1262
+ total: { type: 'number' },
1263
+ },
1264
+ },
1265
+ },
1266
+ output: {
1267
+ schema: {
1268
+ type: 'array',
1269
+ source: '${res.body.total}',
1270
+ items: { type: 'object', properties: { id: { type: 'string' } } },
1271
+ },
1272
+ },
1273
+ };
1274
+ }
1275
+
1276
+ /**
1277
+ * Validation summary + the diagnostic list.
1278
+ *
1279
+ * Severity is never signalled by colour alone: every row carries an icon, the severity word, the
1280
+ * stable `code` and the structural `path`, so the list stays readable for a colour-blind reader and
1281
+ * usable from a screen reader.
1282
+ */
1283
+ class SdApiContractDiagnosticList {
1284
+ diagnostics = input([], ...(ngDevMode ? [{ debugName: "diagnostics" }] : /* istanbul ignore next */ []));
1285
+ autoId = input(...(ngDevMode ? [undefined, { debugName: "autoId" }] : /* istanbul ignore next */ []));
1286
+ navigate = output();
1287
+ errorCount = computed(() => this.diagnostics().filter(diagnostic => diagnostic.severity === 'error').length, ...(ngDevMode ? [{ debugName: "errorCount" }] : /* istanbul ignore next */ []));
1288
+ warningCount = computed(() => this.diagnostics().filter(diagnostic => diagnostic.severity === 'warning').length, ...(ngDevMode ? [{ debugName: "warningCount" }] : /* istanbul ignore next */ []));
1289
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: SdApiContractDiagnosticList, deps: [], target: i0.ɵɵFactoryTarget.Component });
1290
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: SdApiContractDiagnosticList, isStandalone: true, selector: "sd-api-contract-diagnostic-list", inputs: { diagnostics: { classPropertyName: "diagnostics", publicName: "diagnostics", isSignal: true, isRequired: false, transformFunction: null }, autoId: { classPropertyName: "autoId", publicName: "autoId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { navigate: "navigate" }, ngImport: i0, template: `
1291
+ @let _diagnostics = diagnostics();
1292
+ @let _errors = errorCount();
1293
+ @let _warnings = warningCount();
1294
+ @let _autoId = autoId();
1295
+
1296
+ <div class="sd-acb-diagnostics" [attr.data-autoId]="_autoId">
1297
+ <div class="sd-acb-diagnostics__summary" role="status">
1298
+ @if (!_diagnostics.length) {
1299
+ <span class="sd-acb-diagnostics__badge" data-severity="ok">
1300
+ <sd-icon name="check_circle" size="sm"></sd-icon>
1301
+ <span>{{ 'core.component.api-contract-builder.review.valid' | sdTranslate }}</span>
1302
+ </span>
1303
+ } @else {
1304
+ <span class="sd-acb-diagnostics__badge" data-severity="error">
1305
+ <sd-icon name="error" size="sm"></sd-icon>
1306
+ <span>{{ 'core.component.api-contract-builder.review.errors' | sdTranslate: { count: _errors } }}</span>
1307
+ </span>
1308
+ <span class="sd-acb-diagnostics__badge" data-severity="warning">
1309
+ <sd-icon name="warning" size="sm"></sd-icon>
1310
+ <span>{{ 'core.component.api-contract-builder.review.warnings' | sdTranslate: { count: _warnings } }}</span>
1311
+ </span>
1312
+ }
1313
+ </div>
1314
+
1315
+ @if (_diagnostics.length) {
1316
+ <ul class="sd-acb-diagnostics__list">
1317
+ @for (diagnostic of _diagnostics; track diagnostic.code + '|' + diagnostic.path + '|' + $index) {
1318
+ <li class="sd-acb-diagnostics__item" [attr.data-severity]="diagnostic.severity" [attr.data-code]="diagnostic.code">
1319
+ <button
1320
+ type="button"
1321
+ class="sd-acb-diagnostics__nav"
1322
+ [attr.data-autoId]="_autoId ? _autoId + '-goto-' + $index : null"
1323
+ (click)="navigate.emit(diagnostic)">
1324
+ <sd-icon [name]="diagnostic.severity === 'error' ? 'error' : 'warning'" size="sm"></sd-icon>
1325
+ <span class="sd-acb-diagnostics__path">{{ diagnostic.path || '(contract)' }}</span>
1326
+ <span class="sd-acb-diagnostics__message">{{ diagnostic.message }}</span>
1327
+ <span class="sd-acb-diagnostics__code">{{ diagnostic.code }}</span>
1328
+ </button>
1329
+ </li>
1330
+ }
1331
+ </ul>
1332
+ }
1333
+ </div>
1334
+ `, isInline: true, styles: [":host{display:block}.sd-acb-diagnostics__summary{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:10px}.sd-acb-diagnostics__badge{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600}.sd-acb-diagnostics__badge[data-severity=ok]{color:var(--sd-success, #1c6c3a)}.sd-acb-diagnostics__badge[data-severity=error]{color:var(--sd-error, #b3261e)}.sd-acb-diagnostics__badge[data-severity=warning]{color:var(--sd-warning, #8a5a00)}.sd-acb-diagnostics__list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}.sd-acb-diagnostics__nav{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px;width:100%;text-align:left;background:none;border:1px solid transparent;border-radius:6px;padding:6px 8px;cursor:pointer;font:inherit;font-size:12px}.sd-acb-diagnostics__nav:hover,.sd-acb-diagnostics__nav:focus-visible{border-color:var(--sd-border-color, #e6e6e6);background:var(--sd-surface-muted, #f3f5f8)}.sd-acb-diagnostics__item[data-severity=error] .sd-acb-diagnostics__nav{color:var(--sd-error, #b3261e)}.sd-acb-diagnostics__item[data-severity=warning] .sd-acb-diagnostics__nav{color:var(--sd-warning, #8a5a00)}.sd-acb-diagnostics__path{font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;font-weight:600}.sd-acb-diagnostics__message{color:var(--sd-text, #1f2937)}.sd-acb-diagnostics__code{margin-left:auto;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;color:var(--sd-text-secondary, #6b6b6b)}\n"], dependencies: [{ kind: "component", type: SdIcon, selector: "sd-icon", inputs: ["name", "fontIcon", "color", "set", "fontSet", "size", "strokeWidth", "absoluteStrokeWidth", "ariaLabel"] }, { kind: "pipe", type: SdTranslatePipe, name: "sdTranslate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1335
+ }
1336
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: SdApiContractDiagnosticList, decorators: [{
1337
+ type: Component,
1338
+ args: [{ selector: 'sd-api-contract-diagnostic-list', standalone: true, imports: [SdIcon, SdTranslatePipe], changeDetection: ChangeDetectionStrategy.OnPush, template: `
1339
+ @let _diagnostics = diagnostics();
1340
+ @let _errors = errorCount();
1341
+ @let _warnings = warningCount();
1342
+ @let _autoId = autoId();
1343
+
1344
+ <div class="sd-acb-diagnostics" [attr.data-autoId]="_autoId">
1345
+ <div class="sd-acb-diagnostics__summary" role="status">
1346
+ @if (!_diagnostics.length) {
1347
+ <span class="sd-acb-diagnostics__badge" data-severity="ok">
1348
+ <sd-icon name="check_circle" size="sm"></sd-icon>
1349
+ <span>{{ 'core.component.api-contract-builder.review.valid' | sdTranslate }}</span>
1350
+ </span>
1351
+ } @else {
1352
+ <span class="sd-acb-diagnostics__badge" data-severity="error">
1353
+ <sd-icon name="error" size="sm"></sd-icon>
1354
+ <span>{{ 'core.component.api-contract-builder.review.errors' | sdTranslate: { count: _errors } }}</span>
1355
+ </span>
1356
+ <span class="sd-acb-diagnostics__badge" data-severity="warning">
1357
+ <sd-icon name="warning" size="sm"></sd-icon>
1358
+ <span>{{ 'core.component.api-contract-builder.review.warnings' | sdTranslate: { count: _warnings } }}</span>
1359
+ </span>
1360
+ }
1361
+ </div>
1362
+
1363
+ @if (_diagnostics.length) {
1364
+ <ul class="sd-acb-diagnostics__list">
1365
+ @for (diagnostic of _diagnostics; track diagnostic.code + '|' + diagnostic.path + '|' + $index) {
1366
+ <li class="sd-acb-diagnostics__item" [attr.data-severity]="diagnostic.severity" [attr.data-code]="diagnostic.code">
1367
+ <button
1368
+ type="button"
1369
+ class="sd-acb-diagnostics__nav"
1370
+ [attr.data-autoId]="_autoId ? _autoId + '-goto-' + $index : null"
1371
+ (click)="navigate.emit(diagnostic)">
1372
+ <sd-icon [name]="diagnostic.severity === 'error' ? 'error' : 'warning'" size="sm"></sd-icon>
1373
+ <span class="sd-acb-diagnostics__path">{{ diagnostic.path || '(contract)' }}</span>
1374
+ <span class="sd-acb-diagnostics__message">{{ diagnostic.message }}</span>
1375
+ <span class="sd-acb-diagnostics__code">{{ diagnostic.code }}</span>
1376
+ </button>
1377
+ </li>
1378
+ }
1379
+ </ul>
1380
+ }
1381
+ </div>
1382
+ `, styles: [":host{display:block}.sd-acb-diagnostics__summary{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:10px}.sd-acb-diagnostics__badge{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600}.sd-acb-diagnostics__badge[data-severity=ok]{color:var(--sd-success, #1c6c3a)}.sd-acb-diagnostics__badge[data-severity=error]{color:var(--sd-error, #b3261e)}.sd-acb-diagnostics__badge[data-severity=warning]{color:var(--sd-warning, #8a5a00)}.sd-acb-diagnostics__list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}.sd-acb-diagnostics__nav{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px;width:100%;text-align:left;background:none;border:1px solid transparent;border-radius:6px;padding:6px 8px;cursor:pointer;font:inherit;font-size:12px}.sd-acb-diagnostics__nav:hover,.sd-acb-diagnostics__nav:focus-visible{border-color:var(--sd-border-color, #e6e6e6);background:var(--sd-surface-muted, #f3f5f8)}.sd-acb-diagnostics__item[data-severity=error] .sd-acb-diagnostics__nav{color:var(--sd-error, #b3261e)}.sd-acb-diagnostics__item[data-severity=warning] .sd-acb-diagnostics__nav{color:var(--sd-warning, #8a5a00)}.sd-acb-diagnostics__path{font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;font-weight:600}.sd-acb-diagnostics__message{color:var(--sd-text, #1f2937)}.sd-acb-diagnostics__code{margin-left:auto;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;color:var(--sd-text-secondary, #6b6b6b)}\n"] }]
1383
+ }], propDecorators: { diagnostics: [{ type: i0.Input, args: [{ isSignal: true, alias: "diagnostics", required: false }] }], autoId: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoId", required: false }] }], navigate: [{ type: i0.Output, args: ["navigate"] }] } });
1384
+
1385
+ /**
1386
+ * One collapsed, read-only row for a node in a layer list.
1387
+ *
1388
+ * The whole point is that it holds NO editable control. Editing a node happens in the drawer, so the
1389
+ * list can stay a thing you scan rather than a wall of inputs — and so a node never has two editors
1390
+ * writing it at once.
1391
+ *
1392
+ * The row shows what an author needs to spot a mistake without opening anything: the name, the
1393
+ * declared type, whether it is required, and where its value comes from.
1394
+ */
1395
+ class SdApiContractNodeSummary {
1396
+ #i18n = inject(I18nService);
1397
+ name = input.required(...(ngDevMode ? [{ debugName: "name" }] : /* istanbul ignore next */ []));
1398
+ node = input.required(...(ngDevMode ? [{ debugName: "node" }] : /* istanbul ignore next */ []));
1399
+ /** `view` mode and `disabled` both land here: the row becomes text, with no action at all. */
1400
+ readonly = input(false, { ...(ngDevMode ? { debugName: "readonly" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1401
+ autoId = input(...(ngDevMode ? [undefined, { debugName: "autoId" }] : /* istanbul ignore next */ []));
1402
+ edit = output();
1403
+ remove = output();
1404
+ requiredLabel = this.#i18n.t('core.component.api-contract-builder.field.required');
1405
+ removeLabel = this.#i18n.t('core.component.api-contract-builder.node.remove');
1406
+ unmapped = computed(() => {
1407
+ const node = this.node();
1408
+ if (node.source !== undefined || node.value !== undefined)
1409
+ return false;
1410
+ return !(node.type === 'object' && node.properties);
1411
+ }, ...(ngDevMode ? [{ debugName: "unmapped" }] : /* istanbul ignore next */ []));
1412
+ /**
1413
+ * One line describing where the value comes from.
1414
+ *
1415
+ * why bốn dạng khác nhau: `${input.keyword}` rút về `input.keyword` vì dấu `${}` là cú pháp chứ
1416
+ * không phải thông tin; còn template ghép giữ NGUYÊN VĂN vì rút gọn nó sẽ sai. Literal hiện giá trị
1417
+ * để thấy ngay, object hiện số trường để biết còn bao nhiêu phải mở ra xem.
1418
+ */
1419
+ mappingSummary = computed(() => {
1420
+ const node = this.node();
1421
+ if (typeof node.source === 'string' && node.source !== '') {
1422
+ const parsed = parseSdApiContractTemplate(node.source);
1423
+ const reference = parsed.kind === 'exact' ? parsed.references[0] : undefined;
1424
+ return `← ${reference ? reference.expression : node.source}`;
1425
+ }
1426
+ if (node.value !== undefined)
1427
+ return `= ${formatLiteral(node.value)}`;
1428
+ if (node.type === 'object' && node.properties) {
1429
+ return this.#i18n.t('core.component.api-contract-builder.summary.field-count', {
1430
+ count: Object.keys(node.properties).length,
1431
+ });
1432
+ }
1433
+ return this.#i18n.t('core.component.api-contract-builder.summary.unmapped');
1434
+ }, ...(ngDevMode ? [{ debugName: "mappingSummary" }] : /* istanbul ignore next */ []));
1435
+ requestEdit() {
1436
+ if (this.readonly())
1437
+ return;
1438
+ this.edit.emit();
1439
+ }
1440
+ /**
1441
+ * why stopPropagation: nút xoá nằm TRONG hàng có `(click)`, nên không chặn thì bấm xoá cũng mở
1442
+ * drawer — người dùng bấm xoá lại thấy form hiện ra.
1443
+ */
1444
+ requestRemove(event) {
1445
+ event.stopPropagation();
1446
+ if (this.readonly())
1447
+ return;
1448
+ this.remove.emit();
1449
+ }
1450
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: SdApiContractNodeSummary, deps: [], target: i0.ɵɵFactoryTarget.Component });
1451
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: SdApiContractNodeSummary, isStandalone: true, selector: "sd-api-contract-node-summary", inputs: { name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: true, transformFunction: null }, node: { classPropertyName: "node", publicName: "node", isSignal: true, isRequired: true, transformFunction: null }, readonly: { classPropertyName: "readonly", publicName: "readonly", isSignal: true, isRequired: false, transformFunction: null }, autoId: { classPropertyName: "autoId", publicName: "autoId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { edit: "edit", remove: "remove" }, ngImport: i0, template: `
1452
+ @let _autoId = autoId();
1453
+ @let _readonly = readonly();
1454
+
1455
+ <div
1456
+ class="sd-acb-row"
1457
+ [class.sd-acb-row--readonly]="_readonly"
1458
+ [attr.role]="_readonly ? null : 'button'"
1459
+ [attr.tabindex]="_readonly ? null : 0"
1460
+ [attr.data-autoid]="_autoId || null"
1461
+ (click)="requestEdit()"
1462
+ (keydown.enter)="requestEdit()"
1463
+ (keydown.space)="requestEdit()">
1464
+ <span class="sd-acb-row__name">{{ name() }}</span>
1465
+ <span class="sd-acb-row__type">{{ node().type }}</span>
1466
+
1467
+ @if (node().required === true) {
1468
+ <span class="sd-acb-row__required">{{ requiredLabel }}</span>
1469
+ }
1470
+
1471
+ <span class="sd-acb-row__mapping" [class.sd-acb-row__mapping--unmapped]="unmapped()">{{ mappingSummary() }}</span>
1472
+
1473
+ @if (!_readonly) {
1474
+ <button
1475
+ type="button"
1476
+ class="sd-acb-row__remove"
1477
+ [attr.data-autoid]="_autoId ? _autoId + '-remove' : null"
1478
+ [attr.aria-label]="removeLabel"
1479
+ [title]="removeLabel"
1480
+ (click)="requestRemove($event)">
1481
+ <sd-icon name="delete" size="sm"></sd-icon>
1482
+ </button>
1483
+ }
1484
+ </div>
1485
+ `, isInline: true, styles: [":host{display:block;width:100%}.sd-acb-row{display:flex;gap:10px;align-items:center;padding:8px 10px;border:1px solid var(--sd-border-color, #e6e6e6);border-radius:6px;background:var(--sd-surface, #fff);font-size:13px;cursor:pointer}.sd-acb-row:hover{border-color:var(--sd-primary, #005cbb)}.sd-acb-row--readonly{cursor:default}.sd-acb-row--readonly:hover{border-color:var(--sd-border-color, #e6e6e6)}.sd-acb-row__name{min-width:0;font-weight:600;overflow-wrap:anywhere}.sd-acb-row__type{padding:1px 6px;border-radius:4px;background:var(--sd-surface-muted, #f3f5f8);color:var(--sd-text-secondary, #6b6b6b);font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;font-size:12px}.sd-acb-row__required{padding:1px 6px;border-radius:4px;background:color-mix(in srgb,var(--sd-primary, #005cbb) 12%,transparent);color:var(--sd-primary, #005cbb);font-size:11px}.sd-acb-row__mapping{min-width:0;margin-left:auto;overflow:hidden;color:var(--sd-text-secondary, #6b6b6b);font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;font-size:12px;text-overflow:ellipsis;white-space:nowrap}.sd-acb-row__mapping--unmapped{color:var(--sd-warning, #8a5a00);font-style:italic}.sd-acb-row__remove{display:inline-flex;flex:0 0 auto;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:50%;background:transparent;color:var(--sd-error, #b3261e);cursor:pointer}.sd-acb-row__remove:hover{background:color-mix(in srgb,var(--sd-error, #b3261e) 12%,transparent)}\n"], dependencies: [{ kind: "component", type: SdIcon, selector: "sd-icon", inputs: ["name", "fontIcon", "color", "set", "fontSet", "size", "strokeWidth", "absoluteStrokeWidth", "ariaLabel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1486
+ }
1487
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: SdApiContractNodeSummary, decorators: [{
1488
+ type: Component,
1489
+ args: [{ selector: 'sd-api-contract-node-summary', standalone: true, imports: [SdIcon], changeDetection: ChangeDetectionStrategy.OnPush, template: `
1490
+ @let _autoId = autoId();
1491
+ @let _readonly = readonly();
1492
+
1493
+ <div
1494
+ class="sd-acb-row"
1495
+ [class.sd-acb-row--readonly]="_readonly"
1496
+ [attr.role]="_readonly ? null : 'button'"
1497
+ [attr.tabindex]="_readonly ? null : 0"
1498
+ [attr.data-autoid]="_autoId || null"
1499
+ (click)="requestEdit()"
1500
+ (keydown.enter)="requestEdit()"
1501
+ (keydown.space)="requestEdit()">
1502
+ <span class="sd-acb-row__name">{{ name() }}</span>
1503
+ <span class="sd-acb-row__type">{{ node().type }}</span>
1504
+
1505
+ @if (node().required === true) {
1506
+ <span class="sd-acb-row__required">{{ requiredLabel }}</span>
1507
+ }
1508
+
1509
+ <span class="sd-acb-row__mapping" [class.sd-acb-row__mapping--unmapped]="unmapped()">{{ mappingSummary() }}</span>
1510
+
1511
+ @if (!_readonly) {
1512
+ <button
1513
+ type="button"
1514
+ class="sd-acb-row__remove"
1515
+ [attr.data-autoid]="_autoId ? _autoId + '-remove' : null"
1516
+ [attr.aria-label]="removeLabel"
1517
+ [title]="removeLabel"
1518
+ (click)="requestRemove($event)">
1519
+ <sd-icon name="delete" size="sm"></sd-icon>
1520
+ </button>
1521
+ }
1522
+ </div>
1523
+ `, styles: [":host{display:block;width:100%}.sd-acb-row{display:flex;gap:10px;align-items:center;padding:8px 10px;border:1px solid var(--sd-border-color, #e6e6e6);border-radius:6px;background:var(--sd-surface, #fff);font-size:13px;cursor:pointer}.sd-acb-row:hover{border-color:var(--sd-primary, #005cbb)}.sd-acb-row--readonly{cursor:default}.sd-acb-row--readonly:hover{border-color:var(--sd-border-color, #e6e6e6)}.sd-acb-row__name{min-width:0;font-weight:600;overflow-wrap:anywhere}.sd-acb-row__type{padding:1px 6px;border-radius:4px;background:var(--sd-surface-muted, #f3f5f8);color:var(--sd-text-secondary, #6b6b6b);font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;font-size:12px}.sd-acb-row__required{padding:1px 6px;border-radius:4px;background:color-mix(in srgb,var(--sd-primary, #005cbb) 12%,transparent);color:var(--sd-primary, #005cbb);font-size:11px}.sd-acb-row__mapping{min-width:0;margin-left:auto;overflow:hidden;color:var(--sd-text-secondary, #6b6b6b);font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;font-size:12px;text-overflow:ellipsis;white-space:nowrap}.sd-acb-row__mapping--unmapped{color:var(--sd-warning, #8a5a00);font-style:italic}.sd-acb-row__remove{display:inline-flex;flex:0 0 auto;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:50%;background:transparent;color:var(--sd-error, #b3261e);cursor:pointer}.sd-acb-row__remove:hover{background:color-mix(in srgb,var(--sd-error, #b3261e) 12%,transparent)}\n"] }]
1524
+ }], propDecorators: { name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: true }] }], node: [{ type: i0.Input, args: [{ isSignal: true, alias: "node", required: true }] }], readonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonly", required: false }] }], autoId: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoId", required: false }] }], edit: [{ type: i0.Output, args: ["edit"] }], remove: [{ type: i0.Output, args: ["remove"] }] } });
1525
+ /** Renders a literal short enough for one row: strings quoted, composites collapsed. */
1526
+ function formatLiteral(value) {
1527
+ if (typeof value === 'string')
1528
+ return JSON.stringify(value);
1529
+ if (value === null || typeof value !== 'object')
1530
+ return String(value);
1531
+ if (Array.isArray(value))
1532
+ return `[${value.length}]`;
1533
+ return `{${Object.keys(value).length}}`;
1534
+ }
1535
+
1536
+ /**
1537
+ * Value editor for one mapped node: a mode picker plus one control chosen by the mode.
1538
+ *
1539
+ * `source` shows a single dropdown of the references the surrounding schemas and the injected env
1540
+ * catalog make available — the author never types `${…}` for the common case. `advanced` keeps a raw
1541
+ * expression field for what a dropdown cannot express (`Bearer ${env.token}`). `static` renders a
1542
+ * control matching the declared type, including a JSON editor for `object` / `array` literals.
1543
+ *
1544
+ * The JSON editor for a composite literal only writes when the text parses. While it is half-typed it
1545
+ * emits the raw string, and that string is dropped — otherwise one keystroke would turn a literal
1546
+ * object into a garbage string in the contract.
1547
+ */
1548
+ class SdApiContractSourceEditor {
1549
+ #i18n = inject(I18nService);
1550
+ node = input.required(...(ngDevMode ? [{ debugName: "node" }] : /* istanbul ignore next */ []));
1551
+ suggestions = input([], ...(ngDevMode ? [{ debugName: "suggestions" }] : /* istanbul ignore next */ []));
1552
+ disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1553
+ autoId = input(...(ngDevMode ? [undefined, { debugName: "autoId" }] : /* istanbul ignore next */ []));
1554
+ nodeChange = output();
1555
+ advancedPlaceholder = 'Bearer ${env.token}';
1556
+ // why: dựng MỘT lần trong field initializer. `[items]` nhận mảng mới mỗi CD sẽ kích
1557
+ // `toObservable(items)` của sd-select → markForCheck → CD mới → treo trình duyệt (bug OOM
1558
+ // đã gặp ở query-builder). Ngôn ngữ chỉ đổi kèm reload trang nên dựng một lần là đủ.
1559
+ booleanOptions = [
1560
+ { value: 'true', label: this.#i18n.t('core.component.api-contract-builder.boolean.true') },
1561
+ { value: 'false', label: this.#i18n.t('core.component.api-contract-builder.boolean.false') },
1562
+ ];
1563
+ #sourceMode = {
1564
+ value: 'source',
1565
+ label: this.#i18n.t('core.component.api-contract-builder.mapping.mode.source'),
1566
+ };
1567
+ #advancedMode = {
1568
+ value: 'advanced',
1569
+ label: this.#i18n.t('core.component.api-contract-builder.mapping.mode.advanced'),
1570
+ };
1571
+ #scalarModes = [
1572
+ this.#sourceMode,
1573
+ { value: 'static', label: this.#i18n.t('core.component.api-contract-builder.mapping.mode.static') },
1574
+ this.#advancedMode,
1575
+ ];
1576
+ #objectModes = [
1577
+ this.#sourceMode,
1578
+ { value: 'static', label: this.#i18n.t('core.component.api-contract-builder.mapping.mode.static') },
1579
+ this.#advancedMode,
1580
+ { value: 'nested', label: this.#i18n.t('core.component.api-contract-builder.mapping.mode.nested') },
1581
+ ];
1582
+ #arrayModes = [
1583
+ this.#sourceMode,
1584
+ { value: 'static', label: this.#i18n.t('core.component.api-contract-builder.mapping.mode.static') },
1585
+ this.#advancedMode,
1586
+ ];
1587
+ // why: mode là state của GIAO DIỆN, không nằm trong contract — nó được suy lại từ node mỗi khi cha
1588
+ // đẩy giá trị mới. `linkedSignal` (không phải `computed`) vì có đúng một trường hợp phải GHI ĐÈ
1589
+ // suy diễn: người dùng đang ở `advanced` và gõ tới lúc chuỗi tình cờ còn đúng một reference sạch.
1590
+ // Nếu để suy diễn thắng, ô text đang gõ sẽ biến thành dropdown giữa lúc gõ.
1591
+ mode = linkedSignal({ ...(ngDevMode ? { debugName: "mode" } : /* istanbul ignore next */ {}), source: () => this.node(),
1592
+ computation: (node, previous) => {
1593
+ const derived = deriveMode(node);
1594
+ if (previous?.value === 'advanced' && derived === 'source')
1595
+ return 'advanced';
1596
+ return derived;
1597
+ } });
1598
+ /**
1599
+ * Giá trị hiển thị của picker.
1600
+ *
1601
+ * why: `none` không còn là một option, nên bind nó vào `[model]` sẽ ra một ô trống mà người dùng
1602
+ * không hiểu tại sao. Trả `null` để picker rỗng THẬT — cộng `required`, một node chưa gán trở thành
1603
+ * lỗi thấy được ngay trên hàng, khớp với diagnostic `mapping.node.unmapped` mà validation đã báo.
1604
+ */
1605
+ modeValue = computed(() => {
1606
+ const mode = this.mode();
1607
+ return mode === 'none' ? null : mode;
1608
+ }, ...(ngDevMode ? [{ debugName: "modeValue" }] : /* istanbul ignore next */ []));
1609
+ modeOptions = computed(() => {
1610
+ const type = this.node().type;
1611
+ if (type === 'object')
1612
+ return this.#objectModes;
1613
+ if (type === 'array')
1614
+ return this.#arrayModes;
1615
+ return this.#scalarModes;
1616
+ }, ...(ngDevMode ? [{ debugName: "modeOptions" }] : /* istanbul ignore next */ []));
1617
+ suggestionOptions = computed(() => {
1618
+ const type = this.node().type;
1619
+ const all = [...this.suggestions()];
1620
+ // why: lọc mềm — ưu tiên gợi ý hợp type, nhưng trả toàn bộ khi không cái nào khớp, để template
1621
+ // string (`Bearer ${env.token}`) và các cặp type tương thích không bị giấu mất.
1622
+ const compatible = all.filter(suggestion => isAssignable(suggestion.type, type));
1623
+ return compatible.length > 0 ? compatible : all;
1624
+ }, ...(ngDevMode ? [{ debugName: "suggestionOptions" }] : /* istanbul ignore next */ []));
1625
+ /**
1626
+ * Options for the source dropdown.
1627
+ *
1628
+ * why: một `source` đang lưu có thể trỏ vào trường đã bị xoá hoặc đổi tên — nó KHÔNG còn trong
1629
+ * danh sách gợi ý. `sd-select` không tìm thấy value sẽ hiện ô rỗng, và giá trị vẫn nằm trong
1630
+ * contract mà người dùng không thấy để sửa. Thêm một option dựng từ chính giá trị đó để nó luôn
1631
+ * hiển thị; validation lo phần báo `mapping.reference.missing`.
1632
+ */
1633
+ sourceOptions = computed(() => {
1634
+ const options = this.suggestionOptions();
1635
+ const current = this.node().source;
1636
+ if (typeof current !== 'string' || current === '')
1637
+ return options;
1638
+ if (options.some(option => option.expression === current))
1639
+ return options;
1640
+ const parsed = parseSdApiContractTemplate(current);
1641
+ if (parsed.kind !== 'exact')
1642
+ return options;
1643
+ const reference = parsed.references[0];
1644
+ return [
1645
+ ...options,
1646
+ {
1647
+ expression: current,
1648
+ path: reference?.expression ?? current,
1649
+ root: reference?.root ?? 'input',
1650
+ type: this.node().type,
1651
+ display: reference?.expression ?? current,
1652
+ },
1653
+ ];
1654
+ }, ...(ngDevMode ? [{ debugName: "sourceOptions" }] : /* istanbul ignore next */ []));
1655
+ staticText = computed(() => {
1656
+ const value = this.node().value;
1657
+ return value === undefined || value === null ? '' : String(value);
1658
+ }, ...(ngDevMode ? [{ debugName: "staticText" }] : /* istanbul ignore next */ []));
1659
+ staticNumber = computed(() => {
1660
+ const value = this.node().value;
1661
+ return typeof value === 'number' ? value : null;
1662
+ }, ...(ngDevMode ? [{ debugName: "staticNumber" }] : /* istanbul ignore next */ []));
1663
+ setMode(mode) {
1664
+ const node = this.node();
1665
+ // why: đặt mode TRƯỚC khi emit. Node quay lại từ cha sẽ kích `linkedSignal` tính lại, và nó đọc
1666
+ // `previous.value` — nếu chưa đặt, carve-out `advanced` sẽ giữ lại mode cũ và chặn việc rời khỏi
1667
+ // `advanced` sang `source`.
1668
+ if (mode === 'source' || mode === 'advanced') {
1669
+ this.mode.set(mode);
1670
+ // Rời `advanced` sang `source` thì template ghép không còn diễn đạt được bằng dropdown — xoá để
1671
+ // dropdown bắt đầu sạch. Ngược lại, đã có key `source` thì đổi mode không đụng tới model.
1672
+ const abandonsTemplate = mode === 'source' && parseSdApiContractTemplate(node.source).kind === 'interpolated';
1673
+ if (node.source === undefined || abandonsTemplate) {
1674
+ this.nodeChange.emit({ ...withoutMapping(node), source: '' });
1675
+ }
1676
+ return;
1677
+ }
1678
+ if (mode === 'static') {
1679
+ this.mode.set('static');
1680
+ this.nodeChange.emit({ ...withoutMapping(node), value: defaultStatic(node.type) });
1681
+ return;
1682
+ }
1683
+ this.mode.set(node.type === 'object' ? 'nested' : 'none');
1684
+ this.nodeChange.emit(withoutMapping(node));
1685
+ }
1686
+ setSource(value) {
1687
+ this.nodeChange.emit({ ...withoutMapping(this.node()), source: typeof value === 'string' ? value : '' });
1688
+ }
1689
+ setStaticScalar(value) {
1690
+ const node = this.node();
1691
+ this.nodeChange.emit({ ...withoutMapping(node), value: value === null || value === undefined ? '' : String(value) });
1692
+ }
1693
+ setStaticNumber(value) {
1694
+ const node = this.node();
1695
+ const parsed = typeof value === 'number' ? value : Number(value === null || value === undefined ? '' : String(value).trim());
1696
+ this.nodeChange.emit({ ...withoutMapping(node), value: Number.isFinite(parsed) ? parsed : 0 });
1697
+ }
1698
+ /**
1699
+ * Stores a `date` / `datetime` literal.
1700
+ *
1701
+ * why: `<sd-date>` / `<sd-datetime>` có thể bắn ra một `Date`. `String(new Date())` ra
1702
+ * `Mon Aug 17 2026 …` — không parse lại được và đổi theo locale của máy tác giả, nên contract sẽ
1703
+ * mang một giá trị không portable. Chuẩn hoá về ISO.
1704
+ */
1705
+ setStaticTemporal(value) {
1706
+ const node = this.node();
1707
+ if (value instanceof Date) {
1708
+ const iso = Number.isNaN(value.getTime()) ? '' : value.toISOString();
1709
+ this.nodeChange.emit({ ...withoutMapping(node), value: iso });
1710
+ return;
1711
+ }
1712
+ this.nodeChange.emit({ ...withoutMapping(node), value: value === null || value === undefined ? '' : String(value) });
1713
+ }
1714
+ /**
1715
+ * Stores an `object` / `array` literal from the JSON editor.
1716
+ *
1717
+ * why: `<sd-code-editor language="json">` bắn ra STRING khi cú pháp còn dở (`{"a":`). Ghi chuỗi đó
1718
+ * vào contract sẽ biến một literal object thành rác, nên bỏ qua và giữ giá trị hợp lệ cuối cùng —
1719
+ * người dùng vẫn thấy nguyên văn mình đang gõ trong editor.
1720
+ */
1721
+ setStaticJson(value) {
1722
+ if (value === null || typeof value !== 'object')
1723
+ return;
1724
+ this.nodeChange.emit({ ...withoutMapping(this.node()), value: value });
1725
+ }
1726
+ setStaticBoolean(value) {
1727
+ this.nodeChange.emit({ ...withoutMapping(this.node()), value: value === 'true' || value === true });
1728
+ }
1729
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: SdApiContractSourceEditor, deps: [], target: i0.ɵɵFactoryTarget.Component });
1730
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: SdApiContractSourceEditor, isStandalone: true, selector: "sd-api-contract-source-editor", inputs: { node: { classPropertyName: "node", publicName: "node", isSignal: true, isRequired: true, transformFunction: null }, suggestions: { classPropertyName: "suggestions", publicName: "suggestions", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, autoId: { classPropertyName: "autoId", publicName: "autoId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { nodeChange: "nodeChange" }, ngImport: i0, template: `
1731
+ @let _node = node();
1732
+ @let _disabled = disabled();
1733
+ @let _mode = mode();
1734
+ @let _autoId = autoId();
1735
+ @let _suggestions = sourceOptions();
1736
+
1737
+ <div class="sd-acb-source">
1738
+ <sd-select
1739
+ class="sd-acb-source__mode"
1740
+ size="sm"
1741
+ hideInlineError
1742
+ [clearable]="false"
1743
+ [autoId]="_autoId ? _autoId + '-mode' : undefined"
1744
+ [label]="'core.component.api-contract-builder.mapping.mode' | sdTranslate"
1745
+ [items]="modeOptions()"
1746
+ valueField="value"
1747
+ displayField="label"
1748
+ [required]="true"
1749
+ [model]="modeValue()"
1750
+ [disabled]="_disabled"
1751
+ (sdChange)="setMode($event)"></sd-select>
1752
+
1753
+ @if (_mode === 'source') {
1754
+ <sd-select
1755
+ class="sd-acb-source__expression"
1756
+ size="sm"
1757
+ hideInlineError
1758
+ [clearable]="false"
1759
+ [autoId]="_autoId ? _autoId + '-source' : undefined"
1760
+ [label]="'core.component.api-contract-builder.mapping.source' | sdTranslate"
1761
+ [items]="_suggestions"
1762
+ valueField="expression"
1763
+ displayField="display"
1764
+ [model]="_node.source ?? ''"
1765
+ [disabled]="_disabled"
1766
+ (sdChange)="setSource($event)"></sd-select>
1767
+ } @else if (_mode === 'advanced') {
1768
+ <sd-input
1769
+ class="sd-acb-source__expression"
1770
+ size="sm"
1771
+ hideInlineError
1772
+ [autoId]="_autoId ? _autoId + '-advanced' : undefined"
1773
+ [label]="'core.component.api-contract-builder.mapping.source' | sdTranslate"
1774
+ [placeholder]="advancedPlaceholder"
1775
+ [model]="_node.source ?? ''"
1776
+ [disabled]="_disabled"
1777
+ (sdChange)="setSource($event)"></sd-input>
1778
+ } @else if (_mode === 'static') {
1779
+ @let _staticAutoId = _autoId ? _autoId + '-static' : undefined;
1780
+ @let _staticLabel = 'core.component.api-contract-builder.mapping.value' | sdTranslate;
1781
+
1782
+ @switch (_node.type) {
1783
+ @case ('boolean') {
1784
+ <sd-select
1785
+ class="sd-acb-source__static"
1786
+ size="sm"
1787
+ hideInlineError
1788
+ [clearable]="false"
1789
+ [autoId]="_staticAutoId"
1790
+ [label]="_staticLabel"
1791
+ [items]="booleanOptions"
1792
+ valueField="value"
1793
+ displayField="label"
1794
+ [model]="_node.value === true ? 'true' : 'false'"
1795
+ [disabled]="_disabled"
1796
+ (sdChange)="setStaticBoolean($event)"></sd-select>
1797
+ }
1798
+ @case ('number') {
1799
+ <sd-input-number
1800
+ class="sd-acb-source__static"
1801
+ size="sm"
1802
+ hideInlineError
1803
+ [autoId]="_staticAutoId"
1804
+ [label]="_staticLabel"
1805
+ [model]="staticNumber()"
1806
+ [disabled]="_disabled"
1807
+ (sdChange)="setStaticNumber($event)"></sd-input-number>
1808
+ }
1809
+ @case ('date') {
1810
+ <sd-date
1811
+ class="sd-acb-source__static"
1812
+ size="sm"
1813
+ hideInlineError
1814
+ [autoId]="_staticAutoId"
1815
+ [label]="_staticLabel"
1816
+ [model]="staticText()"
1817
+ [disabled]="_disabled"
1818
+ (sdChange)="setStaticTemporal($event)"></sd-date>
1819
+ }
1820
+ @case ('datetime') {
1821
+ <sd-datetime
1822
+ class="sd-acb-source__static"
1823
+ size="sm"
1824
+ hideInlineError
1825
+ [autoId]="_staticAutoId"
1826
+ [label]="_staticLabel"
1827
+ [model]="staticText()"
1828
+ [disabled]="_disabled"
1829
+ (sdChange)="setStaticTemporal($event)"></sd-datetime>
1830
+ }
1831
+ @case ('object') {
1832
+ <sd-code-editor
1833
+ class="sd-acb-source__static sd-acb-source__static--json"
1834
+ language="json"
1835
+ maxHeight="220px"
1836
+ [model]="_node.value"
1837
+ [viewed]="_disabled"
1838
+ (modelChange)="setStaticJson($event)"></sd-code-editor>
1839
+ }
1840
+ @case ('array') {
1841
+ <sd-code-editor
1842
+ class="sd-acb-source__static sd-acb-source__static--json"
1843
+ language="json"
1844
+ maxHeight="220px"
1845
+ [model]="_node.value"
1846
+ [viewed]="_disabled"
1847
+ (modelChange)="setStaticJson($event)"></sd-code-editor>
1848
+ }
1849
+ @default {
1850
+ <sd-input
1851
+ class="sd-acb-source__static"
1852
+ size="sm"
1853
+ hideInlineError
1854
+ [autoId]="_staticAutoId"
1855
+ [label]="_staticLabel"
1856
+ [model]="staticText()"
1857
+ [disabled]="_disabled"
1858
+ (sdChange)="setStaticScalar($event)"></sd-input>
1859
+ }
1860
+ }
1861
+ }
1862
+ </div>
1863
+ `, isInline: true, styles: [":host{display:block;width:100%}.sd-acb-source{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-start}.sd-acb-source__mode{flex:0 0 200px;min-width:180px}.sd-acb-source__expression,.sd-acb-source__static{flex:1 1 260px;min-width:220px}.sd-acb-source__static--json{flex:1 1 100%;min-width:0}\n"], dependencies: [{ kind: "component", type: SdCodeEditor, selector: "sd-code-editor", inputs: ["model", "language", "maxHeight", "viewed"], outputs: ["modelChange"] }, { kind: "component", type: SdDate, selector: "sd-date", inputs: ["autoId", "name", "size", "form", "label", "helperText", "placeholder", "hideInlineError", "required", "disabled", "viewed", "clearable", "inlineError", "hyperlink", "appearance", "floatLabel", "min", "minDate", "max", "maxDate", "transform", "model"], outputs: ["modelChange", "sdChange", "sdFocus"] }, { kind: "component", type: SdDatetime, selector: "sd-datetime", inputs: ["autoId", "name", "size", "form", "label", "helperText", "placeholder", "hideInlineError", "required", "disabled", "viewed", "showSeconds", "clearable", "inlineError", "hyperlink", "appearance", "floatLabel", "min", "minDate", "max", "maxDate", "transform", "model"], outputs: ["modelChange", "sdChange", "sdFocus"] }, { kind: "component", type: SdInput, selector: "sd-input", inputs: ["autoId", "name", "appearance", "floatLabel", "size", "form", "label", "helperText", "placeholder", "type", "mask", "hideInlineError", "blurOnEnter", "clearable", "required", "readonly", "disabled", "viewed", "minlength", "maxlength", "pattern", "patternErrorMessage", "validator", "inlineError", "hyperlink", "model"], outputs: ["modelChange", "sdChange", "sdFocus", "sdBlur", "keyupEnter", "cleared", "sdFocusForceBlur"] }, { kind: "component", type: SdInputNumber, selector: "sd-input-number", inputs: ["autoId", "name", "size", "form", "label", "helperText", "placeholder", "hideInlineError", "blurOnEnter", "clearable", "required", "readonly", "disabled", "viewed", "type", "precision", "format", "min", "max", "validator", "inlineError", "hyperlink", "appearance", "floatLabel", "model"], outputs: ["modelChange", "sdChange", "sdFocus", "sdBlur", "keyupEnter", "cleared", "sdFocusForceBlur"] }, { kind: "component", type: SdSelect, selector: "sd-select", inputs: ["autoId", "name", "size", "form", "label", "helperText", "placeholder", "valueField", "displayField", "disabledField", "cacheChecksum", "limit", "hyperlink", "minWidthPanel", "hideInlineError", "required", "disabled", "viewed", "multiple", "showSelectAll", "clearable", "validator", "inlineError", "appearance", "floatLabel", "items", "model"], outputs: ["modelChange", "sdChange", "sdSelection"] }, { kind: "pipe", type: SdTranslatePipe, name: "sdTranslate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1864
+ }
1865
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: SdApiContractSourceEditor, decorators: [{
1866
+ type: Component,
1867
+ args: [{ selector: 'sd-api-contract-source-editor', standalone: true, imports: [SdCodeEditor, SdDate, SdDatetime, SdInput, SdInputNumber, SdSelect, SdTranslatePipe], changeDetection: ChangeDetectionStrategy.OnPush, template: `
1868
+ @let _node = node();
1869
+ @let _disabled = disabled();
1870
+ @let _mode = mode();
1871
+ @let _autoId = autoId();
1872
+ @let _suggestions = sourceOptions();
1873
+
1874
+ <div class="sd-acb-source">
1875
+ <sd-select
1876
+ class="sd-acb-source__mode"
1877
+ size="sm"
1878
+ hideInlineError
1879
+ [clearable]="false"
1880
+ [autoId]="_autoId ? _autoId + '-mode' : undefined"
1881
+ [label]="'core.component.api-contract-builder.mapping.mode' | sdTranslate"
1882
+ [items]="modeOptions()"
1883
+ valueField="value"
1884
+ displayField="label"
1885
+ [required]="true"
1886
+ [model]="modeValue()"
1887
+ [disabled]="_disabled"
1888
+ (sdChange)="setMode($event)"></sd-select>
1889
+
1890
+ @if (_mode === 'source') {
1891
+ <sd-select
1892
+ class="sd-acb-source__expression"
1893
+ size="sm"
1894
+ hideInlineError
1895
+ [clearable]="false"
1896
+ [autoId]="_autoId ? _autoId + '-source' : undefined"
1897
+ [label]="'core.component.api-contract-builder.mapping.source' | sdTranslate"
1898
+ [items]="_suggestions"
1899
+ valueField="expression"
1900
+ displayField="display"
1901
+ [model]="_node.source ?? ''"
1902
+ [disabled]="_disabled"
1903
+ (sdChange)="setSource($event)"></sd-select>
1904
+ } @else if (_mode === 'advanced') {
1905
+ <sd-input
1906
+ class="sd-acb-source__expression"
1907
+ size="sm"
1908
+ hideInlineError
1909
+ [autoId]="_autoId ? _autoId + '-advanced' : undefined"
1910
+ [label]="'core.component.api-contract-builder.mapping.source' | sdTranslate"
1911
+ [placeholder]="advancedPlaceholder"
1912
+ [model]="_node.source ?? ''"
1913
+ [disabled]="_disabled"
1914
+ (sdChange)="setSource($event)"></sd-input>
1915
+ } @else if (_mode === 'static') {
1916
+ @let _staticAutoId = _autoId ? _autoId + '-static' : undefined;
1917
+ @let _staticLabel = 'core.component.api-contract-builder.mapping.value' | sdTranslate;
1918
+
1919
+ @switch (_node.type) {
1920
+ @case ('boolean') {
1921
+ <sd-select
1922
+ class="sd-acb-source__static"
1923
+ size="sm"
1924
+ hideInlineError
1925
+ [clearable]="false"
1926
+ [autoId]="_staticAutoId"
1927
+ [label]="_staticLabel"
1928
+ [items]="booleanOptions"
1929
+ valueField="value"
1930
+ displayField="label"
1931
+ [model]="_node.value === true ? 'true' : 'false'"
1932
+ [disabled]="_disabled"
1933
+ (sdChange)="setStaticBoolean($event)"></sd-select>
1934
+ }
1935
+ @case ('number') {
1936
+ <sd-input-number
1937
+ class="sd-acb-source__static"
1938
+ size="sm"
1939
+ hideInlineError
1940
+ [autoId]="_staticAutoId"
1941
+ [label]="_staticLabel"
1942
+ [model]="staticNumber()"
1943
+ [disabled]="_disabled"
1944
+ (sdChange)="setStaticNumber($event)"></sd-input-number>
1945
+ }
1946
+ @case ('date') {
1947
+ <sd-date
1948
+ class="sd-acb-source__static"
1949
+ size="sm"
1950
+ hideInlineError
1951
+ [autoId]="_staticAutoId"
1952
+ [label]="_staticLabel"
1953
+ [model]="staticText()"
1954
+ [disabled]="_disabled"
1955
+ (sdChange)="setStaticTemporal($event)"></sd-date>
1956
+ }
1957
+ @case ('datetime') {
1958
+ <sd-datetime
1959
+ class="sd-acb-source__static"
1960
+ size="sm"
1961
+ hideInlineError
1962
+ [autoId]="_staticAutoId"
1963
+ [label]="_staticLabel"
1964
+ [model]="staticText()"
1965
+ [disabled]="_disabled"
1966
+ (sdChange)="setStaticTemporal($event)"></sd-datetime>
1967
+ }
1968
+ @case ('object') {
1969
+ <sd-code-editor
1970
+ class="sd-acb-source__static sd-acb-source__static--json"
1971
+ language="json"
1972
+ maxHeight="220px"
1973
+ [model]="_node.value"
1974
+ [viewed]="_disabled"
1975
+ (modelChange)="setStaticJson($event)"></sd-code-editor>
1976
+ }
1977
+ @case ('array') {
1978
+ <sd-code-editor
1979
+ class="sd-acb-source__static sd-acb-source__static--json"
1980
+ language="json"
1981
+ maxHeight="220px"
1982
+ [model]="_node.value"
1983
+ [viewed]="_disabled"
1984
+ (modelChange)="setStaticJson($event)"></sd-code-editor>
1985
+ }
1986
+ @default {
1987
+ <sd-input
1988
+ class="sd-acb-source__static"
1989
+ size="sm"
1990
+ hideInlineError
1991
+ [autoId]="_staticAutoId"
1992
+ [label]="_staticLabel"
1993
+ [model]="staticText()"
1994
+ [disabled]="_disabled"
1995
+ (sdChange)="setStaticScalar($event)"></sd-input>
1996
+ }
1997
+ }
1998
+ }
1999
+ </div>
2000
+ `, styles: [":host{display:block;width:100%}.sd-acb-source{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-start}.sd-acb-source__mode{flex:0 0 200px;min-width:180px}.sd-acb-source__expression,.sd-acb-source__static{flex:1 1 260px;min-width:220px}.sd-acb-source__static--json{flex:1 1 100%;min-width:0}\n"] }]
2001
+ }], propDecorators: { node: [{ type: i0.Input, args: [{ isSignal: true, alias: "node", required: true }] }], suggestions: [{ type: i0.Input, args: [{ isSignal: true, alias: "suggestions", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], autoId: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoId", required: false }] }], nodeChange: [{ type: i0.Output, args: ["nodeChange"] }] } });
2002
+ /**
2003
+ * Reads the mode out of the node alone — the contract never stores it.
2004
+ *
2005
+ * An EMPTY `source` means "Lấy từ nguồn đã chọn, chưa pick" and stays in `source`; anything the
2006
+ * dropdown cannot express (a template with literal text, or text with no reference at all) is
2007
+ * `advanced`. A clean single reference stays in `source` even when it resolves to nothing, so a typo
2008
+ * is fixed in the friendly picker while validation reports `mapping.reference.missing`.
2009
+ */
2010
+ function deriveMode(node) {
2011
+ if (node.source !== undefined) {
2012
+ if (node.source === '')
2013
+ return 'source';
2014
+ return parseSdApiContractTemplate(node.source).kind === 'exact' ? 'source' : 'advanced';
2015
+ }
2016
+ if (node.value !== undefined)
2017
+ return 'static';
2018
+ return node.type === 'object' ? 'nested' : 'none';
2019
+ }
2020
+ /** Rebuilds a node without `source` / `value`, so the key is gone rather than set to `undefined`. */
2021
+ function withoutMapping(node) {
2022
+ const next = {};
2023
+ for (const key of Object.keys(node)) {
2024
+ if (key === 'source' || key === 'value')
2025
+ continue;
2026
+ next[key] = node[key];
2027
+ }
2028
+ return next;
2029
+ }
2030
+ function defaultStatic(type) {
2031
+ if (type === 'number')
2032
+ return 0;
2033
+ if (type === 'boolean')
2034
+ return false;
2035
+ if (type === 'object')
2036
+ return {};
2037
+ if (type === 'array')
2038
+ return [];
2039
+ return '';
2040
+ }
2041
+ function isAssignable(sourceType, targetType) {
2042
+ if (sourceType === targetType)
2043
+ return true;
2044
+ const temporalish = (type) => type === 'string' || type === 'date' || type === 'datetime';
2045
+ return temporalish(sourceType) && temporalish(targetType);
2046
+ }
2047
+
2048
+ /**
2049
+ * Editing surface for exactly one node, staged and committed in one go.
2050
+ *
2051
+ * The layer lists outside are read-only, so this drawer is the only place a node changes. It holds a
2052
+ * DEEP COPY of the node it was seeded from and emits `nodeCommit` once, when the author saves — that
2053
+ * is what makes "Huỷ" able to actually cancel, and what keeps the tree from changing under the
2054
+ * author's cursor on every keystroke.
2055
+ *
2056
+ * Save is blocked only for the two problems that stop a node from existing at all: no name, or a name
2057
+ * a sibling already holds. Everything else — a reference to a field that does not exist yet, a type
2058
+ * that will not fit — saves and is reported by `validateSdApiContract`, because inventing rules here
2059
+ * would stop an author declaring one end of a mapping before the other exists.
2060
+ */
2061
+ class SdApiContractNodeDrawer {
2062
+ #i18n = inject(I18nService);
2063
+ /** `mapping` shows the value editor; `schema` is a plain declaration with no mapping. */
2064
+ layer = input('mapping', ...(ngDevMode ? [{ debugName: "layer" }] : /* istanbul ignore next */ []));
2065
+ allowTransform = input(false, { ...(ngDevMode ? { debugName: "allowTransform" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
2066
+ suggestions = input([], ...(ngDevMode ? [{ debugName: "suggestions" }] : /* istanbul ignore next */ []));
2067
+ autoId = input(...(ngDevMode ? [undefined, { debugName: "autoId" }] : /* istanbul ignore next */ []));
2068
+ nodeCommit = output();
2069
+ // why không phải ES-private: Angular từ chối `viewChild` trên field `#` (diagnostic 1053).
2070
+ drawerRef = viewChild.required('drawer');
2071
+ // why: seed là mốc so sánh để biết draft có bẩn hay chưa. Không có nó thì "Huỷ" phải đoán, và mọi
2072
+ // lần mở drawer đều bị coi là đã sửa.
2073
+ #seedName = '';
2074
+ #seedJson = '';
2075
+ #draftName = signal('', ...(ngDevMode ? [{ debugName: "#draftName" }] : /* istanbul ignore next */ []));
2076
+ #draft = signal(null, ...(ngDevMode ? [{ debugName: "#draft" }] : /* istanbul ignore next */ []));
2077
+ #rootSiblings = signal([], ...(ngDevMode ? [{ debugName: "#rootSiblings" }] : /* istanbul ignore next */ []));
2078
+ #editingName = signal(null, ...(ngDevMode ? [{ debugName: "#editingName" }] : /* istanbul ignore next */ []));
2079
+ #discardPrompt = signal(false, ...(ngDevMode ? [{ debugName: "#discardPrompt" }] : /* istanbul ignore next */ []));
2080
+ // why pointer chứ không phải "node hiện tại": draft luôn là GỐC của subtree, và một lần Lưu commit
2081
+ // cả gốc. Giữ node con rời ra sẽ phải ghép lại lúc Save, và ghép sai là mất nhánh.
2082
+ #pointer = signal([], ...(ngDevMode ? [{ debugName: "#pointer" }] : /* istanbul ignore next */ []));
2083
+ // why: tên đang gõ ở CẤP HIỆN TẠI, kể cả khi nó chưa áp được vào draft.
2084
+ #typedName = signal('', ...(ngDevMode ? [{ debugName: "#typedName" }] : /* istanbul ignore next */ []));
2085
+ draftName = this.#draftName.asReadonly();
2086
+ discardPrompt = this.#discardPrompt.asReadonly();
2087
+ /** The node the author is looking at right now — the draft root, or a descendant of it. */
2088
+ current = computed(() => {
2089
+ const draft = this.#draft();
2090
+ if (!draft)
2091
+ return null;
2092
+ return getSdApiContractNodeAt(draft, this.#pointer());
2093
+ }, ...(ngDevMode ? [{ debugName: "current" }] : /* istanbul ignore next */ []));
2094
+ /** Every key on the breadcrumb. The pointer alternates `properties`/key, so keys sit on odd slots. */
2095
+ breadcrumb = computed(() => {
2096
+ const pointer = this.#pointer();
2097
+ const keys = pointer.filter((_, index) => index % 2 === 1);
2098
+ return [this.#draftName() || this.#i18n.t('core.component.api-contract-builder.drawer.add-title'), ...keys];
2099
+ }, ...(ngDevMode ? [{ debugName: "breadcrumb" }] : /* istanbul ignore next */ []));
2100
+ /**
2101
+ * What the name field shows.
2102
+ *
2103
+ * why một signal riêng chứ không đọc thẳng từ draft: một tên KHÔNG áp được — rỗng, hoặc trùng
2104
+ * sibling — vẫn phải hiện ra để người dùng thấy mình vừa gõ gì và đọc được lý do bị chặn. Đọc
2105
+ * thẳng từ draft thì ô input nhảy về tên cũ ngay khi gõ, tức là từ chối im lặng.
2106
+ */
2107
+ currentName = this.#typedName.asReadonly();
2108
+ children = computed(() => {
2109
+ const node = this.current();
2110
+ const properties = node?.properties;
2111
+ if (!properties)
2112
+ return [];
2113
+ return Object.keys(properties).map(key => ({ key, node: properties[key] }));
2114
+ }, ...(ngDevMode ? [{ debugName: "children" }] : /* istanbul ignore next */ []));
2115
+ /** Siblings on the level the author is on — the root list, or the parent object's keys. */
2116
+ siblingNames = computed(() => {
2117
+ const pointer = this.#pointer();
2118
+ if (pointer.length === 0)
2119
+ return this.#rootSiblings();
2120
+ const draft = this.#draft();
2121
+ if (!draft)
2122
+ return [];
2123
+ const parent = getSdApiContractNodeAt(draft, pointer.slice(0, -2));
2124
+ return parent?.properties ? Object.keys(parent.properties) : [];
2125
+ }, ...(ngDevMode ? [{ debugName: "siblingNames" }] : /* istanbul ignore next */ []));
2126
+ typeOptions = SD_API_CONTRACT_DATA_TYPES.map(type => ({
2127
+ value: type,
2128
+ label: type,
2129
+ }));
2130
+ requiredOptions = [
2131
+ { value: 'true', label: this.#i18n.t('core.component.api-contract-builder.required.true') },
2132
+ { value: 'false', label: this.#i18n.t('core.component.api-contract-builder.required.false') },
2133
+ ];
2134
+ transformOptions = [
2135
+ { value: '', label: this.#i18n.t('core.component.api-contract-builder.transform.none') },
2136
+ { value: 'ISOString', label: 'ISOString' },
2137
+ { value: 'UTCString', label: 'UTCString' },
2138
+ ];
2139
+ title = computed(() => this.#editingName() === null
2140
+ ? this.#i18n.t('core.component.api-contract-builder.drawer.add-title')
2141
+ : this.#i18n.t('core.component.api-contract-builder.drawer.edit-title'), ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
2142
+ dirty = computed(() => {
2143
+ const draft = this.#draft();
2144
+ if (!draft)
2145
+ return false;
2146
+ return this.#draftName() !== this.#seedName || JSON.stringify(draft) !== this.#seedJson;
2147
+ }, ...(ngDevMode ? [{ debugName: "dirty" }] : /* istanbul ignore next */ []));
2148
+ nameError = computed(() => {
2149
+ const name = this.currentName().trim();
2150
+ if (name === '')
2151
+ return this.#i18n.t('core.component.api-contract-builder.node.name-required');
2152
+ // why: node đang sửa luôn nằm trong danh sách sibling của chính cấp nó — so với chính nó là tự báo
2153
+ // trùng. "Chính nó" phải là key THẬT đang lưu, không phải chữ đang gõ: lấy chữ đang gõ thì gõ đúng
2154
+ // tên một sibling khác cũng tự loại chính mình và lỗi trùng không bao giờ nổi lên.
2155
+ const pointer = this.#pointer();
2156
+ const self = pointer.length === 0 ? this.#editingName() : pointer[pointer.length - 1];
2157
+ const taken = this.siblingNames().some(sibling => sibling === name && sibling !== self);
2158
+ return taken ? this.#i18n.t('core.component.api-contract-builder.node.duplicate-key') : null;
2159
+ }, ...(ngDevMode ? [{ debugName: "nameError" }] : /* istanbul ignore next */ []));
2160
+ // why chỉ xét lỗi ở GỐC: Lưu commit cả subtree, và tên đang gõ ở cấp sâu đã được `setName` áp vào
2161
+ // draft ngay. Nếu chặn theo cấp hiện tại thì đứng ở cấp sâu sẽ không Lưu được gốc hợp lệ.
2162
+ canSave = computed(() => {
2163
+ if (this.#draft() === null)
2164
+ return false;
2165
+ if (this.#draftName().trim() === '')
2166
+ return false;
2167
+ return this.nameError() === null;
2168
+ }, ...(ngDevMode ? [{ debugName: "canSave" }] : /* istanbul ignore next */ []));
2169
+ /**
2170
+ * `beforeClose` guard for `<sd-side-drawer>`.
2171
+ *
2172
+ * why an arrow field, not a method: the drawer takes it as an input value, so it must keep `this`
2173
+ * without the template having to bind it.
2174
+ */
2175
+ closeGuard = () => this.guardClose();
2176
+ // -------------------------------------------------------------------------
2177
+ // Public surface the owning builder drives
2178
+ // -------------------------------------------------------------------------
2179
+ openForAdd(siblingNames = [], type = 'string') {
2180
+ this.#seed(null, createSdApiContractNode(type), siblingNames);
2181
+ this.drawerRef().open();
2182
+ }
2183
+ openForEdit(name, node, siblingNames = []) {
2184
+ this.#seed(name, cloneSdApiContractNode(node), siblingNames);
2185
+ this.drawerRef().open();
2186
+ }
2187
+ // -------------------------------------------------------------------------
2188
+ // Draft edits
2189
+ // -------------------------------------------------------------------------
2190
+ /**
2191
+ * Types into the name field.
2192
+ *
2193
+ * The typed text is ALWAYS kept, then applied to the draft only when it can be. A name that cannot
2194
+ * be applied — empty, or already taken by a sibling — stays visible and `nameError()` explains it,
2195
+ * which is what blocks Save. Refusing the keystroke instead would leave the field showing one name
2196
+ * while the contract holds another.
2197
+ */
2198
+ setName(value) {
2199
+ const name = typeof value === 'string' ? value : '';
2200
+ this.#typedName.set(name);
2201
+ const pointer = this.#pointer();
2202
+ if (pointer.length === 0) {
2203
+ // why chỉ ghi khi hợp lệ: `#draftName` là thứ `save()` phát ra. Ghi tên rỗng vào đó rồi chặn ở
2204
+ // `canSave` cũng được, nhưng giữ nó luôn hợp lệ thì `save()` không cần tin vào cổng chặn.
2205
+ if (name.trim() !== '')
2206
+ this.#draftName.set(name);
2207
+ return;
2208
+ }
2209
+ const draft = this.#draft();
2210
+ const from = pointer[pointer.length - 1];
2211
+ if (!draft || name.trim() === '' || name === from)
2212
+ return;
2213
+ const parentPointer = pointer.slice(0, -2);
2214
+ const parent = getSdApiContractNodeAt(draft, parentPointer);
2215
+ if (!parent)
2216
+ return;
2217
+ const renamed = renameSdApiContractProperty(parent, from, name);
2218
+ // why: helper trả về CHÍNH object cũ khi trùng key. Không áp được thì thôi — `#typedName` đã giữ
2219
+ // chữ người dùng gõ và `nameError()` đã báo, nên không có gì bị nuốt im lặng.
2220
+ if (renamed === parent)
2221
+ return;
2222
+ this.#draft.set(setSdApiContractNodeAt(draft, parentPointer, renamed));
2223
+ // why dời pointer theo: nếu không, pointer vẫn trỏ key CŨ — `current()` trả null và form trắng xoá.
2224
+ this.#pointer.set([...parentPointer, 'properties', name]);
2225
+ }
2226
+ setType(value) {
2227
+ const draft = this.#draft();
2228
+ if (!draft || typeof value !== 'string')
2229
+ return;
2230
+ if (!SD_API_CONTRACT_DATA_TYPES.includes(value))
2231
+ return;
2232
+ this.applyCurrent(changeSdApiContractNodeType(draft, value));
2233
+ }
2234
+ setRequired(value) {
2235
+ const draft = this.#draft();
2236
+ if (!draft)
2237
+ return;
2238
+ if (value === 'true') {
2239
+ this.applyCurrent({ ...draft, required: true });
2240
+ return;
2241
+ }
2242
+ // why: bỏ hẳn key thay vì ghi `false` — key vắng và `false` nghĩa như nhau với consumer, và JSON
2243
+ // gọn hơn. Một `false` do người khác viết tay vẫn được giữ khi nạp vào.
2244
+ const next = { ...draft };
2245
+ delete next['required'];
2246
+ this.applyCurrent(next);
2247
+ }
2248
+ setText(key, value) {
2249
+ const draft = this.#draft();
2250
+ if (!draft)
2251
+ return;
2252
+ const text = typeof value === 'string' ? value.trim() : '';
2253
+ const next = { ...draft };
2254
+ if (text === '')
2255
+ delete next[key];
2256
+ else
2257
+ next[key] = text;
2258
+ this.applyCurrent(next);
2259
+ }
2260
+ setTransform(value) {
2261
+ const draft = this.#draft();
2262
+ if (!draft)
2263
+ return;
2264
+ const next = { ...draft };
2265
+ if (value === 'ISOString' || value === 'UTCString')
2266
+ next['transform'] = value;
2267
+ else
2268
+ delete next['transform'];
2269
+ this.applyCurrent(next);
2270
+ }
2271
+ applyCurrent(node) {
2272
+ const draft = this.#draft();
2273
+ if (!draft)
2274
+ return;
2275
+ this.#draft.set(setSdApiContractNodeAt(draft, this.#pointer(), node));
2276
+ }
2277
+ enter(key) {
2278
+ this.#pointer.set([...this.#pointer(), 'properties', key]);
2279
+ this.#syncTypedName();
2280
+ this.#discardPrompt.set(false);
2281
+ }
2282
+ /** `depth` counts breadcrumb entries, so 0 is the draft root. */
2283
+ backTo(depth) {
2284
+ this.#pointer.set(this.#pointer().slice(0, depth * 2));
2285
+ this.#syncTypedName();
2286
+ this.#discardPrompt.set(false);
2287
+ }
2288
+ addChild() {
2289
+ const node = this.current();
2290
+ const draft = this.#draft();
2291
+ if (!node || !draft)
2292
+ return;
2293
+ const key = uniqueChildKey(node.properties ?? {});
2294
+ const next = addSdApiContractProperty(node, key, createSdApiContractNode('string'));
2295
+ this.#draft.set(setSdApiContractNodeAt(draft, this.#pointer(), next));
2296
+ }
2297
+ removeChild(key) {
2298
+ const node = this.current();
2299
+ const draft = this.#draft();
2300
+ if (!node || !draft)
2301
+ return;
2302
+ this.#draft.set(setSdApiContractNodeAt(draft, this.#pointer(), removeSdApiContractProperty(node, key)));
2303
+ }
2304
+ // -------------------------------------------------------------------------
2305
+ // Chốt sổ
2306
+ // -------------------------------------------------------------------------
2307
+ save() {
2308
+ const draft = this.#draft();
2309
+ if (!draft || !this.canSave())
2310
+ return;
2311
+ this.#discardPrompt.set(false);
2312
+ this.nodeCommit.emit({ name: this.#draftName().trim(), node: draft });
2313
+ // why: forceClose bỏ qua `beforeClose`. Save đã là chủ ý rõ ràng — hỏi lại "bỏ thay đổi?" ngay sau
2314
+ // khi vừa lưu là vô nghĩa.
2315
+ this.#markCommitted();
2316
+ this.drawerRef().forceClose();
2317
+ }
2318
+ requestCancel() {
2319
+ void this.drawerRef().requestClose();
2320
+ }
2321
+ confirmDiscard() {
2322
+ this.#discardPrompt.set(false);
2323
+ this.#markCommitted();
2324
+ this.drawerRef().forceClose();
2325
+ }
2326
+ cancelDiscard() {
2327
+ this.#discardPrompt.set(false);
2328
+ }
2329
+ /**
2330
+ * Blocks the close while the draft is dirty and raises an inline prompt instead.
2331
+ *
2332
+ * why inline chứ không `window.confirm`: một component thư viện không được dựng dialog của browser
2333
+ * — nó không style được, không test được, và chặn cả tab.
2334
+ */
2335
+ guardClose() {
2336
+ if (!this.dirty())
2337
+ return true;
2338
+ this.#discardPrompt.set(true);
2339
+ return false;
2340
+ }
2341
+ onClosed() {
2342
+ this.#discardPrompt.set(false);
2343
+ }
2344
+ // -------------------------------------------------------------------------
2345
+ #seed(name, node, siblingNames) {
2346
+ this.#editingName.set(name);
2347
+ this.#draftName.set(name ?? '');
2348
+ this.#draft.set(node);
2349
+ this.#pointer.set([]);
2350
+ this.#typedName.set(name ?? '');
2351
+ this.#rootSiblings.set([...siblingNames]);
2352
+ this.#discardPrompt.set(false);
2353
+ this.#seedName = name ?? '';
2354
+ this.#seedJson = JSON.stringify(node);
2355
+ }
2356
+ /** Re-points the name field at whatever level the author just moved to. */
2357
+ #syncTypedName() {
2358
+ const pointer = this.#pointer();
2359
+ this.#typedName.set(pointer.length === 0 ? this.#draftName() : pointer[pointer.length - 1]);
2360
+ }
2361
+ /** Re-baselines the seed so a closing drawer is never considered dirty any more. */
2362
+ #markCommitted() {
2363
+ const draft = this.#draft();
2364
+ this.#seedName = this.#draftName();
2365
+ this.#seedJson = draft ? JSON.stringify(draft) : '';
2366
+ }
2367
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: SdApiContractNodeDrawer, deps: [], target: i0.ɵɵFactoryTarget.Component });
2368
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: SdApiContractNodeDrawer, isStandalone: true, selector: "sd-api-contract-node-drawer", inputs: { layer: { classPropertyName: "layer", publicName: "layer", isSignal: true, isRequired: false, transformFunction: null }, allowTransform: { classPropertyName: "allowTransform", publicName: "allowTransform", isSignal: true, isRequired: false, transformFunction: null }, suggestions: { classPropertyName: "suggestions", publicName: "suggestions", isSignal: true, isRequired: false, transformFunction: null }, autoId: { classPropertyName: "autoId", publicName: "autoId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { nodeCommit: "nodeCommit" }, viewQueries: [{ propertyName: "drawerRef", first: true, predicate: ["drawer"], descendants: true, isSignal: true }], ngImport: i0, template: `
2369
+ @let _autoId = autoId();
2370
+ @let _current = current();
2371
+ @let _nameError = nameError();
2372
+
2373
+ <sd-side-drawer
2374
+ #drawer
2375
+ width="560px"
2376
+ disableBackdropClose
2377
+ [title]="title()"
2378
+ [autoId]="_autoId"
2379
+ [beforeClose]="closeGuard"
2380
+ (sdClosed)="onClosed()">
2381
+ @if (_current) {
2382
+ <div class="sd-acb-drawer">
2383
+ @let _breadcrumb = breadcrumb();
2384
+ @if (_breadcrumb.length > 1) {
2385
+ <nav class="sd-acb-drawer__crumbs" [attr.aria-label]="title()">
2386
+ @for (crumb of _breadcrumb; track $index) {
2387
+ @if ($index < _breadcrumb.length - 1) {
2388
+ <button
2389
+ type="button"
2390
+ class="sd-acb-drawer__crumb"
2391
+ [attr.data-autoid]="_autoId ? _autoId + '-crumb-' + $index : null"
2392
+ (click)="backTo($index)">
2393
+ {{ crumb }}
2394
+ </button>
2395
+ <span class="sd-acb-drawer__crumb-sep">›</span>
2396
+ } @else {
2397
+ <span class="sd-acb-drawer__crumb sd-acb-drawer__crumb--current">{{ crumb }}</span>
2398
+ }
2399
+ }
2400
+ </nav>
2401
+ }
2402
+
2403
+ <sd-input
2404
+ size="sm"
2405
+ [autoId]="_autoId ? _autoId + '-name' : undefined"
2406
+ [label]="'core.component.api-contract-builder.node.name' | sdTranslate"
2407
+ [model]="currentName()"
2408
+ [inlineError]="_nameError ?? undefined"
2409
+ (sdChange)="setName($event)"></sd-input>
2410
+
2411
+ <sd-select
2412
+ size="sm"
2413
+ hideInlineError
2414
+ [clearable]="false"
2415
+ [autoId]="_autoId ? _autoId + '-type' : undefined"
2416
+ [label]="'core.component.api-contract-builder.node.type' | sdTranslate"
2417
+ [items]="typeOptions"
2418
+ valueField="value"
2419
+ displayField="label"
2420
+ [model]="_current.type"
2421
+ (sdChange)="setType($event)"></sd-select>
2422
+
2423
+ <sd-select
2424
+ size="sm"
2425
+ hideInlineError
2426
+ [clearable]="false"
2427
+ [autoId]="_autoId ? _autoId + '-required' : undefined"
2428
+ [label]="'core.component.api-contract-builder.node.required' | sdTranslate"
2429
+ [items]="requiredOptions"
2430
+ valueField="value"
2431
+ displayField="label"
2432
+ [model]="_current.required === true ? 'true' : 'false'"
2433
+ (sdChange)="setRequired($event)"></sd-select>
2434
+
2435
+ <sd-input
2436
+ size="sm"
2437
+ hideInlineError
2438
+ [autoId]="_autoId ? _autoId + '-label' : undefined"
2439
+ [label]="'core.component.api-contract-builder.node.label' | sdTranslate"
2440
+ [model]="_current.label ?? ''"
2441
+ (sdChange)="setText('label', $event)"></sd-input>
2442
+
2443
+ <sd-input
2444
+ size="sm"
2445
+ hideInlineError
2446
+ [autoId]="_autoId ? _autoId + '-description' : undefined"
2447
+ [label]="'core.component.api-contract-builder.node.description' | sdTranslate"
2448
+ [model]="_current.description ?? ''"
2449
+ (sdChange)="setText('description', $event)"></sd-input>
2450
+
2451
+ @if (allowTransform() && (_current.type === 'date' || _current.type === 'datetime')) {
2452
+ <sd-select
2453
+ size="sm"
2454
+ hideInlineError
2455
+ [clearable]="false"
2456
+ [autoId]="_autoId ? _autoId + '-transform' : undefined"
2457
+ [label]="'core.component.api-contract-builder.node.transform' | sdTranslate"
2458
+ [items]="transformOptions"
2459
+ valueField="value"
2460
+ displayField="label"
2461
+ [model]="_current.transform ?? ''"
2462
+ (sdChange)="setTransform($event)"></sd-select>
2463
+ }
2464
+
2465
+ @if (layer() === 'mapping') {
2466
+ <sd-api-contract-source-editor
2467
+ [node]="_current"
2468
+ [suggestions]="suggestions()"
2469
+ [autoId]="_autoId ? _autoId + '-value' : undefined"
2470
+ (nodeChange)="applyCurrent($event)"></sd-api-contract-source-editor>
2471
+ }
2472
+
2473
+ @if (_current.type === 'object') {
2474
+ @let _children = children();
2475
+ <div class="sd-acb-drawer__children">
2476
+ <h5 class="sd-acb-drawer__children-title">
2477
+ {{ 'core.component.api-contract-builder.node.add-property' | sdTranslate }}
2478
+ </h5>
2479
+
2480
+ @if (!_children.length) {
2481
+ <p class="sd-acb-drawer__empty">{{ 'core.component.api-contract-builder.node.empty' | sdTranslate }}</p>
2482
+ }
2483
+
2484
+ @for (child of _children; track child.key) {
2485
+ <sd-api-contract-node-summary
2486
+ [name]="child.key"
2487
+ [node]="child.node"
2488
+ [autoId]="_autoId ? _autoId + '-child-' + child.key : undefined"
2489
+ (edit)="enter(child.key)"
2490
+ (remove)="removeChild(child.key)"></sd-api-contract-node-summary>
2491
+ }
2492
+
2493
+ <sd-button
2494
+ type="light"
2495
+ size="sm"
2496
+ prefixIcon="add"
2497
+ [autoId]="_autoId ? _autoId + '-add-child' : undefined"
2498
+ [title]="'core.component.api-contract-builder.node.add-property' | sdTranslate"
2499
+ (click)="addChild()"></sd-button>
2500
+ </div>
2501
+ }
2502
+
2503
+ @if (discardPrompt()) {
2504
+ <div class="sd-acb-drawer__confirm" role="alertdialog">
2505
+ <span>{{ 'core.component.api-contract-builder.drawer.discard-confirm' | sdTranslate }}</span>
2506
+ <sd-button
2507
+ type="text"
2508
+ size="sm"
2509
+ color="error"
2510
+ [autoId]="_autoId ? _autoId + '-discard' : undefined"
2511
+ [title]="'core.component.api-contract-builder.confirm.yes' | sdTranslate"
2512
+ (click)="confirmDiscard()"></sd-button>
2513
+ <sd-button
2514
+ type="text"
2515
+ size="sm"
2516
+ [autoId]="_autoId ? _autoId + '-keep' : undefined"
2517
+ [title]="'core.component.api-contract-builder.confirm.no' | sdTranslate"
2518
+ (click)="cancelDiscard()"></sd-button>
2519
+ </div>
2520
+ }
2521
+ </div>
2522
+ }
2523
+
2524
+ <sd-button
2525
+ sdFooterRight
2526
+ type="text"
2527
+ [autoId]="_autoId ? _autoId + '-cancel' : undefined"
2528
+ [title]="'core.component.api-contract-builder.drawer.cancel' | sdTranslate"
2529
+ (click)="requestCancel()"></sd-button>
2530
+ <sd-button
2531
+ sdFooterRight
2532
+ type="fill"
2533
+ color="primary"
2534
+ [autoId]="_autoId ? _autoId + '-save' : undefined"
2535
+ [disabled]="!canSave()"
2536
+ [title]="'core.component.api-contract-builder.drawer.save' | sdTranslate"
2537
+ (click)="save()"></sd-button>
2538
+ </sd-side-drawer>
2539
+ `, isInline: true, styles: [".sd-acb-drawer{display:flex;flex-direction:column;gap:12px;padding:16px}.sd-acb-drawer__crumbs{display:flex;flex-wrap:wrap;gap:4px;align-items:center;font-size:12px}.sd-acb-drawer__crumb{padding:2px 4px;border:0;border-radius:4px;background:transparent;color:var(--sd-primary, #005cbb);font:inherit;cursor:pointer}.sd-acb-drawer__crumb:hover{background:color-mix(in srgb,var(--sd-primary, #005cbb) 10%,transparent)}.sd-acb-drawer__crumb--current{color:var(--sd-text, #1a1b1f);font-weight:600;cursor:default}.sd-acb-drawer__crumb-sep{color:var(--sd-text-secondary, #6b6b6b)}.sd-acb-drawer__children{display:flex;flex-direction:column;gap:6px;padding-top:4px;border-top:1px solid var(--sd-border-color, #e6e6e6)}.sd-acb-drawer__children-title{margin:0;font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--sd-text-secondary, #6b6b6b)}.sd-acb-drawer__empty{margin:0;font-size:12px;font-style:italic;color:var(--sd-text-secondary, #6b6b6b)}.sd-acb-drawer__confirm{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:8px 10px;border:1px dashed var(--sd-border-color, #e6e6e6);border-radius:6px;background:var(--sd-surface-muted, #f3f5f8);font-size:12px}\n"], dependencies: [{ kind: "component", type: SdSideDrawer, selector: "sd-side-drawer", inputs: ["title", "width", "hideClose", "disableBackdropClose", "beforeClose", "drawerClass", "autoId"], outputs: ["sdClosed", "sdCloseError"] }, { kind: "component", type: SdButton, selector: "sd-button", inputs: ["autoId", "type", "color", "size", "fontSet", "title", "width", "tooltip", "prefixIcon", "suffixIcon", "disabled", "loading", "block", "htmlType"], outputs: ["click"] }, { kind: "component", type: SdInput, selector: "sd-input", inputs: ["autoId", "name", "appearance", "floatLabel", "size", "form", "label", "helperText", "placeholder", "type", "mask", "hideInlineError", "blurOnEnter", "clearable", "required", "readonly", "disabled", "viewed", "minlength", "maxlength", "pattern", "patternErrorMessage", "validator", "inlineError", "hyperlink", "model"], outputs: ["modelChange", "sdChange", "sdFocus", "sdBlur", "keyupEnter", "cleared", "sdFocusForceBlur"] }, { kind: "component", type: SdSelect, selector: "sd-select", inputs: ["autoId", "name", "size", "form", "label", "helperText", "placeholder", "valueField", "displayField", "disabledField", "cacheChecksum", "limit", "hyperlink", "minWidthPanel", "hideInlineError", "required", "disabled", "viewed", "multiple", "showSelectAll", "clearable", "validator", "inlineError", "appearance", "floatLabel", "items", "model"], outputs: ["modelChange", "sdChange", "sdSelection"] }, { kind: "component", type: SdApiContractNodeSummary, selector: "sd-api-contract-node-summary", inputs: ["name", "node", "readonly", "autoId"], outputs: ["edit", "remove"] }, { kind: "component", type: SdApiContractSourceEditor, selector: "sd-api-contract-source-editor", inputs: ["node", "suggestions", "disabled", "autoId"], outputs: ["nodeChange"] }, { kind: "pipe", type: SdTranslatePipe, name: "sdTranslate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2540
+ }
2541
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: SdApiContractNodeDrawer, decorators: [{
2542
+ type: Component,
2543
+ args: [{ selector: 'sd-api-contract-node-drawer', standalone: true, imports: [SdSideDrawer, SdButton, SdInput, SdSelect, SdApiContractNodeSummary, SdApiContractSourceEditor, SdTranslatePipe], changeDetection: ChangeDetectionStrategy.OnPush, template: `
2544
+ @let _autoId = autoId();
2545
+ @let _current = current();
2546
+ @let _nameError = nameError();
2547
+
2548
+ <sd-side-drawer
2549
+ #drawer
2550
+ width="560px"
2551
+ disableBackdropClose
2552
+ [title]="title()"
2553
+ [autoId]="_autoId"
2554
+ [beforeClose]="closeGuard"
2555
+ (sdClosed)="onClosed()">
2556
+ @if (_current) {
2557
+ <div class="sd-acb-drawer">
2558
+ @let _breadcrumb = breadcrumb();
2559
+ @if (_breadcrumb.length > 1) {
2560
+ <nav class="sd-acb-drawer__crumbs" [attr.aria-label]="title()">
2561
+ @for (crumb of _breadcrumb; track $index) {
2562
+ @if ($index < _breadcrumb.length - 1) {
2563
+ <button
2564
+ type="button"
2565
+ class="sd-acb-drawer__crumb"
2566
+ [attr.data-autoid]="_autoId ? _autoId + '-crumb-' + $index : null"
2567
+ (click)="backTo($index)">
2568
+ {{ crumb }}
2569
+ </button>
2570
+ <span class="sd-acb-drawer__crumb-sep">›</span>
2571
+ } @else {
2572
+ <span class="sd-acb-drawer__crumb sd-acb-drawer__crumb--current">{{ crumb }}</span>
2573
+ }
2574
+ }
2575
+ </nav>
2576
+ }
2577
+
2578
+ <sd-input
2579
+ size="sm"
2580
+ [autoId]="_autoId ? _autoId + '-name' : undefined"
2581
+ [label]="'core.component.api-contract-builder.node.name' | sdTranslate"
2582
+ [model]="currentName()"
2583
+ [inlineError]="_nameError ?? undefined"
2584
+ (sdChange)="setName($event)"></sd-input>
2585
+
2586
+ <sd-select
2587
+ size="sm"
2588
+ hideInlineError
2589
+ [clearable]="false"
2590
+ [autoId]="_autoId ? _autoId + '-type' : undefined"
2591
+ [label]="'core.component.api-contract-builder.node.type' | sdTranslate"
2592
+ [items]="typeOptions"
2593
+ valueField="value"
2594
+ displayField="label"
2595
+ [model]="_current.type"
2596
+ (sdChange)="setType($event)"></sd-select>
2597
+
2598
+ <sd-select
2599
+ size="sm"
2600
+ hideInlineError
2601
+ [clearable]="false"
2602
+ [autoId]="_autoId ? _autoId + '-required' : undefined"
2603
+ [label]="'core.component.api-contract-builder.node.required' | sdTranslate"
2604
+ [items]="requiredOptions"
2605
+ valueField="value"
2606
+ displayField="label"
2607
+ [model]="_current.required === true ? 'true' : 'false'"
2608
+ (sdChange)="setRequired($event)"></sd-select>
2609
+
2610
+ <sd-input
2611
+ size="sm"
2612
+ hideInlineError
2613
+ [autoId]="_autoId ? _autoId + '-label' : undefined"
2614
+ [label]="'core.component.api-contract-builder.node.label' | sdTranslate"
2615
+ [model]="_current.label ?? ''"
2616
+ (sdChange)="setText('label', $event)"></sd-input>
2617
+
2618
+ <sd-input
2619
+ size="sm"
2620
+ hideInlineError
2621
+ [autoId]="_autoId ? _autoId + '-description' : undefined"
2622
+ [label]="'core.component.api-contract-builder.node.description' | sdTranslate"
2623
+ [model]="_current.description ?? ''"
2624
+ (sdChange)="setText('description', $event)"></sd-input>
2625
+
2626
+ @if (allowTransform() && (_current.type === 'date' || _current.type === 'datetime')) {
2627
+ <sd-select
2628
+ size="sm"
2629
+ hideInlineError
2630
+ [clearable]="false"
2631
+ [autoId]="_autoId ? _autoId + '-transform' : undefined"
2632
+ [label]="'core.component.api-contract-builder.node.transform' | sdTranslate"
2633
+ [items]="transformOptions"
2634
+ valueField="value"
2635
+ displayField="label"
2636
+ [model]="_current.transform ?? ''"
2637
+ (sdChange)="setTransform($event)"></sd-select>
2638
+ }
2639
+
2640
+ @if (layer() === 'mapping') {
2641
+ <sd-api-contract-source-editor
2642
+ [node]="_current"
2643
+ [suggestions]="suggestions()"
2644
+ [autoId]="_autoId ? _autoId + '-value' : undefined"
2645
+ (nodeChange)="applyCurrent($event)"></sd-api-contract-source-editor>
2646
+ }
2647
+
2648
+ @if (_current.type === 'object') {
2649
+ @let _children = children();
2650
+ <div class="sd-acb-drawer__children">
2651
+ <h5 class="sd-acb-drawer__children-title">
2652
+ {{ 'core.component.api-contract-builder.node.add-property' | sdTranslate }}
2653
+ </h5>
2654
+
2655
+ @if (!_children.length) {
2656
+ <p class="sd-acb-drawer__empty">{{ 'core.component.api-contract-builder.node.empty' | sdTranslate }}</p>
2657
+ }
2658
+
2659
+ @for (child of _children; track child.key) {
2660
+ <sd-api-contract-node-summary
2661
+ [name]="child.key"
2662
+ [node]="child.node"
2663
+ [autoId]="_autoId ? _autoId + '-child-' + child.key : undefined"
2664
+ (edit)="enter(child.key)"
2665
+ (remove)="removeChild(child.key)"></sd-api-contract-node-summary>
2666
+ }
2667
+
2668
+ <sd-button
2669
+ type="light"
2670
+ size="sm"
2671
+ prefixIcon="add"
2672
+ [autoId]="_autoId ? _autoId + '-add-child' : undefined"
2673
+ [title]="'core.component.api-contract-builder.node.add-property' | sdTranslate"
2674
+ (click)="addChild()"></sd-button>
2675
+ </div>
2676
+ }
2677
+
2678
+ @if (discardPrompt()) {
2679
+ <div class="sd-acb-drawer__confirm" role="alertdialog">
2680
+ <span>{{ 'core.component.api-contract-builder.drawer.discard-confirm' | sdTranslate }}</span>
2681
+ <sd-button
2682
+ type="text"
2683
+ size="sm"
2684
+ color="error"
2685
+ [autoId]="_autoId ? _autoId + '-discard' : undefined"
2686
+ [title]="'core.component.api-contract-builder.confirm.yes' | sdTranslate"
2687
+ (click)="confirmDiscard()"></sd-button>
2688
+ <sd-button
2689
+ type="text"
2690
+ size="sm"
2691
+ [autoId]="_autoId ? _autoId + '-keep' : undefined"
2692
+ [title]="'core.component.api-contract-builder.confirm.no' | sdTranslate"
2693
+ (click)="cancelDiscard()"></sd-button>
2694
+ </div>
2695
+ }
2696
+ </div>
2697
+ }
2698
+
2699
+ <sd-button
2700
+ sdFooterRight
2701
+ type="text"
2702
+ [autoId]="_autoId ? _autoId + '-cancel' : undefined"
2703
+ [title]="'core.component.api-contract-builder.drawer.cancel' | sdTranslate"
2704
+ (click)="requestCancel()"></sd-button>
2705
+ <sd-button
2706
+ sdFooterRight
2707
+ type="fill"
2708
+ color="primary"
2709
+ [autoId]="_autoId ? _autoId + '-save' : undefined"
2710
+ [disabled]="!canSave()"
2711
+ [title]="'core.component.api-contract-builder.drawer.save' | sdTranslate"
2712
+ (click)="save()"></sd-button>
2713
+ </sd-side-drawer>
2714
+ `, styles: [".sd-acb-drawer{display:flex;flex-direction:column;gap:12px;padding:16px}.sd-acb-drawer__crumbs{display:flex;flex-wrap:wrap;gap:4px;align-items:center;font-size:12px}.sd-acb-drawer__crumb{padding:2px 4px;border:0;border-radius:4px;background:transparent;color:var(--sd-primary, #005cbb);font:inherit;cursor:pointer}.sd-acb-drawer__crumb:hover{background:color-mix(in srgb,var(--sd-primary, #005cbb) 10%,transparent)}.sd-acb-drawer__crumb--current{color:var(--sd-text, #1a1b1f);font-weight:600;cursor:default}.sd-acb-drawer__crumb-sep{color:var(--sd-text-secondary, #6b6b6b)}.sd-acb-drawer__children{display:flex;flex-direction:column;gap:6px;padding-top:4px;border-top:1px solid var(--sd-border-color, #e6e6e6)}.sd-acb-drawer__children-title{margin:0;font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--sd-text-secondary, #6b6b6b)}.sd-acb-drawer__empty{margin:0;font-size:12px;font-style:italic;color:var(--sd-text-secondary, #6b6b6b)}.sd-acb-drawer__confirm{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:8px 10px;border:1px dashed var(--sd-border-color, #e6e6e6);border-radius:6px;background:var(--sd-surface-muted, #f3f5f8);font-size:12px}\n"] }]
2715
+ }], propDecorators: { layer: [{ type: i0.Input, args: [{ isSignal: true, alias: "layer", required: false }] }], allowTransform: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowTransform", required: false }] }], suggestions: [{ type: i0.Input, args: [{ isSignal: true, alias: "suggestions", required: false }] }], autoId: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoId", required: false }] }], nodeCommit: [{ type: i0.Output, args: ["nodeCommit"] }], drawerRef: [{ type: i0.ViewChild, args: ['drawer', { isSignal: true }] }] } });
2716
+ /** First free `truong`, `truong2`, … so adding twice never collides. */
2717
+ function uniqueChildKey(properties) {
2718
+ const base = 'truong';
2719
+ if (!Object.prototype.hasOwnProperty.call(properties, base))
2720
+ return base;
2721
+ for (let index = 2;; index += 1) {
2722
+ const candidate = `${base}${index}`;
2723
+ if (!Object.prototype.hasOwnProperty.call(properties, candidate))
2724
+ return candidate;
2725
+ }
2726
+ }
2727
+
2728
+ /**
2729
+ * One schema layer — `input.schema`, `req.body`, `res.body`, `output.schema` — rendered as a flat list
2730
+ * of the fields it declares.
2731
+ *
2732
+ * It used to be a recursive tree of editable seven-column rows. It is now a list you read: adding and
2733
+ * editing ask the builder to open its drawer, and only removal is applied straight away. The list is
2734
+ * deliberately FLAT — an `object` field shows how many fields it holds and the author drills into it
2735
+ * inside the drawer, so a deeply nested node is edited with a drawer's worth of room instead of a
2736
+ * 100px cell.
2737
+ */
2738
+ class SdApiContractNodeEditor {
2739
+ /** The layer's root node. */
2740
+ node = input.required(...(ngDevMode ? [{ debugName: "node" }] : /* istanbul ignore next */ []));
2741
+ /** Diagnostic path of the layer itself, e.g. `input.schema`. */
2742
+ basePath = input.required(...(ngDevMode ? [{ debugName: "basePath" }] : /* istanbul ignore next */ []));
2743
+ disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
2744
+ autoId = input(...(ngDevMode ? [undefined, { debugName: "autoId" }] : /* istanbul ignore next */ []));
2745
+ nodeChange = output();
2746
+ editRequest = output();
2747
+ /**
2748
+ * Which node actually holds the fields.
2749
+ *
2750
+ * why: một tầng `array` (`output.schema` sau khi nhận `${res.body.items}`) khai field ở `items`, chứ
2751
+ * bản thân nó không có `properties`. Đọc thẳng `node.properties` sẽ ra danh sách rỗng và tầng output
2752
+ * của contract mẫu thành không sửa được.
2753
+ */
2754
+ #container = computed(() => {
2755
+ const node = this.node();
2756
+ return node.type === 'array' && node.items ? node.items : node;
2757
+ }, ...(ngDevMode ? [{ debugName: "#container" }] : /* istanbul ignore next */ []));
2758
+ /** `['items']` for an array layer, `[]` otherwise — the prefix a commit has to be written under. */
2759
+ #containerPointer = computed(() => {
2760
+ const node = this.node();
2761
+ return node.type === 'array' && node.items ? ['items'] : [];
2762
+ }, ...(ngDevMode ? [{ debugName: "#containerPointer" }] : /* istanbul ignore next */ []));
2763
+ entries = computed(() => {
2764
+ const properties = this.#container().properties;
2765
+ if (!properties)
2766
+ return [];
2767
+ return Object.keys(properties).map(key => ({ key, node: properties[key] }));
2768
+ }, ...(ngDevMode ? [{ debugName: "entries" }] : /* istanbul ignore next */ []));
2769
+ #keys = computed(() => this.entries().map(entry => entry.key), ...(ngDevMode ? [{ debugName: "#keys" }] : /* istanbul ignore next */ []));
2770
+ requestAdd() {
2771
+ this.editRequest.emit({ name: null, node: null, siblingNames: this.#keys(), pointer: this.#containerPointer() });
2772
+ }
2773
+ requestEdit(key, node) {
2774
+ this.editRequest.emit({ name: key, node, siblingNames: this.#keys(), pointer: this.#containerPointer() });
2775
+ }
2776
+ removeProperty(key) {
2777
+ const stripped = removeSdApiContractProperty(this.#container(), key);
2778
+ this.nodeChange.emit(setSdApiContractNodeAt(this.node(), this.#containerPointer(), stripped));
2779
+ }
2780
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: SdApiContractNodeEditor, deps: [], target: i0.ɵɵFactoryTarget.Component });
2781
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: SdApiContractNodeEditor, isStandalone: true, selector: "sd-api-contract-node-editor", inputs: { node: { classPropertyName: "node", publicName: "node", isSignal: true, isRequired: true, transformFunction: null }, basePath: { classPropertyName: "basePath", publicName: "basePath", isSignal: true, isRequired: true, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, autoId: { classPropertyName: "autoId", publicName: "autoId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { nodeChange: "nodeChange", editRequest: "editRequest" }, ngImport: i0, template: "@let _entries = entries();\n@let _disabled = disabled();\n@let _autoId = autoId();\n\n<div class=\"sd-acb-list\">\n @if (!_entries.length) {\n <p class=\"sd-acb-list__empty\">{{ 'core.component.api-contract-builder.node.empty' | sdTranslate }}</p>\n }\n\n @for (entry of _entries; track entry.key) {\n <sd-api-contract-node-summary\n [name]=\"entry.key\"\n [node]=\"entry.node\"\n [readonly]=\"_disabled\"\n [autoId]=\"_autoId ? _autoId + '-' + entry.key : undefined\"\n (edit)=\"requestEdit(entry.key, entry.node)\"\n (remove)=\"removeProperty(entry.key)\"></sd-api-contract-node-summary>\n }\n\n @if (!_disabled) {\n <sd-button\n class=\"sd-acb-list__add\"\n type=\"light\"\n size=\"sm\"\n prefixIcon=\"add\"\n [autoId]=\"_autoId ? _autoId + '-add' : undefined\"\n [title]=\"'core.component.api-contract-builder.node.add-property' | sdTranslate\"\n (click)=\"requestAdd()\"></sd-button>\n }\n</div>\n", styles: [":host{display:block;width:100%}.sd-acb-list{display:flex;flex-direction:column;gap:6px}.sd-acb-list__empty{margin:0;font-size:12px;font-style:italic;color:var(--sd-text-secondary, #6b6b6b)}.sd-acb-list__add{align-self:flex-start}\n"], dependencies: [{ kind: "component", type: SdButton, selector: "sd-button", inputs: ["autoId", "type", "color", "size", "fontSet", "title", "width", "tooltip", "prefixIcon", "suffixIcon", "disabled", "loading", "block", "htmlType"], outputs: ["click"] }, { kind: "component", type: SdApiContractNodeSummary, selector: "sd-api-contract-node-summary", inputs: ["name", "node", "readonly", "autoId"], outputs: ["edit", "remove"] }, { kind: "pipe", type: SdTranslatePipe, name: "sdTranslate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2782
+ }
2783
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: SdApiContractNodeEditor, decorators: [{
2784
+ type: Component,
2785
+ args: [{ selector: 'sd-api-contract-node-editor', standalone: true, imports: [SdButton, SdTranslatePipe, SdApiContractNodeSummary], changeDetection: ChangeDetectionStrategy.OnPush, template: "@let _entries = entries();\n@let _disabled = disabled();\n@let _autoId = autoId();\n\n<div class=\"sd-acb-list\">\n @if (!_entries.length) {\n <p class=\"sd-acb-list__empty\">{{ 'core.component.api-contract-builder.node.empty' | sdTranslate }}</p>\n }\n\n @for (entry of _entries; track entry.key) {\n <sd-api-contract-node-summary\n [name]=\"entry.key\"\n [node]=\"entry.node\"\n [readonly]=\"_disabled\"\n [autoId]=\"_autoId ? _autoId + '-' + entry.key : undefined\"\n (edit)=\"requestEdit(entry.key, entry.node)\"\n (remove)=\"removeProperty(entry.key)\"></sd-api-contract-node-summary>\n }\n\n @if (!_disabled) {\n <sd-button\n class=\"sd-acb-list__add\"\n type=\"light\"\n size=\"sm\"\n prefixIcon=\"add\"\n [autoId]=\"_autoId ? _autoId + '-add' : undefined\"\n [title]=\"'core.component.api-contract-builder.node.add-property' | sdTranslate\"\n (click)=\"requestAdd()\"></sd-button>\n }\n</div>\n", styles: [":host{display:block;width:100%}.sd-acb-list{display:flex;flex-direction:column;gap:6px}.sd-acb-list__empty{margin:0;font-size:12px;font-style:italic;color:var(--sd-text-secondary, #6b6b6b)}.sd-acb-list__add{align-self:flex-start}\n"] }]
2786
+ }], propDecorators: { node: [{ type: i0.Input, args: [{ isSignal: true, alias: "node", required: true }] }], basePath: [{ type: i0.Input, args: [{ isSignal: true, alias: "basePath", required: true }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], autoId: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoId", required: false }] }], nodeChange: [{ type: i0.Output, args: ["nodeChange"] }], editRequest: [{ type: i0.Output, args: ["editRequest"] }] } });
2787
+
2788
+ /**
2789
+ * One keyed collection of nodes — `req.path` / `req.query` / `req.headers` / `res.headers` — rendered
2790
+ * as a list you read, not a list you type into.
2791
+ *
2792
+ * Every entry is a collapsed row. Adding and editing both ask the builder to open its drawer; only
2793
+ * removal is applied straight away, because there is nothing to stage about deleting a row. That is
2794
+ * why `add()` emits an edit request rather than an entry: a half-declared field must never reach the
2795
+ * contract just because someone clicked the add button and changed their mind.
2796
+ */
2797
+ class SdApiContractRecordEditor {
2798
+ record = input(...(ngDevMode ? [undefined, { debugName: "record" }] : /* istanbul ignore next */ []));
2799
+ /** Diagnostic path of the collection itself, e.g. `req.query`. */
2800
+ basePath = input.required(...(ngDevMode ? [{ debugName: "basePath" }] : /* istanbul ignore next */ []));
2801
+ disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
2802
+ emptyLabel = input('', ...(ngDevMode ? [{ debugName: "emptyLabel" }] : /* istanbul ignore next */ []));
2803
+ autoId = input(...(ngDevMode ? [undefined, { debugName: "autoId" }] : /* istanbul ignore next */ []));
2804
+ recordChange = output();
2805
+ editRequest = output();
2806
+ entries = computed(() => {
2807
+ const record = this.record();
2808
+ if (!record)
2809
+ return [];
2810
+ const base = this.basePath();
2811
+ return Object.keys(record).map(key => ({ key, node: record[key], path: `${base}.${key}` }));
2812
+ }, ...(ngDevMode ? [{ debugName: "entries" }] : /* istanbul ignore next */ []));
2813
+ #keys = computed(() => this.entries().map(entry => entry.key), ...(ngDevMode ? [{ debugName: "#keys" }] : /* istanbul ignore next */ []));
2814
+ requestAdd() {
2815
+ this.editRequest.emit({ name: null, node: null, siblingNames: this.#keys() });
2816
+ }
2817
+ requestEdit(entry) {
2818
+ this.editRequest.emit({ name: entry.key, node: entry.node, siblingNames: this.#keys() });
2819
+ }
2820
+ remove(key) {
2821
+ const record = this.record();
2822
+ if (!record)
2823
+ return;
2824
+ this.recordChange.emit(sdApiContractRecordRemove(record, key));
2825
+ }
2826
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: SdApiContractRecordEditor, deps: [], target: i0.ɵɵFactoryTarget.Component });
2827
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: SdApiContractRecordEditor, isStandalone: true, selector: "sd-api-contract-record-editor", inputs: { record: { classPropertyName: "record", publicName: "record", isSignal: true, isRequired: false, transformFunction: null }, basePath: { classPropertyName: "basePath", publicName: "basePath", isSignal: true, isRequired: true, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, emptyLabel: { classPropertyName: "emptyLabel", publicName: "emptyLabel", isSignal: true, isRequired: false, transformFunction: null }, autoId: { classPropertyName: "autoId", publicName: "autoId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { recordChange: "recordChange", editRequest: "editRequest" }, ngImport: i0, template: `
2828
+ @let _entries = entries();
2829
+ @let _disabled = disabled();
2830
+ @let _autoId = autoId();
2831
+
2832
+ <div class="sd-acb-record">
2833
+ @if (!_entries.length) {
2834
+ <p class="sd-acb-record__empty">{{ emptyLabel() }}</p>
2835
+ }
2836
+
2837
+ @for (entry of _entries; track entry.key) {
2838
+ <sd-api-contract-node-summary
2839
+ [name]="entry.key"
2840
+ [node]="entry.node"
2841
+ [readonly]="_disabled"
2842
+ [autoId]="_autoId ? _autoId + '-' + entry.key : undefined"
2843
+ (edit)="requestEdit(entry)"
2844
+ (remove)="remove(entry.key)"></sd-api-contract-node-summary>
2845
+ }
2846
+
2847
+ @if (!_disabled) {
2848
+ <sd-button
2849
+ class="sd-acb-record__add"
2850
+ type="light"
2851
+ size="sm"
2852
+ prefixIcon="add"
2853
+ [autoId]="_autoId ? _autoId + '-add' : undefined"
2854
+ [title]="'core.component.api-contract-builder.request.add-entry' | sdTranslate"
2855
+ (click)="requestAdd()"></sd-button>
2856
+ }
2857
+ </div>
2858
+ `, isInline: true, styles: [":host{display:block;width:100%}.sd-acb-record{display:flex;flex-direction:column;gap:6px}.sd-acb-record__empty{margin:0;font-size:12px;font-style:italic;color:var(--sd-text-secondary, #6b6b6b)}.sd-acb-record__add{align-self:flex-start}\n"], dependencies: [{ kind: "component", type: SdButton, selector: "sd-button", inputs: ["autoId", "type", "color", "size", "fontSet", "title", "width", "tooltip", "prefixIcon", "suffixIcon", "disabled", "loading", "block", "htmlType"], outputs: ["click"] }, { kind: "component", type: SdApiContractNodeSummary, selector: "sd-api-contract-node-summary", inputs: ["name", "node", "readonly", "autoId"], outputs: ["edit", "remove"] }, { kind: "pipe", type: SdTranslatePipe, name: "sdTranslate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2859
+ }
2860
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: SdApiContractRecordEditor, decorators: [{
2861
+ type: Component,
2862
+ args: [{ selector: 'sd-api-contract-record-editor', standalone: true, imports: [SdButton, SdTranslatePipe, SdApiContractNodeSummary], changeDetection: ChangeDetectionStrategy.OnPush, template: `
2863
+ @let _entries = entries();
2864
+ @let _disabled = disabled();
2865
+ @let _autoId = autoId();
2866
+
2867
+ <div class="sd-acb-record">
2868
+ @if (!_entries.length) {
2869
+ <p class="sd-acb-record__empty">{{ emptyLabel() }}</p>
2870
+ }
2871
+
2872
+ @for (entry of _entries; track entry.key) {
2873
+ <sd-api-contract-node-summary
2874
+ [name]="entry.key"
2875
+ [node]="entry.node"
2876
+ [readonly]="_disabled"
2877
+ [autoId]="_autoId ? _autoId + '-' + entry.key : undefined"
2878
+ (edit)="requestEdit(entry)"
2879
+ (remove)="remove(entry.key)"></sd-api-contract-node-summary>
2880
+ }
2881
+
2882
+ @if (!_disabled) {
2883
+ <sd-button
2884
+ class="sd-acb-record__add"
2885
+ type="light"
2886
+ size="sm"
2887
+ prefixIcon="add"
2888
+ [autoId]="_autoId ? _autoId + '-add' : undefined"
2889
+ [title]="'core.component.api-contract-builder.request.add-entry' | sdTranslate"
2890
+ (click)="requestAdd()"></sd-button>
2891
+ }
2892
+ </div>
2893
+ `, styles: [":host{display:block;width:100%}.sd-acb-record{display:flex;flex-direction:column;gap:6px}.sd-acb-record__empty{margin:0;font-size:12px;font-style:italic;color:var(--sd-text-secondary, #6b6b6b)}.sd-acb-record__add{align-self:flex-start}\n"] }]
2894
+ }], propDecorators: { record: [{ type: i0.Input, args: [{ isSignal: true, alias: "record", required: false }] }], basePath: [{ type: i0.Input, args: [{ isSignal: true, alias: "basePath", required: true }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], emptyLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyLabel", required: false }] }], autoId: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoId", required: false }] }], recordChange: [{ type: i0.Output, args: ["recordChange"] }], editRequest: [{ type: i0.Output, args: ["editRequest"] }] } });
2895
+
2896
+ const STEP_GENERAL = 0;
2897
+ const STEP_INPUT = 1;
2898
+ const STEP_REQUEST = 2;
2899
+ const STEP_RESPONSE = 3;
2900
+ const STEP_OUTPUT = 4;
2901
+ const STEP_REVIEW = 5;
2902
+ /**
2903
+ * Visual builder for an `SdApiContract`.
2904
+ *
2905
+ * The component is a **design-time** tool: it never performs a request, never resolves an
2906
+ * expression and never holds a secret. It edits, validates and serializes the contract; executing
2907
+ * it is a separate concern for a future `form-builder` / `form-render` integration.
2908
+ *
2909
+ * @example
2910
+ * ```html
2911
+ * <sd-api-contract-builder [(model)]="contract" autoId="product-search"></sd-api-contract-builder>
2912
+ * ```
2913
+ */
2914
+ class SdApiContractBuilder {
2915
+ #i18n = inject(I18nService);
2916
+ #configuration = resolveSdApiContractConfiguration(inject(SD_API_CONTRACT_CONFIGURATION, { optional: true }));
2917
+ model = model(null, ...(ngDevMode ? [{ debugName: "model" }] : /* istanbul ignore next */ []));
2918
+ mode = input('edit', ...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
2919
+ disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
2920
+ autoId = input(...(ngDevMode ? [undefined, { debugName: "autoId" }] : /* istanbul ignore next */ []));
2921
+ /** Fires whenever the diagnostics change, including once for the initially seeded contract. */
2922
+ diagnosticsChange = output();
2923
+ /** Fires only when validity flips, so a consumer can gate a Save button without debouncing. */
2924
+ validChange = output();
2925
+ // why: bản nháp nội bộ là BẢN SAO SÂU của model cha. Component không bao giờ ghi vào object cha —
2926
+ // mọi thao tác dựng contract mới rồi mới `model.set`.
2927
+ #draft = signal(null, ...(ngDevMode ? [{ debugName: "#draft" }] : /* istanbul ignore next */ []));
2928
+ // why: mốc so sánh để phân biệt "cha đưa giá trị mới vào" với "chính ta vừa bắn ra" — thiếu nó,
2929
+ // effect seed sẽ clone lại contract ta vừa emit và tạo vòng lặp phản hồi.
2930
+ #lastEmitted = null;
2931
+ #lastValid = null;
2932
+ activeStep = signal(STEP_GENERAL, ...(ngDevMode ? [{ debugName: "activeStep" }] : /* istanbul ignore next */ []));
2933
+ contractVersion = SD_API_CONTRACT_VERSION;
2934
+ responseFieldToken = signal(null, ...(ngDevMode ? [{ debugName: "responseFieldToken" }] : /* istanbul ignore next */ []));
2935
+ urlPlaceholder = '${env.baseUrl}/products/{id}';
2936
+ draft = this.#draft.asReadonly();
2937
+ readonly = computed(() => this.disabled() || this.mode() === 'view', ...(ngDevMode ? [{ debugName: "readonly" }] : /* istanbul ignore next */ []));
2938
+ isView = computed(() => this.mode() === 'view', ...(ngDevMode ? [{ debugName: "isView" }] : /* istanbul ignore next */ []));
2939
+ steps = [
2940
+ { index: STEP_GENERAL, key: 'general', label: this.#i18n.t('core.component.api-contract-builder.step.general') },
2941
+ { index: STEP_INPUT, key: 'input', label: this.#i18n.t('core.component.api-contract-builder.step.input') },
2942
+ { index: STEP_REQUEST, key: 'request', label: this.#i18n.t('core.component.api-contract-builder.step.request') },
2943
+ { index: STEP_RESPONSE, key: 'response', label: this.#i18n.t('core.component.api-contract-builder.step.response') },
2944
+ { index: STEP_OUTPUT, key: 'output', label: this.#i18n.t('core.component.api-contract-builder.step.output') },
2945
+ { index: STEP_REVIEW, key: 'review', label: this.#i18n.t('core.component.api-contract-builder.step.review') },
2946
+ ];
2947
+ methodOptions = SD_API_CONTRACT_HTTP_METHODS.map(method => ({ value: method, label: method }));
2948
+ allTypes = SD_API_CONTRACT_DATA_TYPES;
2949
+ scalarTypes = SD_API_CONTRACT_SCALAR_DATA_TYPES;
2950
+ queryTypes = [...SD_API_CONTRACT_SCALAR_DATA_TYPES, 'array'];
2951
+ #envSuggestions = Object.keys(this.#configuration.env).map(key => {
2952
+ const variable = this.#configuration.env[key];
2953
+ const sensitiveMark = variable.sensitive ? ` · ${this.#i18n.t('core.component.api-contract-builder.mapping.sensitive')}` : '';
2954
+ return {
2955
+ expression: formatSdApiContractExpression('env', [key]),
2956
+ path: `env.${key}`,
2957
+ root: 'env',
2958
+ type: variable.type,
2959
+ display: `env.${key} — ${variable.type}${sensitiveMark}`,
2960
+ };
2961
+ });
2962
+ // why: builder sở hữu ĐÚNG MỘT drawer, nên phải tự nhớ danh sách nào vừa yêu cầu mở nó. Không có
2963
+ // mốc này thì `applyNodeCommit` không biết ghi kết quả vào `req.query` hay `res.headers`.
2964
+ #drawerTarget = signal(null, ...(ngDevMode ? [{ debugName: "#drawerTarget" }] : /* istanbul ignore next */ []));
2965
+ nodeDrawer = viewChild('nodeDrawer', ...(ngDevMode ? [{ debugName: "nodeDrawer" }] : /* istanbul ignore next */ []));
2966
+ drawerLayer = computed(() => this.#drawerTarget()?.section === 'input.schema' ? 'schema' : 'mapping', ...(ngDevMode ? [{ debugName: "drawerLayer" }] : /* istanbul ignore next */ []));
2967
+ // why chỉ input/output schema: `transform` là thuộc tính của schema frontend. `req`/`res` mô tả HTTP,
2968
+ // ở đó một giá trị thời gian đã là chuỗi trên đường truyền rồi.
2969
+ drawerAllowsTransform = computed(() => {
2970
+ const section = this.#drawerTarget()?.section;
2971
+ return section === 'input.schema' || section === 'output.schema';
2972
+ }, ...(ngDevMode ? [{ debugName: "drawerAllowsTransform" }] : /* istanbul ignore next */ []));
2973
+ drawerSuggestions = computed(() => {
2974
+ const section = this.#drawerTarget()?.section;
2975
+ if (section === undefined)
2976
+ return [];
2977
+ if (section === 'output.schema')
2978
+ return this.outputSuggestions();
2979
+ if (section === 'res.headers' || section === 'res.body')
2980
+ return this.outputSuggestions();
2981
+ return this.requestSuggestions();
2982
+ }, ...(ngDevMode ? [{ debugName: "drawerSuggestions" }] : /* istanbul ignore next */ []));
2983
+ // why: JSON dán vào có thể chưa parse được (đang gõ dở, thiếu ngoặc). Lỗi đó KHÔNG đến từ
2984
+ // `validateSdApiContract` — nó chưa bao giờ thấy contract — nên phải mang riêng rồi trộn vào
2985
+ // danh sách chẩn đoán, để người dán thấy lý do thay vì thấy contract im lặng không đổi.
2986
+ #pasteError = signal(null, ...(ngDevMode ? [{ debugName: "#pasteError" }] : /* istanbul ignore next */ []));
2987
+ diagnostics = computed(() => {
2988
+ const draft = this.#draft();
2989
+ const base = draft ? validateSdApiContract(draft, this.#configuration) : [];
2990
+ const pasteError = this.#pasteError();
2991
+ return pasteError ? [pasteError, ...base] : base;
2992
+ }, ...(ngDevMode ? [{ debugName: "diagnostics" }] : /* istanbul ignore next */ []));
2993
+ json = computed(() => serializeSdApiContract(this.#draft()), ...(ngDevMode ? [{ debugName: "json" }] : /* istanbul ignore next */ []));
2994
+ // why: contract từ ngoài có thể thiếu hẳn một tầng. Mọi computed đọc draft phải chịu được điều đó —
2995
+ // builder có nhiệm vụ HIỂN THỊ contract sai kèm chẩn đoán, không được nổ và cũng không được tự vá.
2996
+ #inputSuggestions = computed(() => {
2997
+ const schema = this.#draft()?.input?.schema;
2998
+ if (!schema)
2999
+ return [];
3000
+ return listSdApiContractSchemaFields(schema, { arrays: 'stop' }).map(field => ({
3001
+ expression: formatSdApiContractExpression('input', field.segments),
3002
+ path: `input.${field.path}`,
3003
+ root: 'input',
3004
+ type: field.type,
3005
+ display: `input.${field.path} — ${field.type}${field.label ? ` (${field.label})` : ''}`,
3006
+ }));
3007
+ }, ...(ngDevMode ? [{ debugName: "#inputSuggestions" }] : /* istanbul ignore next */ []));
3008
+ #responseSuggestions = computed(() => {
3009
+ const response = this.#draft()?.res;
3010
+ if (!response)
3011
+ return [];
3012
+ return listSdApiContractResponseFields(response).map(field => ({
3013
+ expression: formatSdApiContractExpression('res', field.segments),
3014
+ path: `res.${field.path}`,
3015
+ root: 'res',
3016
+ type: field.type,
3017
+ display: `res.${field.path} — ${field.type}`,
3018
+ }));
3019
+ }, ...(ngDevMode ? [{ debugName: "#responseSuggestions" }] : /* istanbul ignore next */ []));
3020
+ requestSuggestions = computed(() => [...this.#inputSuggestions(), ...this.#envSuggestions], ...(ngDevMode ? [{ debugName: "requestSuggestions" }] : /* istanbul ignore next */ []));
3021
+ outputSuggestions = computed(() => [
3022
+ ...this.#responseSuggestions(),
3023
+ ...this.#inputSuggestions(),
3024
+ ...this.#envSuggestions,
3025
+ ], ...(ngDevMode ? [{ debugName: "outputSuggestions" }] : /* istanbul ignore next */ []));
3026
+ /** Response paths offered by the "use a response field as the output" action. */
3027
+ responseFieldOptions = computed(() => {
3028
+ const response = this.#draft()?.res;
3029
+ if (!response)
3030
+ return [];
3031
+ return listSdApiContractResponseFields(response).map(field => ({ value: field.path, label: `${field.path} — ${field.type}` }));
3032
+ }, ...(ngDevMode ? [{ debugName: "responseFieldOptions" }] : /* istanbul ignore next */ []));
3033
+ /** Leaf fields a dropdown / table consumer will see once this contract runs. */
3034
+ outputFields = computed(() => {
3035
+ const schema = this.#draft()?.output?.schema;
3036
+ if (!schema)
3037
+ return [];
3038
+ return listSdApiContractSchemaFields(schema).filter(field => field.leaf);
3039
+ }, ...(ngDevMode ? [{ debugName: "outputFields" }] : /* istanbul ignore next */ []));
3040
+ statusText = computed(() => {
3041
+ const status = this.#draft()?.res?.status;
3042
+ if (status === undefined)
3043
+ return '';
3044
+ return Array.isArray(status) ? status.join(', ') : String(status);
3045
+ }, ...(ngDevMode ? [{ debugName: "statusText" }] : /* istanbul ignore next */ []));
3046
+ get autoIdAttr() {
3047
+ return this.autoId() ?? null;
3048
+ }
3049
+ constructor() {
3050
+ effect(() => {
3051
+ const external = this.model();
3052
+ untracked(() => {
3053
+ if (external === this.#lastEmitted)
3054
+ return;
3055
+ // why: clone sâu — cha giữ nguyên object của mình, mọi chỉnh sửa chỉ chạm bản nháp.
3056
+ // Contract sai cấu trúc vẫn được nạp nguyên trạng để hiển thị + chẩn đoán, KHÔNG tự sửa.
3057
+ this.#draft.set(external ? cloneSdApiContract(external) : null);
3058
+ });
3059
+ });
3060
+ effect(() => {
3061
+ const diagnostics = this.diagnostics();
3062
+ untracked(() => {
3063
+ this.diagnosticsChange.emit(diagnostics);
3064
+ const valid = !diagnostics.some(diagnostic => diagnostic.severity === 'error');
3065
+ if (valid !== this.#lastValid) {
3066
+ this.#lastValid = valid;
3067
+ this.validChange.emit(valid);
3068
+ }
3069
+ });
3070
+ });
3071
+ }
3072
+ // -------------------------------------------------------------------------
3073
+ // Navigation
3074
+ // -------------------------------------------------------------------------
3075
+ goToStep(index) {
3076
+ this.activeStep.set(index);
3077
+ }
3078
+ goToDiagnostic(diagnostic) {
3079
+ this.activeStep.set(stepForPath(diagnostic.path));
3080
+ }
3081
+ // -------------------------------------------------------------------------
3082
+ // Contract-level edits
3083
+ // -------------------------------------------------------------------------
3084
+ createContract() {
3085
+ this.#commit({
3086
+ contractVersion: SD_API_CONTRACT_VERSION,
3087
+ code: '',
3088
+ name: '',
3089
+ input: { schema: { type: 'object', properties: {} } },
3090
+ req: { method: 'GET', url: '' },
3091
+ res: { status: 200 },
3092
+ output: { schema: { type: 'object', properties: {} } },
3093
+ });
3094
+ }
3095
+ setText(key, value) {
3096
+ const draft = this.#draft();
3097
+ if (!draft)
3098
+ return;
3099
+ const text = typeof value === 'string' ? value : '';
3100
+ if (key === 'description' && !text) {
3101
+ const { description: _dropped, ...rest } = draft;
3102
+ this.#commit(rest);
3103
+ return;
3104
+ }
3105
+ this.#commit({ ...draft, [key]: text });
3106
+ }
3107
+ setInputSchema(node) {
3108
+ const draft = this.#draft();
3109
+ if (!draft)
3110
+ return;
3111
+ this.#commit({ ...draft, input: { schema: node } });
3112
+ }
3113
+ setMethod(value) {
3114
+ const draft = this.#draft();
3115
+ if (!draft || typeof value !== 'string')
3116
+ return;
3117
+ this.#commit({ ...draft, req: { ...draft.req, method: value } });
3118
+ }
3119
+ setUrl(value) {
3120
+ const draft = this.#draft();
3121
+ if (!draft)
3122
+ return;
3123
+ this.#commit({ ...draft, req: { ...draft.req, url: typeof value === 'string' ? value : '' } });
3124
+ }
3125
+ // -------------------------------------------------------------------------
3126
+ // Drawer
3127
+ // -------------------------------------------------------------------------
3128
+ /**
3129
+ * Opens the one drawer for whichever list asked.
3130
+ *
3131
+ * why gác `readonly` ở đây nữa: hàng thu gọn đã tự chặn khi read-only, nhưng builder là nơi duy nhất
3132
+ * biết `mode`/`disabled` thật. Hai lớp gác rẻ hơn một đường mở drawer lọt trong chế độ xem.
3133
+ */
3134
+ openNodeDrawer(section, request) {
3135
+ if (this.readonly())
3136
+ return;
3137
+ this.#drawerTarget.set({ section, request });
3138
+ const drawer = this.nodeDrawer();
3139
+ if (!drawer)
3140
+ return;
3141
+ if (request.name === null || request.node === null)
3142
+ drawer.openForAdd(request.siblingNames);
3143
+ else
3144
+ drawer.openForEdit(request.name, request.node, request.siblingNames);
3145
+ }
3146
+ /**
3147
+ * Writes a committed node back where it came from — one `modelChange`, at Save time.
3148
+ *
3149
+ * A rename goes through `sdApiContractRecordRename` first so the entry keeps its position in the
3150
+ * JSON instead of jumping to the end. The drawer has already refused a duplicate name, so the
3151
+ * rename cannot collide by the time it reaches here.
3152
+ */
3153
+ applyNodeCommit(commit) {
3154
+ const target = this.#drawerTarget();
3155
+ const draft = this.#draft();
3156
+ this.#drawerTarget.set(null);
3157
+ if (!target || !draft)
3158
+ return;
3159
+ const previous = target.request.name;
3160
+ const pointer = target.request.pointer ?? [];
3161
+ switch (target.section) {
3162
+ case 'req.path':
3163
+ case 'req.query':
3164
+ case 'req.headers': {
3165
+ const key = target.section.slice('req.'.length);
3166
+ const record = (draft.req?.[key] ?? {});
3167
+ this.setRequestRecord(key, commitIntoRecord(record, previous, commit));
3168
+ return;
3169
+ }
3170
+ case 'res.headers': {
3171
+ const record = (draft.res?.headers ?? {});
3172
+ this.setResponseHeaders(commitIntoRecord(record, previous, commit));
3173
+ return;
3174
+ }
3175
+ case 'input.schema': {
3176
+ const root = draft.input?.schema;
3177
+ if (!root)
3178
+ return;
3179
+ this.setInputSchema(commitIntoProperties(root, pointer, previous, commit));
3180
+ return;
3181
+ }
3182
+ case 'req.body': {
3183
+ const root = draft.req?.body;
3184
+ if (!root)
3185
+ return;
3186
+ this.setRequestBody(commitIntoProperties(root, pointer, previous, commit));
3187
+ return;
3188
+ }
3189
+ case 'res.body': {
3190
+ const root = draft.res?.body;
3191
+ if (!root)
3192
+ return;
3193
+ this.setResponseBody(commitIntoProperties(root, pointer, previous, commit));
3194
+ return;
3195
+ }
3196
+ case 'output.schema': {
3197
+ const root = draft.output?.schema;
3198
+ if (!root)
3199
+ return;
3200
+ this.setOutputSchema(commitIntoProperties(root, pointer, previous, commit));
3201
+ return;
3202
+ }
3203
+ default:
3204
+ return;
3205
+ }
3206
+ }
3207
+ setRequestRecord(section, record) {
3208
+ const draft = this.#draft();
3209
+ if (!draft)
3210
+ return;
3211
+ this.#commit({ ...draft, req: { ...draft.req, [section]: record } });
3212
+ }
3213
+ setRequestBody(node) {
3214
+ const draft = this.#draft();
3215
+ if (!draft)
3216
+ return;
3217
+ this.#commit({ ...draft, req: { ...draft.req, body: node } });
3218
+ }
3219
+ addRequestBody() {
3220
+ this.setRequestBody(createSdApiContractNode('object'));
3221
+ }
3222
+ removeRequestBody() {
3223
+ const draft = this.#draft();
3224
+ if (!draft)
3225
+ return;
3226
+ const { body: _dropped, ...req } = draft.req;
3227
+ this.#commit({ ...draft, req });
3228
+ }
3229
+ setStatus(value) {
3230
+ const draft = this.#draft();
3231
+ if (!draft)
3232
+ return;
3233
+ const parts = String(value ?? '')
3234
+ .split(',')
3235
+ .map(part => part.trim())
3236
+ .filter(part => part.length > 0)
3237
+ .map(part => Number(part));
3238
+ // why: KHÔNG bỏ giá trị không phải số — validator phải báo `res.status.invalid` cho người dùng
3239
+ // thấy, im lặng nuốt đi là "âm thầm sửa contract sai".
3240
+ const status = parts.length === 1 ? parts[0] : parts;
3241
+ this.#commit({ ...draft, res: { ...draft.res, status } });
3242
+ }
3243
+ setResponseHeaders(record) {
3244
+ const draft = this.#draft();
3245
+ if (!draft)
3246
+ return;
3247
+ this.#commit({ ...draft, res: { ...draft.res, headers: record } });
3248
+ }
3249
+ setResponseBody(node) {
3250
+ const draft = this.#draft();
3251
+ if (!draft)
3252
+ return;
3253
+ this.#commit({ ...draft, res: { ...draft.res, body: node } });
3254
+ }
3255
+ addResponseBody() {
3256
+ this.setResponseBody(createSdApiContractNode('object'));
3257
+ }
3258
+ removeResponseBody() {
3259
+ const draft = this.#draft();
3260
+ if (!draft)
3261
+ return;
3262
+ const { body: _dropped, ...res } = draft.res;
3263
+ this.#commit({ ...draft, res });
3264
+ }
3265
+ setOutputSchema(node) {
3266
+ const draft = this.#draft();
3267
+ if (!draft)
3268
+ return;
3269
+ this.#commit({ ...draft, output: { schema: node } });
3270
+ }
3271
+ /**
3272
+ * Adopts a response subtree as the output schema.
3273
+ *
3274
+ * The subtree is **deep-copied**, never referenced: editing the output afterwards must not reach
3275
+ * back into the response declaration. An array or scalar target takes a whole-node `source`; an
3276
+ * object target keeps its shape and each branch gets its own `source`, because an object with both
3277
+ * a whole-node source and child mappings is invalid by design.
3278
+ */
3279
+ useResponseFieldAsOutput(value) {
3280
+ const draft = this.#draft();
3281
+ if (!draft || typeof value !== 'string' || !value)
3282
+ return;
3283
+ const segments = value.split('.');
3284
+ const resolved = resolveSdApiContractResponsePath(draft.res, segments);
3285
+ this.responseFieldToken.set(null);
3286
+ if (!resolved)
3287
+ return;
3288
+ const schema = resolved.node
3289
+ ? adoptResponseNode(resolved.node, segments)
3290
+ : { type: resolved.type, source: formatSdApiContractExpression('res', segments) };
3291
+ this.setOutputSchema(schema);
3292
+ }
3293
+ /**
3294
+ * Adopts a contract pasted into the review editor.
3295
+ *
3296
+ * `<sd-code-editor language="json">` emits the PARSED value when the text is valid JSON and the
3297
+ * raw STRING while it is still half-typed. A string therefore means "not parseable yet": keep the
3298
+ * current draft and report it, because replacing a contract with a fragment of text would destroy
3299
+ * the author's work on a keystroke.
3300
+ *
3301
+ * A parseable object is adopted VERBATIM — no field is added, removed or repaired. Whatever is
3302
+ * wrong with it surfaces through `validateSdApiContract`, which is the whole point of pasting.
3303
+ */
3304
+ applyPastedJson(value) {
3305
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
3306
+ this.#pasteError.set({
3307
+ code: 'contract.invalid',
3308
+ severity: 'error',
3309
+ path: '',
3310
+ message: 'The pasted text is not valid JSON. The contract was left unchanged.',
3311
+ });
3312
+ return;
3313
+ }
3314
+ this.#pasteError.set(null);
3315
+ this.#commit(cloneSdApiContract(value));
3316
+ }
3317
+ #commit(next) {
3318
+ this.#lastEmitted = next;
3319
+ this.#draft.set(next);
3320
+ this.model.set(next);
3321
+ }
3322
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: SdApiContractBuilder, deps: [], target: i0.ɵɵFactoryTarget.Component });
3323
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: SdApiContractBuilder, isStandalone: true, selector: "sd-api-contract-builder", inputs: { model: { classPropertyName: "model", publicName: "model", isSignal: true, isRequired: false, transformFunction: null }, mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, autoId: { classPropertyName: "autoId", publicName: "autoId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { model: "modelChange", diagnosticsChange: "diagnosticsChange", validChange: "validChange" }, host: { properties: { "attr.data-autoId": "this.autoIdAttr" } }, viewQueries: [{ propertyName: "nodeDrawer", first: true, predicate: ["nodeDrawer"], descendants: true, isSignal: true }], ngImport: i0, template: "@let _draft = draft();\n@let _readonly = readonly();\n@let _autoId = autoId();\n@let _diagnostics = diagnostics();\n@let _activeStep = activeStep();\n@let _requestSuggestions = requestSuggestions();\n@let _responseFieldOptions = responseFieldOptions();\n\n<!-- why: contract t\u1EEB ngo\u00E0i c\u00F3 th\u1EC3 thi\u1EBFu h\u1EB3n m\u1ED9t t\u1EA7ng. M\u1ECDi nh\u00E1nh \u0111\u1ECDc `_draft.<section>` \u0111\u1EC1u ph\u1EA3i\n g\u00E1c \u2014 builder hi\u1EC3n th\u1ECB contract sai k\u00E8m ch\u1EA9n \u0111o\u00E1n ch\u1EE9 kh\u00F4ng t\u1EF1 v\u00E1, v\u00E0 c\u0169ng kh\u00F4ng \u0111\u01B0\u1EE3c n\u1ED5. -->\n@if (!_draft) {\n <div class=\"sd-acb__empty\">\n <sd-icon name=\"description\" size=\"lg\"></sd-icon>\n <p>{{ 'core.component.api-contract-builder.empty.contract' | sdTranslate }}</p>\n @if (!_readonly) {\n <sd-button\n type=\"fill\"\n color=\"primary\"\n size=\"sm\"\n prefixIcon=\"add\"\n [autoId]=\"_autoId ? _autoId + '-create' : undefined\"\n [title]=\"'core.component.api-contract-builder.create-contract' | sdTranslate\"\n (click)=\"createContract()\"></sd-button>\n }\n </div>\n} @else if (isView()) {\n <section class=\"sd-acb__view\">\n <dl class=\"sd-acb__summary\">\n <dt>{{ 'core.component.api-contract-builder.contract.code' | sdTranslate }}</dt>\n <dd data-field=\"code\">{{ _draft.code }}</dd>\n <dt>{{ 'core.component.api-contract-builder.contract.name' | sdTranslate }}</dt>\n <dd data-field=\"name\">{{ _draft.name }}</dd>\n <dt>{{ 'core.component.api-contract-builder.request.method' | sdTranslate }}</dt>\n <dd data-field=\"method\">{{ _draft.req?.method }}</dd>\n <dt>{{ 'core.component.api-contract-builder.request.url' | sdTranslate }}</dt>\n <dd data-field=\"url\">{{ _draft.req?.url }}</dd>\n </dl>\n\n <sd-api-contract-diagnostic-list\n [diagnostics]=\"_diagnostics\"\n [autoId]=\"_autoId ? _autoId + '-diagnostics' : undefined\"></sd-api-contract-diagnostic-list>\n\n <sd-code-editor language=\"json\" [viewed]=\"true\" maxHeight=\"420px\" [model]=\"json()\"></sd-code-editor>\n </section>\n} @else {\n <nav class=\"sd-acb__steps\" [attr.aria-label]=\"'core.component.api-contract-builder.steps' | sdTranslate\">\n @for (step of steps; track step.index) {\n <button\n type=\"button\"\n class=\"sd-acb__step\"\n [class.sd-acb__step--active]=\"_activeStep === step.index\"\n [attr.aria-current]=\"_activeStep === step.index ? 'step' : null\"\n [attr.data-autoId]=\"_autoId ? _autoId + '-step-' + step.key : null\"\n (click)=\"goToStep(step.index)\">\n <span class=\"sd-acb__step-index\">{{ step.index + 1 }}</span>\n <span class=\"sd-acb__step-label\">{{ step.label }}</span>\n </button>\n }\n </nav>\n\n <div class=\"sd-acb__body\">\n @switch (_activeStep) {\n @case (0) {\n <section class=\"sd-acb__section\" data-step=\"general\">\n <div class=\"sd-acb__grid\">\n <sd-input\n size=\"sm\"\n hideInlineError\n [autoId]=\"_autoId ? _autoId + '-code' : undefined\"\n [label]=\"'core.component.api-contract-builder.contract.code' | sdTranslate\"\n [model]=\"_draft.code\"\n [disabled]=\"_readonly\"\n (sdChange)=\"setText('code', $event)\"></sd-input>\n <sd-input\n size=\"sm\"\n hideInlineError\n [autoId]=\"_autoId ? _autoId + '-name' : undefined\"\n [label]=\"'core.component.api-contract-builder.contract.name' | sdTranslate\"\n [model]=\"_draft.name\"\n [disabled]=\"_readonly\"\n (sdChange)=\"setText('name', $event)\"></sd-input>\n <sd-input\n size=\"sm\"\n hideInlineError\n [autoId]=\"_autoId ? _autoId + '-description' : undefined\"\n [label]=\"'core.component.api-contract-builder.contract.description' | sdTranslate\"\n [model]=\"_draft.description ?? ''\"\n [disabled]=\"_readonly\"\n (sdChange)=\"setText('description', $event)\"></sd-input>\n <sd-input\n size=\"sm\"\n hideInlineError\n [autoId]=\"_autoId ? _autoId + '-version' : undefined\"\n [label]=\"'core.component.api-contract-builder.contract.version' | sdTranslate\"\n [model]=\"contractVersion\"\n [readonly]=\"true\"\n [disabled]=\"true\"></sd-input>\n </div>\n </section>\n }\n\n @case (1) {\n <section class=\"sd-acb__section\" data-step=\"input\">\n @if (_draft.input?.schema; as _inputSchema) {\n <sd-api-contract-node-editor\n [node]=\"$any(_inputSchema)\"\n basePath=\"input.schema\"\n [disabled]=\"_readonly\"\n [autoId]=\"_autoId ? _autoId + '-input' : undefined\"\n (nodeChange)=\"setInputSchema($event)\"\n (editRequest)=\"openNodeDrawer('input.schema', $event)\"></sd-api-contract-node-editor>\n } @else {\n <p class=\"sd-acb__empty-text\">{{ 'core.component.api-contract-builder.empty.section' | sdTranslate }}</p>\n }\n </section>\n }\n\n @case (2) {\n <section class=\"sd-acb__section\" data-step=\"request\">\n @if (_draft.req; as _req) {\n <div class=\"sd-acb__grid\">\n <sd-select\n size=\"sm\"\n hideInlineError\n [clearable]=\"false\"\n [autoId]=\"_autoId ? _autoId + '-method' : undefined\"\n [label]=\"'core.component.api-contract-builder.request.method' | sdTranslate\"\n [items]=\"methodOptions\"\n valueField=\"value\"\n displayField=\"label\"\n [model]=\"_req.method\"\n [disabled]=\"_readonly\"\n (sdChange)=\"setMethod($event)\"></sd-select>\n <sd-input\n class=\"sd-acb__url\"\n size=\"sm\"\n hideInlineError\n [autoId]=\"_autoId ? _autoId + '-url' : undefined\"\n [label]=\"'core.component.api-contract-builder.request.url' | sdTranslate\"\n [placeholder]=\"urlPlaceholder\"\n [model]=\"_req.url\"\n [disabled]=\"_readonly\"\n (sdChange)=\"setUrl($event)\"></sd-input>\n </div>\n\n <h4 class=\"sd-acb__heading\">{{ 'core.component.api-contract-builder.request.path' | sdTranslate }}</h4>\n <sd-api-contract-record-editor\n [record]=\"$any(_req.path)\"\n basePath=\"req.path\"\n [disabled]=\"_readonly\"\n [emptyLabel]=\"'core.component.api-contract-builder.empty.path' | sdTranslate\"\n [autoId]=\"_autoId ? _autoId + '-path' : undefined\"\n (recordChange)=\"setRequestRecord('path', $event)\"\n (editRequest)=\"openNodeDrawer('req.path', $event)\"></sd-api-contract-record-editor>\n\n <h4 class=\"sd-acb__heading\">{{ 'core.component.api-contract-builder.request.query' | sdTranslate }}</h4>\n <sd-api-contract-record-editor\n [record]=\"$any(_req.query)\"\n basePath=\"req.query\"\n [disabled]=\"_readonly\"\n [emptyLabel]=\"'core.component.api-contract-builder.empty.query' | sdTranslate\"\n [autoId]=\"_autoId ? _autoId + '-query' : undefined\"\n (recordChange)=\"setRequestRecord('query', $event)\"\n (editRequest)=\"openNodeDrawer('req.query', $event)\"></sd-api-contract-record-editor>\n\n <h4 class=\"sd-acb__heading\">{{ 'core.component.api-contract-builder.request.headers' | sdTranslate }}</h4>\n <sd-api-contract-record-editor\n [record]=\"$any(_req.headers)\"\n basePath=\"req.headers\"\n [disabled]=\"_readonly\"\n [emptyLabel]=\"'core.component.api-contract-builder.empty.headers' | sdTranslate\"\n [autoId]=\"_autoId ? _autoId + '-headers' : undefined\"\n (recordChange)=\"setRequestRecord('headers', $event)\"\n (editRequest)=\"openNodeDrawer('req.headers', $event)\"></sd-api-contract-record-editor>\n\n <h4 class=\"sd-acb__heading\">{{ 'core.component.api-contract-builder.request.body' | sdTranslate }}</h4>\n @if (_req.body) {\n <sd-api-contract-node-editor\n [node]=\"$any(_req.body)\"\n basePath=\"req.body\"\n [disabled]=\"_readonly\"\n [autoId]=\"_autoId ? _autoId + '-body' : undefined\"\n (nodeChange)=\"setRequestBody($event)\"\n (editRequest)=\"openNodeDrawer('req.body', $event)\"></sd-api-contract-node-editor>\n @if (!_readonly) {\n <sd-button\n type=\"text\"\n color=\"error\"\n size=\"sm\"\n prefixIcon=\"delete\"\n [autoId]=\"_autoId ? _autoId + '-remove-body' : undefined\"\n [title]=\"'core.component.api-contract-builder.request.remove-body' | sdTranslate\"\n (click)=\"removeRequestBody()\"></sd-button>\n }\n } @else {\n <p class=\"sd-acb__empty-text\">{{ 'core.component.api-contract-builder.empty.body' | sdTranslate }}</p>\n @if (!_readonly) {\n <sd-button\n type=\"light\"\n size=\"sm\"\n prefixIcon=\"add\"\n [autoId]=\"_autoId ? _autoId + '-add-body' : undefined\"\n [title]=\"'core.component.api-contract-builder.request.add-body' | sdTranslate\"\n (click)=\"addRequestBody()\"></sd-button>\n }\n }\n } @else {\n <p class=\"sd-acb__empty-text\">{{ 'core.component.api-contract-builder.empty.section' | sdTranslate }}</p>\n }\n </section>\n }\n\n @case (3) {\n <section class=\"sd-acb__section\" data-step=\"response\">\n @if (_draft.res; as _res) {\n <sd-input\n class=\"sd-acb__status\"\n size=\"sm\"\n hideInlineError\n [autoId]=\"_autoId ? _autoId + '-status' : undefined\"\n [label]=\"'core.component.api-contract-builder.response.status' | sdTranslate\"\n [helperText]=\"'core.component.api-contract-builder.response.status-hint' | sdTranslate\"\n [model]=\"statusText()\"\n [disabled]=\"_readonly\"\n (sdChange)=\"setStatus($event)\"></sd-input>\n\n <h4 class=\"sd-acb__heading\">{{ 'core.component.api-contract-builder.response.headers' | sdTranslate }}</h4>\n <sd-api-contract-record-editor\n [record]=\"$any(_res.headers)\"\n basePath=\"res.headers\"\n [disabled]=\"_readonly\"\n [emptyLabel]=\"'core.component.api-contract-builder.empty.headers' | sdTranslate\"\n [autoId]=\"_autoId ? _autoId + '-res-headers' : undefined\"\n (recordChange)=\"setResponseHeaders($event)\"\n (editRequest)=\"openNodeDrawer('res.headers', $event)\"></sd-api-contract-record-editor>\n\n <h4 class=\"sd-acb__heading\">{{ 'core.component.api-contract-builder.response.body' | sdTranslate }}</h4>\n @if (_res.body) {\n <sd-api-contract-node-editor\n [node]=\"$any(_res.body)\"\n basePath=\"res.body\"\n [disabled]=\"_readonly\"\n [autoId]=\"_autoId ? _autoId + '-res-body' : undefined\"\n (nodeChange)=\"setResponseBody($event)\"\n (editRequest)=\"openNodeDrawer('res.body', $event)\"></sd-api-contract-node-editor>\n @if (!_readonly) {\n <sd-button\n type=\"text\"\n color=\"error\"\n size=\"sm\"\n prefixIcon=\"delete\"\n [autoId]=\"_autoId ? _autoId + '-remove-res-body' : undefined\"\n [title]=\"'core.component.api-contract-builder.response.remove-body' | sdTranslate\"\n (click)=\"removeResponseBody()\"></sd-button>\n }\n } @else {\n <p class=\"sd-acb__empty-text\">{{ 'core.component.api-contract-builder.empty.body' | sdTranslate }}</p>\n @if (!_readonly) {\n <sd-button\n type=\"light\"\n size=\"sm\"\n prefixIcon=\"add\"\n [autoId]=\"_autoId ? _autoId + '-add-res-body' : undefined\"\n [title]=\"'core.component.api-contract-builder.response.add-body' | sdTranslate\"\n (click)=\"addResponseBody()\"></sd-button>\n }\n }\n } @else {\n <p class=\"sd-acb__empty-text\">{{ 'core.component.api-contract-builder.empty.section' | sdTranslate }}</p>\n }\n </section>\n }\n\n @case (4) {\n <section class=\"sd-acb__section\" data-step=\"output\">\n @if (_draft.output?.schema; as _outputSchema) {\n @if (!_readonly && _responseFieldOptions.length) {\n <sd-select\n class=\"sd-acb__adopt\"\n size=\"sm\"\n hideInlineError\n [autoId]=\"_autoId ? _autoId + '-use-response' : undefined\"\n [label]=\"'core.component.api-contract-builder.output.use-response' | sdTranslate\"\n [items]=\"_responseFieldOptions\"\n valueField=\"value\"\n displayField=\"label\"\n [(model)]=\"responseFieldToken\"\n (sdChange)=\"useResponseFieldAsOutput($event)\"></sd-select>\n }\n\n <sd-api-contract-node-editor\n [node]=\"$any(_outputSchema)\"\n basePath=\"output.schema\"\n [disabled]=\"_readonly\"\n [autoId]=\"_autoId ? _autoId + '-output' : undefined\"\n (nodeChange)=\"setOutputSchema($event)\"\n (editRequest)=\"openNodeDrawer('output.schema', $event)\"></sd-api-contract-node-editor>\n\n <h4 class=\"sd-acb__heading\">{{ 'core.component.api-contract-builder.output.fields' | sdTranslate }}</h4>\n @let _outputFields = outputFields();\n @if (_outputFields.length) {\n <ul class=\"sd-acb__fields\" [attr.data-autoId]=\"_autoId ? _autoId + '-output-fields' : null\">\n @for (field of _outputFields; track field.path) {\n <li class=\"sd-acb__field\">\n <code>{{ field.path }}</code>\n <span class=\"sd-acb__field-type\">{{ field.type }}</span>\n @if (field.required === true) {\n <span class=\"sd-acb__field-flag\">{{ 'core.component.api-contract-builder.field.required' | sdTranslate }}</span>\n }\n </li>\n }\n </ul>\n } @else {\n <p class=\"sd-acb__empty-text\">{{ 'core.component.api-contract-builder.output.no-fields' | sdTranslate }}</p>\n }\n } @else {\n <p class=\"sd-acb__empty-text\">{{ 'core.component.api-contract-builder.empty.section' | sdTranslate }}</p>\n }\n </section>\n }\n\n @default {\n <section class=\"sd-acb__section\" data-step=\"review\">\n <sd-api-contract-diagnostic-list\n [diagnostics]=\"_diagnostics\"\n [autoId]=\"_autoId ? _autoId + '-diagnostics' : undefined\"\n (navigate)=\"goToDiagnostic($event)\"></sd-api-contract-diagnostic-list>\n\n <h4 class=\"sd-acb__heading\">{{ 'core.component.api-contract-builder.review.json' | sdTranslate }}</h4>\n <sd-code-editor\n language=\"json\"\n maxHeight=\"480px\"\n [viewed]=\"disabled() || mode() === 'view'\"\n [model]=\"json()\"\n (modelChange)=\"applyPastedJson($event)\"></sd-code-editor>\n </section>\n }\n }\n </div>\n\n <!-- why M\u1ED8T instance duy nh\u1EA5t, \u0111\u1EB7t ngo\u00E0i @switch: m\u1ED7i layer m\u1ED9t drawer s\u1EBD l\u00E0 m\u1ED7i layer m\u1ED9t draft, v\u00E0\n \u0111\u1ED5i step gi\u1EEFa l\u00FAc \u0111ang s\u1EEDa s\u1EBD destroy drawer c\u00F9ng v\u1EDBi thay \u0111\u1ED5i ch\u01B0a l\u01B0u. -->\n <sd-api-contract-node-drawer\n #nodeDrawer\n [layer]=\"drawerLayer()\"\n [allowTransform]=\"drawerAllowsTransform()\"\n [suggestions]=\"drawerSuggestions()\"\n [autoId]=\"_autoId ? _autoId + '-node-drawer' : undefined\"\n (nodeCommit)=\"applyNodeCommit($event)\"></sd-api-contract-node-drawer>\n}\n", styles: [":host{display:block;width:100%}.sd-acb__empty{display:flex;flex-direction:column;align-items:center;gap:10px;padding:32px 16px;border:1px dashed var(--sd-border-color, #e6e6e6);border-radius:8px;color:var(--sd-text-secondary, #6b6b6b)}.sd-acb__empty p{margin:0;font-size:13px}.sd-acb__steps{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:16px;border-bottom:1px solid var(--sd-border-color, #e6e6e6);padding-bottom:8px}.sd-acb__step{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border:1px solid transparent;border-radius:999px;background:transparent;color:var(--sd-text-secondary, #6b6b6b);font:inherit;font-size:13px;cursor:pointer}.sd-acb__step:hover,.sd-acb__step:focus-visible{background:var(--sd-surface-muted, #f3f5f8)}.sd-acb__step--active{border-color:var(--sd-primary, #005cbb);background:var(--sd-primary-light, #e5efff);color:var(--sd-primary-dark, #00419e);font-weight:600}.sd-acb__step-index{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:var(--sd-surface-muted, #f3f5f8);font-size:11px;font-weight:700}.sd-acb__step--active .sd-acb__step-index{background:var(--sd-primary, #005cbb);color:#fff}.sd-acb__body{display:block}.sd-acb__section{display:flex;flex-direction:column;gap:12px}.sd-acb__grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;align-items:start}.sd-acb__url,.sd-acb__status{grid-column:span 2}.sd-acb__adopt{max-width:420px}.sd-acb__heading{margin:8px 0 0;font-size:13px;font-weight:600;color:var(--sd-text, #1f2937)}.sd-acb__empty-text{margin:0;font-size:12px;font-style:italic;color:var(--sd-text-secondary, #6b6b6b)}.sd-acb__fields{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:8px}.sd-acb__field{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border:1px solid var(--sd-border-color, #e6e6e6);border-radius:6px;background:var(--sd-surface-muted, #f3f5f8);font-size:12px}.sd-acb__field-type{color:var(--sd-primary, #005cbb);font-weight:600}.sd-acb__field-flag{color:var(--sd-text-secondary, #6b6b6b)}.sd-acb__view{display:flex;flex-direction:column;gap:16px}.sd-acb__summary{display:grid;grid-template-columns:minmax(120px,max-content) 1fr;gap:6px 16px;margin:0;font-size:13px}.sd-acb__summary dt{color:var(--sd-text-secondary, #6b6b6b)}.sd-acb__summary dd{margin:0;font-weight:600;overflow-wrap:anywhere}@media(max-width:768px){.sd-acb__url,.sd-acb__status{grid-column:span 1}.sd-acb__step-label{display:none}}\n"], dependencies: [{ kind: "component", type: SdButton, selector: "sd-button", inputs: ["autoId", "type", "color", "size", "fontSet", "title", "width", "tooltip", "prefixIcon", "suffixIcon", "disabled", "loading", "block", "htmlType"], outputs: ["click"] }, { kind: "component", type: SdCodeEditor, selector: "sd-code-editor", inputs: ["model", "language", "maxHeight", "viewed"], outputs: ["modelChange"] }, { kind: "component", type: SdIcon, selector: "sd-icon", inputs: ["name", "fontIcon", "color", "set", "fontSet", "size", "strokeWidth", "absoluteStrokeWidth", "ariaLabel"] }, { kind: "component", type: SdInput, selector: "sd-input", inputs: ["autoId", "name", "appearance", "floatLabel", "size", "form", "label", "helperText", "placeholder", "type", "mask", "hideInlineError", "blurOnEnter", "clearable", "required", "readonly", "disabled", "viewed", "minlength", "maxlength", "pattern", "patternErrorMessage", "validator", "inlineError", "hyperlink", "model"], outputs: ["modelChange", "sdChange", "sdFocus", "sdBlur", "keyupEnter", "cleared", "sdFocusForceBlur"] }, { kind: "component", type: SdSelect, selector: "sd-select", inputs: ["autoId", "name", "size", "form", "label", "helperText", "placeholder", "valueField", "displayField", "disabledField", "cacheChecksum", "limit", "hyperlink", "minWidthPanel", "hideInlineError", "required", "disabled", "viewed", "multiple", "showSelectAll", "clearable", "validator", "inlineError", "appearance", "floatLabel", "items", "model"], outputs: ["modelChange", "sdChange", "sdSelection"] }, { kind: "component", type: SdApiContractDiagnosticList, selector: "sd-api-contract-diagnostic-list", inputs: ["diagnostics", "autoId"], outputs: ["navigate"] }, { kind: "component", type: SdApiContractNodeEditor, selector: "sd-api-contract-node-editor", inputs: ["node", "basePath", "disabled", "autoId"], outputs: ["nodeChange", "editRequest"] }, { kind: "component", type: SdApiContractNodeDrawer, selector: "sd-api-contract-node-drawer", inputs: ["layer", "allowTransform", "suggestions", "autoId"], outputs: ["nodeCommit"] }, { kind: "component", type: SdApiContractRecordEditor, selector: "sd-api-contract-record-editor", inputs: ["record", "basePath", "disabled", "emptyLabel", "autoId"], outputs: ["recordChange", "editRequest"] }, { kind: "pipe", type: SdTranslatePipe, name: "sdTranslate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3324
+ }
3325
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: SdApiContractBuilder, decorators: [{
3326
+ type: Component,
3327
+ args: [{ selector: 'sd-api-contract-builder', standalone: true, imports: [
3328
+ SdButton,
3329
+ SdCodeEditor,
3330
+ SdIcon,
3331
+ SdInput,
3332
+ SdSelect,
3333
+ SdTranslatePipe,
3334
+ SdApiContractDiagnosticList,
3335
+ SdApiContractNodeEditor,
3336
+ SdApiContractNodeDrawer,
3337
+ SdApiContractRecordEditor,
3338
+ ], changeDetection: ChangeDetectionStrategy.OnPush, template: "@let _draft = draft();\n@let _readonly = readonly();\n@let _autoId = autoId();\n@let _diagnostics = diagnostics();\n@let _activeStep = activeStep();\n@let _requestSuggestions = requestSuggestions();\n@let _responseFieldOptions = responseFieldOptions();\n\n<!-- why: contract t\u1EEB ngo\u00E0i c\u00F3 th\u1EC3 thi\u1EBFu h\u1EB3n m\u1ED9t t\u1EA7ng. M\u1ECDi nh\u00E1nh \u0111\u1ECDc `_draft.<section>` \u0111\u1EC1u ph\u1EA3i\n g\u00E1c \u2014 builder hi\u1EC3n th\u1ECB contract sai k\u00E8m ch\u1EA9n \u0111o\u00E1n ch\u1EE9 kh\u00F4ng t\u1EF1 v\u00E1, v\u00E0 c\u0169ng kh\u00F4ng \u0111\u01B0\u1EE3c n\u1ED5. -->\n@if (!_draft) {\n <div class=\"sd-acb__empty\">\n <sd-icon name=\"description\" size=\"lg\"></sd-icon>\n <p>{{ 'core.component.api-contract-builder.empty.contract' | sdTranslate }}</p>\n @if (!_readonly) {\n <sd-button\n type=\"fill\"\n color=\"primary\"\n size=\"sm\"\n prefixIcon=\"add\"\n [autoId]=\"_autoId ? _autoId + '-create' : undefined\"\n [title]=\"'core.component.api-contract-builder.create-contract' | sdTranslate\"\n (click)=\"createContract()\"></sd-button>\n }\n </div>\n} @else if (isView()) {\n <section class=\"sd-acb__view\">\n <dl class=\"sd-acb__summary\">\n <dt>{{ 'core.component.api-contract-builder.contract.code' | sdTranslate }}</dt>\n <dd data-field=\"code\">{{ _draft.code }}</dd>\n <dt>{{ 'core.component.api-contract-builder.contract.name' | sdTranslate }}</dt>\n <dd data-field=\"name\">{{ _draft.name }}</dd>\n <dt>{{ 'core.component.api-contract-builder.request.method' | sdTranslate }}</dt>\n <dd data-field=\"method\">{{ _draft.req?.method }}</dd>\n <dt>{{ 'core.component.api-contract-builder.request.url' | sdTranslate }}</dt>\n <dd data-field=\"url\">{{ _draft.req?.url }}</dd>\n </dl>\n\n <sd-api-contract-diagnostic-list\n [diagnostics]=\"_diagnostics\"\n [autoId]=\"_autoId ? _autoId + '-diagnostics' : undefined\"></sd-api-contract-diagnostic-list>\n\n <sd-code-editor language=\"json\" [viewed]=\"true\" maxHeight=\"420px\" [model]=\"json()\"></sd-code-editor>\n </section>\n} @else {\n <nav class=\"sd-acb__steps\" [attr.aria-label]=\"'core.component.api-contract-builder.steps' | sdTranslate\">\n @for (step of steps; track step.index) {\n <button\n type=\"button\"\n class=\"sd-acb__step\"\n [class.sd-acb__step--active]=\"_activeStep === step.index\"\n [attr.aria-current]=\"_activeStep === step.index ? 'step' : null\"\n [attr.data-autoId]=\"_autoId ? _autoId + '-step-' + step.key : null\"\n (click)=\"goToStep(step.index)\">\n <span class=\"sd-acb__step-index\">{{ step.index + 1 }}</span>\n <span class=\"sd-acb__step-label\">{{ step.label }}</span>\n </button>\n }\n </nav>\n\n <div class=\"sd-acb__body\">\n @switch (_activeStep) {\n @case (0) {\n <section class=\"sd-acb__section\" data-step=\"general\">\n <div class=\"sd-acb__grid\">\n <sd-input\n size=\"sm\"\n hideInlineError\n [autoId]=\"_autoId ? _autoId + '-code' : undefined\"\n [label]=\"'core.component.api-contract-builder.contract.code' | sdTranslate\"\n [model]=\"_draft.code\"\n [disabled]=\"_readonly\"\n (sdChange)=\"setText('code', $event)\"></sd-input>\n <sd-input\n size=\"sm\"\n hideInlineError\n [autoId]=\"_autoId ? _autoId + '-name' : undefined\"\n [label]=\"'core.component.api-contract-builder.contract.name' | sdTranslate\"\n [model]=\"_draft.name\"\n [disabled]=\"_readonly\"\n (sdChange)=\"setText('name', $event)\"></sd-input>\n <sd-input\n size=\"sm\"\n hideInlineError\n [autoId]=\"_autoId ? _autoId + '-description' : undefined\"\n [label]=\"'core.component.api-contract-builder.contract.description' | sdTranslate\"\n [model]=\"_draft.description ?? ''\"\n [disabled]=\"_readonly\"\n (sdChange)=\"setText('description', $event)\"></sd-input>\n <sd-input\n size=\"sm\"\n hideInlineError\n [autoId]=\"_autoId ? _autoId + '-version' : undefined\"\n [label]=\"'core.component.api-contract-builder.contract.version' | sdTranslate\"\n [model]=\"contractVersion\"\n [readonly]=\"true\"\n [disabled]=\"true\"></sd-input>\n </div>\n </section>\n }\n\n @case (1) {\n <section class=\"sd-acb__section\" data-step=\"input\">\n @if (_draft.input?.schema; as _inputSchema) {\n <sd-api-contract-node-editor\n [node]=\"$any(_inputSchema)\"\n basePath=\"input.schema\"\n [disabled]=\"_readonly\"\n [autoId]=\"_autoId ? _autoId + '-input' : undefined\"\n (nodeChange)=\"setInputSchema($event)\"\n (editRequest)=\"openNodeDrawer('input.schema', $event)\"></sd-api-contract-node-editor>\n } @else {\n <p class=\"sd-acb__empty-text\">{{ 'core.component.api-contract-builder.empty.section' | sdTranslate }}</p>\n }\n </section>\n }\n\n @case (2) {\n <section class=\"sd-acb__section\" data-step=\"request\">\n @if (_draft.req; as _req) {\n <div class=\"sd-acb__grid\">\n <sd-select\n size=\"sm\"\n hideInlineError\n [clearable]=\"false\"\n [autoId]=\"_autoId ? _autoId + '-method' : undefined\"\n [label]=\"'core.component.api-contract-builder.request.method' | sdTranslate\"\n [items]=\"methodOptions\"\n valueField=\"value\"\n displayField=\"label\"\n [model]=\"_req.method\"\n [disabled]=\"_readonly\"\n (sdChange)=\"setMethod($event)\"></sd-select>\n <sd-input\n class=\"sd-acb__url\"\n size=\"sm\"\n hideInlineError\n [autoId]=\"_autoId ? _autoId + '-url' : undefined\"\n [label]=\"'core.component.api-contract-builder.request.url' | sdTranslate\"\n [placeholder]=\"urlPlaceholder\"\n [model]=\"_req.url\"\n [disabled]=\"_readonly\"\n (sdChange)=\"setUrl($event)\"></sd-input>\n </div>\n\n <h4 class=\"sd-acb__heading\">{{ 'core.component.api-contract-builder.request.path' | sdTranslate }}</h4>\n <sd-api-contract-record-editor\n [record]=\"$any(_req.path)\"\n basePath=\"req.path\"\n [disabled]=\"_readonly\"\n [emptyLabel]=\"'core.component.api-contract-builder.empty.path' | sdTranslate\"\n [autoId]=\"_autoId ? _autoId + '-path' : undefined\"\n (recordChange)=\"setRequestRecord('path', $event)\"\n (editRequest)=\"openNodeDrawer('req.path', $event)\"></sd-api-contract-record-editor>\n\n <h4 class=\"sd-acb__heading\">{{ 'core.component.api-contract-builder.request.query' | sdTranslate }}</h4>\n <sd-api-contract-record-editor\n [record]=\"$any(_req.query)\"\n basePath=\"req.query\"\n [disabled]=\"_readonly\"\n [emptyLabel]=\"'core.component.api-contract-builder.empty.query' | sdTranslate\"\n [autoId]=\"_autoId ? _autoId + '-query' : undefined\"\n (recordChange)=\"setRequestRecord('query', $event)\"\n (editRequest)=\"openNodeDrawer('req.query', $event)\"></sd-api-contract-record-editor>\n\n <h4 class=\"sd-acb__heading\">{{ 'core.component.api-contract-builder.request.headers' | sdTranslate }}</h4>\n <sd-api-contract-record-editor\n [record]=\"$any(_req.headers)\"\n basePath=\"req.headers\"\n [disabled]=\"_readonly\"\n [emptyLabel]=\"'core.component.api-contract-builder.empty.headers' | sdTranslate\"\n [autoId]=\"_autoId ? _autoId + '-headers' : undefined\"\n (recordChange)=\"setRequestRecord('headers', $event)\"\n (editRequest)=\"openNodeDrawer('req.headers', $event)\"></sd-api-contract-record-editor>\n\n <h4 class=\"sd-acb__heading\">{{ 'core.component.api-contract-builder.request.body' | sdTranslate }}</h4>\n @if (_req.body) {\n <sd-api-contract-node-editor\n [node]=\"$any(_req.body)\"\n basePath=\"req.body\"\n [disabled]=\"_readonly\"\n [autoId]=\"_autoId ? _autoId + '-body' : undefined\"\n (nodeChange)=\"setRequestBody($event)\"\n (editRequest)=\"openNodeDrawer('req.body', $event)\"></sd-api-contract-node-editor>\n @if (!_readonly) {\n <sd-button\n type=\"text\"\n color=\"error\"\n size=\"sm\"\n prefixIcon=\"delete\"\n [autoId]=\"_autoId ? _autoId + '-remove-body' : undefined\"\n [title]=\"'core.component.api-contract-builder.request.remove-body' | sdTranslate\"\n (click)=\"removeRequestBody()\"></sd-button>\n }\n } @else {\n <p class=\"sd-acb__empty-text\">{{ 'core.component.api-contract-builder.empty.body' | sdTranslate }}</p>\n @if (!_readonly) {\n <sd-button\n type=\"light\"\n size=\"sm\"\n prefixIcon=\"add\"\n [autoId]=\"_autoId ? _autoId + '-add-body' : undefined\"\n [title]=\"'core.component.api-contract-builder.request.add-body' | sdTranslate\"\n (click)=\"addRequestBody()\"></sd-button>\n }\n }\n } @else {\n <p class=\"sd-acb__empty-text\">{{ 'core.component.api-contract-builder.empty.section' | sdTranslate }}</p>\n }\n </section>\n }\n\n @case (3) {\n <section class=\"sd-acb__section\" data-step=\"response\">\n @if (_draft.res; as _res) {\n <sd-input\n class=\"sd-acb__status\"\n size=\"sm\"\n hideInlineError\n [autoId]=\"_autoId ? _autoId + '-status' : undefined\"\n [label]=\"'core.component.api-contract-builder.response.status' | sdTranslate\"\n [helperText]=\"'core.component.api-contract-builder.response.status-hint' | sdTranslate\"\n [model]=\"statusText()\"\n [disabled]=\"_readonly\"\n (sdChange)=\"setStatus($event)\"></sd-input>\n\n <h4 class=\"sd-acb__heading\">{{ 'core.component.api-contract-builder.response.headers' | sdTranslate }}</h4>\n <sd-api-contract-record-editor\n [record]=\"$any(_res.headers)\"\n basePath=\"res.headers\"\n [disabled]=\"_readonly\"\n [emptyLabel]=\"'core.component.api-contract-builder.empty.headers' | sdTranslate\"\n [autoId]=\"_autoId ? _autoId + '-res-headers' : undefined\"\n (recordChange)=\"setResponseHeaders($event)\"\n (editRequest)=\"openNodeDrawer('res.headers', $event)\"></sd-api-contract-record-editor>\n\n <h4 class=\"sd-acb__heading\">{{ 'core.component.api-contract-builder.response.body' | sdTranslate }}</h4>\n @if (_res.body) {\n <sd-api-contract-node-editor\n [node]=\"$any(_res.body)\"\n basePath=\"res.body\"\n [disabled]=\"_readonly\"\n [autoId]=\"_autoId ? _autoId + '-res-body' : undefined\"\n (nodeChange)=\"setResponseBody($event)\"\n (editRequest)=\"openNodeDrawer('res.body', $event)\"></sd-api-contract-node-editor>\n @if (!_readonly) {\n <sd-button\n type=\"text\"\n color=\"error\"\n size=\"sm\"\n prefixIcon=\"delete\"\n [autoId]=\"_autoId ? _autoId + '-remove-res-body' : undefined\"\n [title]=\"'core.component.api-contract-builder.response.remove-body' | sdTranslate\"\n (click)=\"removeResponseBody()\"></sd-button>\n }\n } @else {\n <p class=\"sd-acb__empty-text\">{{ 'core.component.api-contract-builder.empty.body' | sdTranslate }}</p>\n @if (!_readonly) {\n <sd-button\n type=\"light\"\n size=\"sm\"\n prefixIcon=\"add\"\n [autoId]=\"_autoId ? _autoId + '-add-res-body' : undefined\"\n [title]=\"'core.component.api-contract-builder.response.add-body' | sdTranslate\"\n (click)=\"addResponseBody()\"></sd-button>\n }\n }\n } @else {\n <p class=\"sd-acb__empty-text\">{{ 'core.component.api-contract-builder.empty.section' | sdTranslate }}</p>\n }\n </section>\n }\n\n @case (4) {\n <section class=\"sd-acb__section\" data-step=\"output\">\n @if (_draft.output?.schema; as _outputSchema) {\n @if (!_readonly && _responseFieldOptions.length) {\n <sd-select\n class=\"sd-acb__adopt\"\n size=\"sm\"\n hideInlineError\n [autoId]=\"_autoId ? _autoId + '-use-response' : undefined\"\n [label]=\"'core.component.api-contract-builder.output.use-response' | sdTranslate\"\n [items]=\"_responseFieldOptions\"\n valueField=\"value\"\n displayField=\"label\"\n [(model)]=\"responseFieldToken\"\n (sdChange)=\"useResponseFieldAsOutput($event)\"></sd-select>\n }\n\n <sd-api-contract-node-editor\n [node]=\"$any(_outputSchema)\"\n basePath=\"output.schema\"\n [disabled]=\"_readonly\"\n [autoId]=\"_autoId ? _autoId + '-output' : undefined\"\n (nodeChange)=\"setOutputSchema($event)\"\n (editRequest)=\"openNodeDrawer('output.schema', $event)\"></sd-api-contract-node-editor>\n\n <h4 class=\"sd-acb__heading\">{{ 'core.component.api-contract-builder.output.fields' | sdTranslate }}</h4>\n @let _outputFields = outputFields();\n @if (_outputFields.length) {\n <ul class=\"sd-acb__fields\" [attr.data-autoId]=\"_autoId ? _autoId + '-output-fields' : null\">\n @for (field of _outputFields; track field.path) {\n <li class=\"sd-acb__field\">\n <code>{{ field.path }}</code>\n <span class=\"sd-acb__field-type\">{{ field.type }}</span>\n @if (field.required === true) {\n <span class=\"sd-acb__field-flag\">{{ 'core.component.api-contract-builder.field.required' | sdTranslate }}</span>\n }\n </li>\n }\n </ul>\n } @else {\n <p class=\"sd-acb__empty-text\">{{ 'core.component.api-contract-builder.output.no-fields' | sdTranslate }}</p>\n }\n } @else {\n <p class=\"sd-acb__empty-text\">{{ 'core.component.api-contract-builder.empty.section' | sdTranslate }}</p>\n }\n </section>\n }\n\n @default {\n <section class=\"sd-acb__section\" data-step=\"review\">\n <sd-api-contract-diagnostic-list\n [diagnostics]=\"_diagnostics\"\n [autoId]=\"_autoId ? _autoId + '-diagnostics' : undefined\"\n (navigate)=\"goToDiagnostic($event)\"></sd-api-contract-diagnostic-list>\n\n <h4 class=\"sd-acb__heading\">{{ 'core.component.api-contract-builder.review.json' | sdTranslate }}</h4>\n <sd-code-editor\n language=\"json\"\n maxHeight=\"480px\"\n [viewed]=\"disabled() || mode() === 'view'\"\n [model]=\"json()\"\n (modelChange)=\"applyPastedJson($event)\"></sd-code-editor>\n </section>\n }\n }\n </div>\n\n <!-- why M\u1ED8T instance duy nh\u1EA5t, \u0111\u1EB7t ngo\u00E0i @switch: m\u1ED7i layer m\u1ED9t drawer s\u1EBD l\u00E0 m\u1ED7i layer m\u1ED9t draft, v\u00E0\n \u0111\u1ED5i step gi\u1EEFa l\u00FAc \u0111ang s\u1EEDa s\u1EBD destroy drawer c\u00F9ng v\u1EDBi thay \u0111\u1ED5i ch\u01B0a l\u01B0u. -->\n <sd-api-contract-node-drawer\n #nodeDrawer\n [layer]=\"drawerLayer()\"\n [allowTransform]=\"drawerAllowsTransform()\"\n [suggestions]=\"drawerSuggestions()\"\n [autoId]=\"_autoId ? _autoId + '-node-drawer' : undefined\"\n (nodeCommit)=\"applyNodeCommit($event)\"></sd-api-contract-node-drawer>\n}\n", styles: [":host{display:block;width:100%}.sd-acb__empty{display:flex;flex-direction:column;align-items:center;gap:10px;padding:32px 16px;border:1px dashed var(--sd-border-color, #e6e6e6);border-radius:8px;color:var(--sd-text-secondary, #6b6b6b)}.sd-acb__empty p{margin:0;font-size:13px}.sd-acb__steps{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:16px;border-bottom:1px solid var(--sd-border-color, #e6e6e6);padding-bottom:8px}.sd-acb__step{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border:1px solid transparent;border-radius:999px;background:transparent;color:var(--sd-text-secondary, #6b6b6b);font:inherit;font-size:13px;cursor:pointer}.sd-acb__step:hover,.sd-acb__step:focus-visible{background:var(--sd-surface-muted, #f3f5f8)}.sd-acb__step--active{border-color:var(--sd-primary, #005cbb);background:var(--sd-primary-light, #e5efff);color:var(--sd-primary-dark, #00419e);font-weight:600}.sd-acb__step-index{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:var(--sd-surface-muted, #f3f5f8);font-size:11px;font-weight:700}.sd-acb__step--active .sd-acb__step-index{background:var(--sd-primary, #005cbb);color:#fff}.sd-acb__body{display:block}.sd-acb__section{display:flex;flex-direction:column;gap:12px}.sd-acb__grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;align-items:start}.sd-acb__url,.sd-acb__status{grid-column:span 2}.sd-acb__adopt{max-width:420px}.sd-acb__heading{margin:8px 0 0;font-size:13px;font-weight:600;color:var(--sd-text, #1f2937)}.sd-acb__empty-text{margin:0;font-size:12px;font-style:italic;color:var(--sd-text-secondary, #6b6b6b)}.sd-acb__fields{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:8px}.sd-acb__field{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border:1px solid var(--sd-border-color, #e6e6e6);border-radius:6px;background:var(--sd-surface-muted, #f3f5f8);font-size:12px}.sd-acb__field-type{color:var(--sd-primary, #005cbb);font-weight:600}.sd-acb__field-flag{color:var(--sd-text-secondary, #6b6b6b)}.sd-acb__view{display:flex;flex-direction:column;gap:16px}.sd-acb__summary{display:grid;grid-template-columns:minmax(120px,max-content) 1fr;gap:6px 16px;margin:0;font-size:13px}.sd-acb__summary dt{color:var(--sd-text-secondary, #6b6b6b)}.sd-acb__summary dd{margin:0;font-weight:600;overflow-wrap:anywhere}@media(max-width:768px){.sd-acb__url,.sd-acb__status{grid-column:span 1}.sd-acb__step-label{display:none}}\n"] }]
3339
+ }], ctorParameters: () => [], propDecorators: { model: [{ type: i0.Input, args: [{ isSignal: true, alias: "model", required: false }] }, { type: i0.Output, args: ["modelChange"] }], mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], autoId: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoId", required: false }] }], diagnosticsChange: [{ type: i0.Output, args: ["diagnosticsChange"] }], validChange: [{ type: i0.Output, args: ["validChange"] }], nodeDrawer: [{ type: i0.ViewChild, args: ['nodeDrawer', { isSignal: true }] }], autoIdAttr: [{
3340
+ type: HostBinding,
3341
+ args: ['attr.data-autoId']
3342
+ }] } });
3343
+ /**
3344
+ * Places a committed node back under a schema root's `properties`.
3345
+ *
3346
+ * why phân biệt add với overwrite: `addSdApiContractProperty` CỐ Ý không ghi đè key đã tồn tại (nó bảo
3347
+ * caller phải tự dedupe). Sửa một field đang có thì phải đi qua `setSdApiContractNodeAt`, nếu không
3348
+ * mọi lần Lưu trên field cũ sẽ im lặng không có tác dụng.
3349
+ */
3350
+ function commitIntoProperties(root, pointer, previous, commit) {
3351
+ // why đi qua pointer: danh sách nói cho ta biết nó đang đọc `properties` hay `items.properties`.
3352
+ // Ghi thẳng vào root sẽ tạo ra một nhánh `properties` song song mà tầng `array` không bao giờ đọc.
3353
+ const container = getSdApiContractNodeAt(root, pointer);
3354
+ if (!container)
3355
+ return root;
3356
+ let next = container;
3357
+ if (previous !== null && previous !== commit.name) {
3358
+ next = renameSdApiContractProperty(next, previous, commit.name);
3359
+ }
3360
+ const exists = next.properties && Object.prototype.hasOwnProperty.call(next.properties, commit.name);
3361
+ next = exists
3362
+ ? setSdApiContractNodeAt(next, ['properties', commit.name], commit.node)
3363
+ : addSdApiContractProperty(next, commit.name, commit.node);
3364
+ return setSdApiContractNodeAt(root, pointer, next);
3365
+ }
3366
+ /** Places a committed node back into a keyed record, keeping the author's key order. */
3367
+ function commitIntoRecord(record, previous, commit) {
3368
+ if (previous !== null && previous !== commit.name) {
3369
+ const renamed = sdApiContractRecordRename(record, previous, commit.name);
3370
+ return sdApiContractRecordSet(renamed, commit.name, commit.node);
3371
+ }
3372
+ return sdApiContractRecordSet(record, commit.name, commit.node);
3373
+ }
3374
+ /** Recursively rebuilds a response subtree as a mapped output subtree. */
3375
+ function adoptResponseNode(node, segments) {
3376
+ if (node.type === 'object' && node.properties) {
3377
+ const properties = {};
3378
+ for (const key of Object.keys(node.properties)) {
3379
+ properties[key] = adoptResponseNode(node.properties[key], [...segments, key]);
3380
+ }
3381
+ const next = { type: 'object', properties };
3382
+ if (node.required !== undefined)
3383
+ next.required = node.required;
3384
+ if (node.label !== undefined)
3385
+ next.label = node.label;
3386
+ if (node.description !== undefined)
3387
+ next.description = node.description;
3388
+ return next;
3389
+ }
3390
+ return { ...cloneSdApiContractNode(node), source: formatSdApiContractExpression('res', segments) };
3391
+ }
3392
+ function stepForPath(path) {
3393
+ if (path.startsWith('input.'))
3394
+ return STEP_INPUT;
3395
+ if (path.startsWith('req'))
3396
+ return STEP_REQUEST;
3397
+ if (path.startsWith('res'))
3398
+ return STEP_RESPONSE;
3399
+ if (path.startsWith('output'))
3400
+ return STEP_OUTPUT;
3401
+ return STEP_GENERAL;
3402
+ }
3403
+
3404
+ /**
3405
+ * Generated bundle index. Do not edit.
3406
+ */
3407
+
3408
+ export { SD_API_CONTRACT_ALLOWED_ROOTS, SD_API_CONTRACT_CONFIGURATION, SD_API_CONTRACT_DATA_TYPES, SD_API_CONTRACT_EMPTY_CONFIGURATION, SD_API_CONTRACT_EXPRESSION_ROOTS, SD_API_CONTRACT_HTTP_METHODS, SD_API_CONTRACT_SAMPLE_ENVIRONMENT, SD_API_CONTRACT_SCALAR_DATA_TYPES, SD_API_CONTRACT_VERSION, SdApiContractBuilder, addSdApiContractProperty, changeSdApiContractNodeType, cloneSdApiContract, cloneSdApiContractNode, createSdApiContractNode, extractSdApiContractReferences, formatSdApiContractExpression, formatSdApiContractPointer, getSdApiContractNodeAt, listSdApiContractResponseFields, listSdApiContractSchemaFields, parseSdApiContractTemplate, parseSdApiContractUrlPlaceholders, provideSdApiContract, removeSdApiContractProperty, renameSdApiContractProperty, resolveSdApiContractConfiguration, resolveSdApiContractResponsePath, resolveSdApiContractSchemaPath, sdApiContractCreateSample, sdApiContractInvalidSample, sdApiContractRecordRemove, sdApiContractRecordRename, sdApiContractRecordSet, sdApiContractSearchSample, sdIsApiContractDataType, sdIsApiContractHttpMethod, sdIsApiContractScalarDataType, sdIsApiContractTemporalDataType, serializeSdApiContract, setSdApiContractNodeAt, validateSdApiContract };
3409
+ //# sourceMappingURL=sdcorejs-angular-components-api-contract-builder.mjs.map