@sdcorejs/angular 19.2.0 → 19.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/api-contract-builder/index.d.ts +8 -0
- package/components/api-contract-builder/src/api-contract-builder.component.d.ts +123 -0
- package/components/api-contract-builder/src/api-contract.configuration.d.ts +41 -0
- package/components/api-contract-builder/src/api-contract.expression.d.ts +43 -0
- package/components/api-contract-builder/src/api-contract.model.d.ts +167 -0
- package/components/api-contract-builder/src/api-contract.samples.d.ts +25 -0
- package/components/api-contract-builder/src/api-contract.schema.d.ts +126 -0
- package/components/api-contract-builder/src/api-contract.serializer.d.ts +17 -0
- package/components/api-contract-builder/src/api-contract.validation.d.ts +12 -0
- package/components/api-contract-builder/src/components/api-contract-diagnostic-list.component.d.ts +18 -0
- package/components/api-contract-builder/src/components/api-contract-node-drawer.component.d.ts +130 -0
- package/components/api-contract-builder/src/components/api-contract-node-editor.component.d.ts +35 -0
- package/components/api-contract-builder/src/components/api-contract-node-summary.component.d.ts +41 -0
- package/components/api-contract-builder/src/components/api-contract-record-editor.component.d.ts +36 -0
- package/components/api-contract-builder/src/components/api-contract-source-editor.component.d.ts +86 -0
- package/components/api-contract-builder/src/components/api-contract-suggestion.model.d.ts +17 -0
- package/components/badge/src/badge.component.d.ts +1 -0
- package/components/index.d.ts +1 -0
- package/components/section/src/section.component.d.ts +2 -0
- package/fesm2022/sdcorejs-angular-components-api-contract-builder.mjs +3411 -0
- package/fesm2022/sdcorejs-angular-components-api-contract-builder.mjs.map +1 -0
- package/fesm2022/sdcorejs-angular-components-badge.mjs +14 -2
- package/fesm2022/sdcorejs-angular-components-badge.mjs.map +1 -1
- package/fesm2022/sdcorejs-angular-components-operator.mjs +2 -2
- package/fesm2022/sdcorejs-angular-components-operator.mjs.map +1 -1
- package/fesm2022/sdcorejs-angular-components-section.mjs +22 -5
- package/fesm2022/sdcorejs-angular-components-section.mjs.map +1 -1
- package/fesm2022/sdcorejs-angular-components-table.mjs +14 -13
- package/fesm2022/sdcorejs-angular-components-table.mjs.map +1 -1
- package/fesm2022/sdcorejs-angular-components.mjs +1 -0
- package/fesm2022/sdcorejs-angular-components.mjs.map +1 -1
- package/fesm2022/sdcorejs-angular-i18n.mjs +405 -0
- package/fesm2022/sdcorejs-angular-i18n.mjs.map +1 -1
- package/fesm2022/sdcorejs-angular-modules-icon.mjs.map +1 -1
- package/fesm2022/sdcorejs-angular-modules-layout.mjs +146 -83
- package/fesm2022/sdcorejs-angular-modules-layout.mjs.map +1 -1
- package/i18n/src/en.d.ts +80 -0
- package/modules/layout/components/sidebar-v1/components/sidebar/sidebar.component.d.ts +9 -0
- package/modules/layout/pipes/menu-focus.pipe.d.ts +6 -2
- package/modules/layout/utils/index.d.ts +1 -0
- package/modules/layout/utils/menu-path.util.d.ts +16 -0
- package/package.json +17 -13
|
@@ -0,0 +1,3411 @@
|
|
|
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([]);
|
|
1285
|
+
autoId = input();
|
|
1286
|
+
navigate = output();
|
|
1287
|
+
errorCount = computed(() => this.diagnostics().filter(diagnostic => diagnostic.severity === 'error').length);
|
|
1288
|
+
warningCount = computed(() => this.diagnostics().filter(diagnostic => diagnostic.severity === 'warning').length);
|
|
1289
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.21", ngImport: i0, type: SdApiContractDiagnosticList, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1290
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.21", 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;&[data-severity=ok]{color:var(--sd-success, #1c6c3a)}&[data-severity=error]{color:var(--sd-error, #b3261e)}&[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;&:hover,&: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: "19.2.21", 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;&[data-severity=ok]{color:var(--sd-success, #1c6c3a)}&[data-severity=error]{color:var(--sd-error, #b3261e)}&[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;&:hover,&: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
|
+
}] });
|
|
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();
|
|
1398
|
+
node = input.required();
|
|
1399
|
+
/** `view` mode and `disabled` both land here: the row becomes text, with no action at all. */
|
|
1400
|
+
readonly = input(false, { transform: booleanAttribute });
|
|
1401
|
+
autoId = input();
|
|
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
|
+
});
|
|
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
|
+
});
|
|
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: "19.2.21", ngImport: i0, type: SdApiContractNodeSummary, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1451
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.21", 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: "19.2.21", 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
|
+
}] });
|
|
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();
|
|
1551
|
+
suggestions = input([]);
|
|
1552
|
+
disabled = input(false, { transform: booleanAttribute });
|
|
1553
|
+
autoId = input();
|
|
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({
|
|
1592
|
+
source: () => this.node(),
|
|
1593
|
+
computation: (node, previous) => {
|
|
1594
|
+
const derived = deriveMode(node);
|
|
1595
|
+
if (previous?.value === 'advanced' && derived === 'source')
|
|
1596
|
+
return 'advanced';
|
|
1597
|
+
return derived;
|
|
1598
|
+
},
|
|
1599
|
+
});
|
|
1600
|
+
/**
|
|
1601
|
+
* Giá trị hiển thị của picker.
|
|
1602
|
+
*
|
|
1603
|
+
* 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
|
|
1604
|
+
* 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
|
|
1605
|
+
* lỗi thấy được ngay trên hàng, khớp với diagnostic `mapping.node.unmapped` mà validation đã báo.
|
|
1606
|
+
*/
|
|
1607
|
+
modeValue = computed(() => {
|
|
1608
|
+
const mode = this.mode();
|
|
1609
|
+
return mode === 'none' ? null : mode;
|
|
1610
|
+
});
|
|
1611
|
+
modeOptions = computed(() => {
|
|
1612
|
+
const type = this.node().type;
|
|
1613
|
+
if (type === 'object')
|
|
1614
|
+
return this.#objectModes;
|
|
1615
|
+
if (type === 'array')
|
|
1616
|
+
return this.#arrayModes;
|
|
1617
|
+
return this.#scalarModes;
|
|
1618
|
+
});
|
|
1619
|
+
suggestionOptions = computed(() => {
|
|
1620
|
+
const type = this.node().type;
|
|
1621
|
+
const all = [...this.suggestions()];
|
|
1622
|
+
// 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
|
|
1623
|
+
// string (`Bearer ${env.token}`) và các cặp type tương thích không bị giấu mất.
|
|
1624
|
+
const compatible = all.filter(suggestion => isAssignable(suggestion.type, type));
|
|
1625
|
+
return compatible.length > 0 ? compatible : all;
|
|
1626
|
+
});
|
|
1627
|
+
/**
|
|
1628
|
+
* Options for the source dropdown.
|
|
1629
|
+
*
|
|
1630
|
+
* 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
|
|
1631
|
+
* 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
|
|
1632
|
+
* 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
|
|
1633
|
+
* hiển thị; validation lo phần báo `mapping.reference.missing`.
|
|
1634
|
+
*/
|
|
1635
|
+
sourceOptions = computed(() => {
|
|
1636
|
+
const options = this.suggestionOptions();
|
|
1637
|
+
const current = this.node().source;
|
|
1638
|
+
if (typeof current !== 'string' || current === '')
|
|
1639
|
+
return options;
|
|
1640
|
+
if (options.some(option => option.expression === current))
|
|
1641
|
+
return options;
|
|
1642
|
+
const parsed = parseSdApiContractTemplate(current);
|
|
1643
|
+
if (parsed.kind !== 'exact')
|
|
1644
|
+
return options;
|
|
1645
|
+
const reference = parsed.references[0];
|
|
1646
|
+
return [
|
|
1647
|
+
...options,
|
|
1648
|
+
{
|
|
1649
|
+
expression: current,
|
|
1650
|
+
path: reference?.expression ?? current,
|
|
1651
|
+
root: reference?.root ?? 'input',
|
|
1652
|
+
type: this.node().type,
|
|
1653
|
+
display: reference?.expression ?? current,
|
|
1654
|
+
},
|
|
1655
|
+
];
|
|
1656
|
+
});
|
|
1657
|
+
staticText = computed(() => {
|
|
1658
|
+
const value = this.node().value;
|
|
1659
|
+
return value === undefined || value === null ? '' : String(value);
|
|
1660
|
+
});
|
|
1661
|
+
staticNumber = computed(() => {
|
|
1662
|
+
const value = this.node().value;
|
|
1663
|
+
return typeof value === 'number' ? value : null;
|
|
1664
|
+
});
|
|
1665
|
+
setMode(mode) {
|
|
1666
|
+
const node = this.node();
|
|
1667
|
+
// why: đặt mode TRƯỚC khi emit. Node quay lại từ cha sẽ kích `linkedSignal` tính lại, và nó đọc
|
|
1668
|
+
// `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
|
|
1669
|
+
// `advanced` sang `source`.
|
|
1670
|
+
if (mode === 'source' || mode === 'advanced') {
|
|
1671
|
+
this.mode.set(mode);
|
|
1672
|
+
// Rời `advanced` sang `source` thì template ghép không còn diễn đạt được bằng dropdown — xoá để
|
|
1673
|
+
// dropdown bắt đầu sạch. Ngược lại, đã có key `source` thì đổi mode không đụng tới model.
|
|
1674
|
+
const abandonsTemplate = mode === 'source' && parseSdApiContractTemplate(node.source).kind === 'interpolated';
|
|
1675
|
+
if (node.source === undefined || abandonsTemplate) {
|
|
1676
|
+
this.nodeChange.emit({ ...withoutMapping(node), source: '' });
|
|
1677
|
+
}
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
if (mode === 'static') {
|
|
1681
|
+
this.mode.set('static');
|
|
1682
|
+
this.nodeChange.emit({ ...withoutMapping(node), value: defaultStatic(node.type) });
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
this.mode.set(node.type === 'object' ? 'nested' : 'none');
|
|
1686
|
+
this.nodeChange.emit(withoutMapping(node));
|
|
1687
|
+
}
|
|
1688
|
+
setSource(value) {
|
|
1689
|
+
this.nodeChange.emit({ ...withoutMapping(this.node()), source: typeof value === 'string' ? value : '' });
|
|
1690
|
+
}
|
|
1691
|
+
setStaticScalar(value) {
|
|
1692
|
+
const node = this.node();
|
|
1693
|
+
this.nodeChange.emit({ ...withoutMapping(node), value: value === null || value === undefined ? '' : String(value) });
|
|
1694
|
+
}
|
|
1695
|
+
setStaticNumber(value) {
|
|
1696
|
+
const node = this.node();
|
|
1697
|
+
const parsed = typeof value === 'number' ? value : Number(value === null || value === undefined ? '' : String(value).trim());
|
|
1698
|
+
this.nodeChange.emit({ ...withoutMapping(node), value: Number.isFinite(parsed) ? parsed : 0 });
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Stores a `date` / `datetime` literal.
|
|
1702
|
+
*
|
|
1703
|
+
* why: `<sd-date>` / `<sd-datetime>` có thể bắn ra một `Date`. `String(new Date())` ra
|
|
1704
|
+
* `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ẽ
|
|
1705
|
+
* mang một giá trị không portable. Chuẩn hoá về ISO.
|
|
1706
|
+
*/
|
|
1707
|
+
setStaticTemporal(value) {
|
|
1708
|
+
const node = this.node();
|
|
1709
|
+
if (value instanceof Date) {
|
|
1710
|
+
const iso = Number.isNaN(value.getTime()) ? '' : value.toISOString();
|
|
1711
|
+
this.nodeChange.emit({ ...withoutMapping(node), value: iso });
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
this.nodeChange.emit({ ...withoutMapping(node), value: value === null || value === undefined ? '' : String(value) });
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* Stores an `object` / `array` literal from the JSON editor.
|
|
1718
|
+
*
|
|
1719
|
+
* why: `<sd-code-editor language="json">` bắn ra STRING khi cú pháp còn dở (`{"a":`). Ghi chuỗi đó
|
|
1720
|
+
* 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 —
|
|
1721
|
+
* người dùng vẫn thấy nguyên văn mình đang gõ trong editor.
|
|
1722
|
+
*/
|
|
1723
|
+
setStaticJson(value) {
|
|
1724
|
+
if (value === null || typeof value !== 'object')
|
|
1725
|
+
return;
|
|
1726
|
+
this.nodeChange.emit({ ...withoutMapping(this.node()), value: value });
|
|
1727
|
+
}
|
|
1728
|
+
setStaticBoolean(value) {
|
|
1729
|
+
this.nodeChange.emit({ ...withoutMapping(this.node()), value: value === 'true' || value === true });
|
|
1730
|
+
}
|
|
1731
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.21", ngImport: i0, type: SdApiContractSourceEditor, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1732
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.21", 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: `
|
|
1733
|
+
@let _node = node();
|
|
1734
|
+
@let _disabled = disabled();
|
|
1735
|
+
@let _mode = mode();
|
|
1736
|
+
@let _autoId = autoId();
|
|
1737
|
+
@let _suggestions = sourceOptions();
|
|
1738
|
+
|
|
1739
|
+
<div class="sd-acb-source">
|
|
1740
|
+
<sd-select
|
|
1741
|
+
class="sd-acb-source__mode"
|
|
1742
|
+
size="sm"
|
|
1743
|
+
hideInlineError
|
|
1744
|
+
[clearable]="false"
|
|
1745
|
+
[autoId]="_autoId ? _autoId + '-mode' : undefined"
|
|
1746
|
+
[label]="'core.component.api-contract-builder.mapping.mode' | sdTranslate"
|
|
1747
|
+
[items]="modeOptions()"
|
|
1748
|
+
valueField="value"
|
|
1749
|
+
displayField="label"
|
|
1750
|
+
[required]="true"
|
|
1751
|
+
[model]="modeValue()"
|
|
1752
|
+
[disabled]="_disabled"
|
|
1753
|
+
(sdChange)="setMode($event)"></sd-select>
|
|
1754
|
+
|
|
1755
|
+
@if (_mode === 'source') {
|
|
1756
|
+
<sd-select
|
|
1757
|
+
class="sd-acb-source__expression"
|
|
1758
|
+
size="sm"
|
|
1759
|
+
hideInlineError
|
|
1760
|
+
[clearable]="false"
|
|
1761
|
+
[autoId]="_autoId ? _autoId + '-source' : undefined"
|
|
1762
|
+
[label]="'core.component.api-contract-builder.mapping.source' | sdTranslate"
|
|
1763
|
+
[items]="_suggestions"
|
|
1764
|
+
valueField="expression"
|
|
1765
|
+
displayField="display"
|
|
1766
|
+
[model]="_node.source ?? ''"
|
|
1767
|
+
[disabled]="_disabled"
|
|
1768
|
+
(sdChange)="setSource($event)"></sd-select>
|
|
1769
|
+
} @else if (_mode === 'advanced') {
|
|
1770
|
+
<sd-input
|
|
1771
|
+
class="sd-acb-source__expression"
|
|
1772
|
+
size="sm"
|
|
1773
|
+
hideInlineError
|
|
1774
|
+
[autoId]="_autoId ? _autoId + '-advanced' : undefined"
|
|
1775
|
+
[label]="'core.component.api-contract-builder.mapping.source' | sdTranslate"
|
|
1776
|
+
[placeholder]="advancedPlaceholder"
|
|
1777
|
+
[model]="_node.source ?? ''"
|
|
1778
|
+
[disabled]="_disabled"
|
|
1779
|
+
(sdChange)="setSource($event)"></sd-input>
|
|
1780
|
+
} @else if (_mode === 'static') {
|
|
1781
|
+
@let _staticAutoId = _autoId ? _autoId + '-static' : undefined;
|
|
1782
|
+
@let _staticLabel = 'core.component.api-contract-builder.mapping.value' | sdTranslate;
|
|
1783
|
+
|
|
1784
|
+
@switch (_node.type) {
|
|
1785
|
+
@case ('boolean') {
|
|
1786
|
+
<sd-select
|
|
1787
|
+
class="sd-acb-source__static"
|
|
1788
|
+
size="sm"
|
|
1789
|
+
hideInlineError
|
|
1790
|
+
[clearable]="false"
|
|
1791
|
+
[autoId]="_staticAutoId"
|
|
1792
|
+
[label]="_staticLabel"
|
|
1793
|
+
[items]="booleanOptions"
|
|
1794
|
+
valueField="value"
|
|
1795
|
+
displayField="label"
|
|
1796
|
+
[model]="_node.value === true ? 'true' : 'false'"
|
|
1797
|
+
[disabled]="_disabled"
|
|
1798
|
+
(sdChange)="setStaticBoolean($event)"></sd-select>
|
|
1799
|
+
}
|
|
1800
|
+
@case ('number') {
|
|
1801
|
+
<sd-input-number
|
|
1802
|
+
class="sd-acb-source__static"
|
|
1803
|
+
size="sm"
|
|
1804
|
+
hideInlineError
|
|
1805
|
+
[autoId]="_staticAutoId"
|
|
1806
|
+
[label]="_staticLabel"
|
|
1807
|
+
[model]="staticNumber()"
|
|
1808
|
+
[disabled]="_disabled"
|
|
1809
|
+
(sdChange)="setStaticNumber($event)"></sd-input-number>
|
|
1810
|
+
}
|
|
1811
|
+
@case ('date') {
|
|
1812
|
+
<sd-date
|
|
1813
|
+
class="sd-acb-source__static"
|
|
1814
|
+
size="sm"
|
|
1815
|
+
hideInlineError
|
|
1816
|
+
[autoId]="_staticAutoId"
|
|
1817
|
+
[label]="_staticLabel"
|
|
1818
|
+
[model]="staticText()"
|
|
1819
|
+
[disabled]="_disabled"
|
|
1820
|
+
(sdChange)="setStaticTemporal($event)"></sd-date>
|
|
1821
|
+
}
|
|
1822
|
+
@case ('datetime') {
|
|
1823
|
+
<sd-datetime
|
|
1824
|
+
class="sd-acb-source__static"
|
|
1825
|
+
size="sm"
|
|
1826
|
+
hideInlineError
|
|
1827
|
+
[autoId]="_staticAutoId"
|
|
1828
|
+
[label]="_staticLabel"
|
|
1829
|
+
[model]="staticText()"
|
|
1830
|
+
[disabled]="_disabled"
|
|
1831
|
+
(sdChange)="setStaticTemporal($event)"></sd-datetime>
|
|
1832
|
+
}
|
|
1833
|
+
@case ('object') {
|
|
1834
|
+
<sd-code-editor
|
|
1835
|
+
class="sd-acb-source__static sd-acb-source__static--json"
|
|
1836
|
+
language="json"
|
|
1837
|
+
maxHeight="220px"
|
|
1838
|
+
[model]="_node.value"
|
|
1839
|
+
[viewed]="_disabled"
|
|
1840
|
+
(modelChange)="setStaticJson($event)"></sd-code-editor>
|
|
1841
|
+
}
|
|
1842
|
+
@case ('array') {
|
|
1843
|
+
<sd-code-editor
|
|
1844
|
+
class="sd-acb-source__static sd-acb-source__static--json"
|
|
1845
|
+
language="json"
|
|
1846
|
+
maxHeight="220px"
|
|
1847
|
+
[model]="_node.value"
|
|
1848
|
+
[viewed]="_disabled"
|
|
1849
|
+
(modelChange)="setStaticJson($event)"></sd-code-editor>
|
|
1850
|
+
}
|
|
1851
|
+
@default {
|
|
1852
|
+
<sd-input
|
|
1853
|
+
class="sd-acb-source__static"
|
|
1854
|
+
size="sm"
|
|
1855
|
+
hideInlineError
|
|
1856
|
+
[autoId]="_staticAutoId"
|
|
1857
|
+
[label]="_staticLabel"
|
|
1858
|
+
[model]="staticText()"
|
|
1859
|
+
[disabled]="_disabled"
|
|
1860
|
+
(sdChange)="setStaticScalar($event)"></sd-input>
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
</div>
|
|
1865
|
+
`, 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 });
|
|
1866
|
+
}
|
|
1867
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.21", ngImport: i0, type: SdApiContractSourceEditor, decorators: [{
|
|
1868
|
+
type: Component,
|
|
1869
|
+
args: [{ selector: 'sd-api-contract-source-editor', standalone: true, imports: [SdCodeEditor, SdDate, SdDatetime, SdInput, SdInputNumber, SdSelect, SdTranslatePipe], changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
1870
|
+
@let _node = node();
|
|
1871
|
+
@let _disabled = disabled();
|
|
1872
|
+
@let _mode = mode();
|
|
1873
|
+
@let _autoId = autoId();
|
|
1874
|
+
@let _suggestions = sourceOptions();
|
|
1875
|
+
|
|
1876
|
+
<div class="sd-acb-source">
|
|
1877
|
+
<sd-select
|
|
1878
|
+
class="sd-acb-source__mode"
|
|
1879
|
+
size="sm"
|
|
1880
|
+
hideInlineError
|
|
1881
|
+
[clearable]="false"
|
|
1882
|
+
[autoId]="_autoId ? _autoId + '-mode' : undefined"
|
|
1883
|
+
[label]="'core.component.api-contract-builder.mapping.mode' | sdTranslate"
|
|
1884
|
+
[items]="modeOptions()"
|
|
1885
|
+
valueField="value"
|
|
1886
|
+
displayField="label"
|
|
1887
|
+
[required]="true"
|
|
1888
|
+
[model]="modeValue()"
|
|
1889
|
+
[disabled]="_disabled"
|
|
1890
|
+
(sdChange)="setMode($event)"></sd-select>
|
|
1891
|
+
|
|
1892
|
+
@if (_mode === 'source') {
|
|
1893
|
+
<sd-select
|
|
1894
|
+
class="sd-acb-source__expression"
|
|
1895
|
+
size="sm"
|
|
1896
|
+
hideInlineError
|
|
1897
|
+
[clearable]="false"
|
|
1898
|
+
[autoId]="_autoId ? _autoId + '-source' : undefined"
|
|
1899
|
+
[label]="'core.component.api-contract-builder.mapping.source' | sdTranslate"
|
|
1900
|
+
[items]="_suggestions"
|
|
1901
|
+
valueField="expression"
|
|
1902
|
+
displayField="display"
|
|
1903
|
+
[model]="_node.source ?? ''"
|
|
1904
|
+
[disabled]="_disabled"
|
|
1905
|
+
(sdChange)="setSource($event)"></sd-select>
|
|
1906
|
+
} @else if (_mode === 'advanced') {
|
|
1907
|
+
<sd-input
|
|
1908
|
+
class="sd-acb-source__expression"
|
|
1909
|
+
size="sm"
|
|
1910
|
+
hideInlineError
|
|
1911
|
+
[autoId]="_autoId ? _autoId + '-advanced' : undefined"
|
|
1912
|
+
[label]="'core.component.api-contract-builder.mapping.source' | sdTranslate"
|
|
1913
|
+
[placeholder]="advancedPlaceholder"
|
|
1914
|
+
[model]="_node.source ?? ''"
|
|
1915
|
+
[disabled]="_disabled"
|
|
1916
|
+
(sdChange)="setSource($event)"></sd-input>
|
|
1917
|
+
} @else if (_mode === 'static') {
|
|
1918
|
+
@let _staticAutoId = _autoId ? _autoId + '-static' : undefined;
|
|
1919
|
+
@let _staticLabel = 'core.component.api-contract-builder.mapping.value' | sdTranslate;
|
|
1920
|
+
|
|
1921
|
+
@switch (_node.type) {
|
|
1922
|
+
@case ('boolean') {
|
|
1923
|
+
<sd-select
|
|
1924
|
+
class="sd-acb-source__static"
|
|
1925
|
+
size="sm"
|
|
1926
|
+
hideInlineError
|
|
1927
|
+
[clearable]="false"
|
|
1928
|
+
[autoId]="_staticAutoId"
|
|
1929
|
+
[label]="_staticLabel"
|
|
1930
|
+
[items]="booleanOptions"
|
|
1931
|
+
valueField="value"
|
|
1932
|
+
displayField="label"
|
|
1933
|
+
[model]="_node.value === true ? 'true' : 'false'"
|
|
1934
|
+
[disabled]="_disabled"
|
|
1935
|
+
(sdChange)="setStaticBoolean($event)"></sd-select>
|
|
1936
|
+
}
|
|
1937
|
+
@case ('number') {
|
|
1938
|
+
<sd-input-number
|
|
1939
|
+
class="sd-acb-source__static"
|
|
1940
|
+
size="sm"
|
|
1941
|
+
hideInlineError
|
|
1942
|
+
[autoId]="_staticAutoId"
|
|
1943
|
+
[label]="_staticLabel"
|
|
1944
|
+
[model]="staticNumber()"
|
|
1945
|
+
[disabled]="_disabled"
|
|
1946
|
+
(sdChange)="setStaticNumber($event)"></sd-input-number>
|
|
1947
|
+
}
|
|
1948
|
+
@case ('date') {
|
|
1949
|
+
<sd-date
|
|
1950
|
+
class="sd-acb-source__static"
|
|
1951
|
+
size="sm"
|
|
1952
|
+
hideInlineError
|
|
1953
|
+
[autoId]="_staticAutoId"
|
|
1954
|
+
[label]="_staticLabel"
|
|
1955
|
+
[model]="staticText()"
|
|
1956
|
+
[disabled]="_disabled"
|
|
1957
|
+
(sdChange)="setStaticTemporal($event)"></sd-date>
|
|
1958
|
+
}
|
|
1959
|
+
@case ('datetime') {
|
|
1960
|
+
<sd-datetime
|
|
1961
|
+
class="sd-acb-source__static"
|
|
1962
|
+
size="sm"
|
|
1963
|
+
hideInlineError
|
|
1964
|
+
[autoId]="_staticAutoId"
|
|
1965
|
+
[label]="_staticLabel"
|
|
1966
|
+
[model]="staticText()"
|
|
1967
|
+
[disabled]="_disabled"
|
|
1968
|
+
(sdChange)="setStaticTemporal($event)"></sd-datetime>
|
|
1969
|
+
}
|
|
1970
|
+
@case ('object') {
|
|
1971
|
+
<sd-code-editor
|
|
1972
|
+
class="sd-acb-source__static sd-acb-source__static--json"
|
|
1973
|
+
language="json"
|
|
1974
|
+
maxHeight="220px"
|
|
1975
|
+
[model]="_node.value"
|
|
1976
|
+
[viewed]="_disabled"
|
|
1977
|
+
(modelChange)="setStaticJson($event)"></sd-code-editor>
|
|
1978
|
+
}
|
|
1979
|
+
@case ('array') {
|
|
1980
|
+
<sd-code-editor
|
|
1981
|
+
class="sd-acb-source__static sd-acb-source__static--json"
|
|
1982
|
+
language="json"
|
|
1983
|
+
maxHeight="220px"
|
|
1984
|
+
[model]="_node.value"
|
|
1985
|
+
[viewed]="_disabled"
|
|
1986
|
+
(modelChange)="setStaticJson($event)"></sd-code-editor>
|
|
1987
|
+
}
|
|
1988
|
+
@default {
|
|
1989
|
+
<sd-input
|
|
1990
|
+
class="sd-acb-source__static"
|
|
1991
|
+
size="sm"
|
|
1992
|
+
hideInlineError
|
|
1993
|
+
[autoId]="_staticAutoId"
|
|
1994
|
+
[label]="_staticLabel"
|
|
1995
|
+
[model]="staticText()"
|
|
1996
|
+
[disabled]="_disabled"
|
|
1997
|
+
(sdChange)="setStaticScalar($event)"></sd-input>
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
</div>
|
|
2002
|
+
`, 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"] }]
|
|
2003
|
+
}] });
|
|
2004
|
+
/**
|
|
2005
|
+
* Reads the mode out of the node alone — the contract never stores it.
|
|
2006
|
+
*
|
|
2007
|
+
* An EMPTY `source` means "Lấy từ nguồn đã chọn, chưa pick" and stays in `source`; anything the
|
|
2008
|
+
* dropdown cannot express (a template with literal text, or text with no reference at all) is
|
|
2009
|
+
* `advanced`. A clean single reference stays in `source` even when it resolves to nothing, so a typo
|
|
2010
|
+
* is fixed in the friendly picker while validation reports `mapping.reference.missing`.
|
|
2011
|
+
*/
|
|
2012
|
+
function deriveMode(node) {
|
|
2013
|
+
if (node.source !== undefined) {
|
|
2014
|
+
if (node.source === '')
|
|
2015
|
+
return 'source';
|
|
2016
|
+
return parseSdApiContractTemplate(node.source).kind === 'exact' ? 'source' : 'advanced';
|
|
2017
|
+
}
|
|
2018
|
+
if (node.value !== undefined)
|
|
2019
|
+
return 'static';
|
|
2020
|
+
return node.type === 'object' ? 'nested' : 'none';
|
|
2021
|
+
}
|
|
2022
|
+
/** Rebuilds a node without `source` / `value`, so the key is gone rather than set to `undefined`. */
|
|
2023
|
+
function withoutMapping(node) {
|
|
2024
|
+
const next = {};
|
|
2025
|
+
for (const key of Object.keys(node)) {
|
|
2026
|
+
if (key === 'source' || key === 'value')
|
|
2027
|
+
continue;
|
|
2028
|
+
next[key] = node[key];
|
|
2029
|
+
}
|
|
2030
|
+
return next;
|
|
2031
|
+
}
|
|
2032
|
+
function defaultStatic(type) {
|
|
2033
|
+
if (type === 'number')
|
|
2034
|
+
return 0;
|
|
2035
|
+
if (type === 'boolean')
|
|
2036
|
+
return false;
|
|
2037
|
+
if (type === 'object')
|
|
2038
|
+
return {};
|
|
2039
|
+
if (type === 'array')
|
|
2040
|
+
return [];
|
|
2041
|
+
return '';
|
|
2042
|
+
}
|
|
2043
|
+
function isAssignable(sourceType, targetType) {
|
|
2044
|
+
if (sourceType === targetType)
|
|
2045
|
+
return true;
|
|
2046
|
+
const temporalish = (type) => type === 'string' || type === 'date' || type === 'datetime';
|
|
2047
|
+
return temporalish(sourceType) && temporalish(targetType);
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
/**
|
|
2051
|
+
* Editing surface for exactly one node, staged and committed in one go.
|
|
2052
|
+
*
|
|
2053
|
+
* The layer lists outside are read-only, so this drawer is the only place a node changes. It holds a
|
|
2054
|
+
* DEEP COPY of the node it was seeded from and emits `nodeCommit` once, when the author saves — that
|
|
2055
|
+
* is what makes "Huỷ" able to actually cancel, and what keeps the tree from changing under the
|
|
2056
|
+
* author's cursor on every keystroke.
|
|
2057
|
+
*
|
|
2058
|
+
* Save is blocked only for the two problems that stop a node from existing at all: no name, or a name
|
|
2059
|
+
* a sibling already holds. Everything else — a reference to a field that does not exist yet, a type
|
|
2060
|
+
* that will not fit — saves and is reported by `validateSdApiContract`, because inventing rules here
|
|
2061
|
+
* would stop an author declaring one end of a mapping before the other exists.
|
|
2062
|
+
*/
|
|
2063
|
+
class SdApiContractNodeDrawer {
|
|
2064
|
+
#i18n = inject(I18nService);
|
|
2065
|
+
/** `mapping` shows the value editor; `schema` is a plain declaration with no mapping. */
|
|
2066
|
+
layer = input('mapping');
|
|
2067
|
+
allowTransform = input(false, { transform: booleanAttribute });
|
|
2068
|
+
suggestions = input([]);
|
|
2069
|
+
autoId = input();
|
|
2070
|
+
nodeCommit = output();
|
|
2071
|
+
// why không phải ES-private: Angular từ chối `viewChild` trên field `#` (diagnostic 1053).
|
|
2072
|
+
drawerRef = viewChild.required('drawer');
|
|
2073
|
+
// 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
|
|
2074
|
+
// lần mở drawer đều bị coi là đã sửa.
|
|
2075
|
+
#seedName = '';
|
|
2076
|
+
#seedJson = '';
|
|
2077
|
+
#draftName = signal('');
|
|
2078
|
+
#draft = signal(null);
|
|
2079
|
+
#rootSiblings = signal([]);
|
|
2080
|
+
#editingName = signal(null);
|
|
2081
|
+
#discardPrompt = signal(false);
|
|
2082
|
+
// 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
|
|
2083
|
+
// 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.
|
|
2084
|
+
#pointer = signal([]);
|
|
2085
|
+
// why: tên đang gõ ở CẤP HIỆN TẠI, kể cả khi nó chưa áp được vào draft.
|
|
2086
|
+
#typedName = signal('');
|
|
2087
|
+
draftName = this.#draftName.asReadonly();
|
|
2088
|
+
discardPrompt = this.#discardPrompt.asReadonly();
|
|
2089
|
+
/** The node the author is looking at right now — the draft root, or a descendant of it. */
|
|
2090
|
+
current = computed(() => {
|
|
2091
|
+
const draft = this.#draft();
|
|
2092
|
+
if (!draft)
|
|
2093
|
+
return null;
|
|
2094
|
+
return getSdApiContractNodeAt(draft, this.#pointer());
|
|
2095
|
+
});
|
|
2096
|
+
/** Every key on the breadcrumb. The pointer alternates `properties`/key, so keys sit on odd slots. */
|
|
2097
|
+
breadcrumb = computed(() => {
|
|
2098
|
+
const pointer = this.#pointer();
|
|
2099
|
+
const keys = pointer.filter((_, index) => index % 2 === 1);
|
|
2100
|
+
return [this.#draftName() || this.#i18n.t('core.component.api-contract-builder.drawer.add-title'), ...keys];
|
|
2101
|
+
});
|
|
2102
|
+
/**
|
|
2103
|
+
* What the name field shows.
|
|
2104
|
+
*
|
|
2105
|
+
* 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
|
|
2106
|
+
* 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
|
|
2107
|
+
* thẳng từ draft thì ô input nhảy về tên cũ ngay khi gõ, tức là từ chối im lặng.
|
|
2108
|
+
*/
|
|
2109
|
+
currentName = this.#typedName.asReadonly();
|
|
2110
|
+
children = computed(() => {
|
|
2111
|
+
const node = this.current();
|
|
2112
|
+
const properties = node?.properties;
|
|
2113
|
+
if (!properties)
|
|
2114
|
+
return [];
|
|
2115
|
+
return Object.keys(properties).map(key => ({ key, node: properties[key] }));
|
|
2116
|
+
});
|
|
2117
|
+
/** Siblings on the level the author is on — the root list, or the parent object's keys. */
|
|
2118
|
+
siblingNames = computed(() => {
|
|
2119
|
+
const pointer = this.#pointer();
|
|
2120
|
+
if (pointer.length === 0)
|
|
2121
|
+
return this.#rootSiblings();
|
|
2122
|
+
const draft = this.#draft();
|
|
2123
|
+
if (!draft)
|
|
2124
|
+
return [];
|
|
2125
|
+
const parent = getSdApiContractNodeAt(draft, pointer.slice(0, -2));
|
|
2126
|
+
return parent?.properties ? Object.keys(parent.properties) : [];
|
|
2127
|
+
});
|
|
2128
|
+
typeOptions = SD_API_CONTRACT_DATA_TYPES.map(type => ({
|
|
2129
|
+
value: type,
|
|
2130
|
+
label: type,
|
|
2131
|
+
}));
|
|
2132
|
+
requiredOptions = [
|
|
2133
|
+
{ value: 'true', label: this.#i18n.t('core.component.api-contract-builder.required.true') },
|
|
2134
|
+
{ value: 'false', label: this.#i18n.t('core.component.api-contract-builder.required.false') },
|
|
2135
|
+
];
|
|
2136
|
+
transformOptions = [
|
|
2137
|
+
{ value: '', label: this.#i18n.t('core.component.api-contract-builder.transform.none') },
|
|
2138
|
+
{ value: 'ISOString', label: 'ISOString' },
|
|
2139
|
+
{ value: 'UTCString', label: 'UTCString' },
|
|
2140
|
+
];
|
|
2141
|
+
title = computed(() => this.#editingName() === null
|
|
2142
|
+
? this.#i18n.t('core.component.api-contract-builder.drawer.add-title')
|
|
2143
|
+
: this.#i18n.t('core.component.api-contract-builder.drawer.edit-title'));
|
|
2144
|
+
dirty = computed(() => {
|
|
2145
|
+
const draft = this.#draft();
|
|
2146
|
+
if (!draft)
|
|
2147
|
+
return false;
|
|
2148
|
+
return this.#draftName() !== this.#seedName || JSON.stringify(draft) !== this.#seedJson;
|
|
2149
|
+
});
|
|
2150
|
+
nameError = computed(() => {
|
|
2151
|
+
const name = this.currentName().trim();
|
|
2152
|
+
if (name === '')
|
|
2153
|
+
return this.#i18n.t('core.component.api-contract-builder.node.name-required');
|
|
2154
|
+
// 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
|
|
2155
|
+
// 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
|
|
2156
|
+
// 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.
|
|
2157
|
+
const pointer = this.#pointer();
|
|
2158
|
+
const self = pointer.length === 0 ? this.#editingName() : pointer[pointer.length - 1];
|
|
2159
|
+
const taken = this.siblingNames().some(sibling => sibling === name && sibling !== self);
|
|
2160
|
+
return taken ? this.#i18n.t('core.component.api-contract-builder.node.duplicate-key') : null;
|
|
2161
|
+
});
|
|
2162
|
+
// 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
|
|
2163
|
+
// 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ệ.
|
|
2164
|
+
canSave = computed(() => {
|
|
2165
|
+
if (this.#draft() === null)
|
|
2166
|
+
return false;
|
|
2167
|
+
if (this.#draftName().trim() === '')
|
|
2168
|
+
return false;
|
|
2169
|
+
return this.nameError() === null;
|
|
2170
|
+
});
|
|
2171
|
+
/**
|
|
2172
|
+
* `beforeClose` guard for `<sd-side-drawer>`.
|
|
2173
|
+
*
|
|
2174
|
+
* why an arrow field, not a method: the drawer takes it as an input value, so it must keep `this`
|
|
2175
|
+
* without the template having to bind it.
|
|
2176
|
+
*/
|
|
2177
|
+
closeGuard = () => this.guardClose();
|
|
2178
|
+
// -------------------------------------------------------------------------
|
|
2179
|
+
// Public surface the owning builder drives
|
|
2180
|
+
// -------------------------------------------------------------------------
|
|
2181
|
+
openForAdd(siblingNames = [], type = 'string') {
|
|
2182
|
+
this.#seed(null, createSdApiContractNode(type), siblingNames);
|
|
2183
|
+
this.drawerRef().open();
|
|
2184
|
+
}
|
|
2185
|
+
openForEdit(name, node, siblingNames = []) {
|
|
2186
|
+
this.#seed(name, cloneSdApiContractNode(node), siblingNames);
|
|
2187
|
+
this.drawerRef().open();
|
|
2188
|
+
}
|
|
2189
|
+
// -------------------------------------------------------------------------
|
|
2190
|
+
// Draft edits
|
|
2191
|
+
// -------------------------------------------------------------------------
|
|
2192
|
+
/**
|
|
2193
|
+
* Types into the name field.
|
|
2194
|
+
*
|
|
2195
|
+
* The typed text is ALWAYS kept, then applied to the draft only when it can be. A name that cannot
|
|
2196
|
+
* be applied — empty, or already taken by a sibling — stays visible and `nameError()` explains it,
|
|
2197
|
+
* which is what blocks Save. Refusing the keystroke instead would leave the field showing one name
|
|
2198
|
+
* while the contract holds another.
|
|
2199
|
+
*/
|
|
2200
|
+
setName(value) {
|
|
2201
|
+
const name = typeof value === 'string' ? value : '';
|
|
2202
|
+
this.#typedName.set(name);
|
|
2203
|
+
const pointer = this.#pointer();
|
|
2204
|
+
if (pointer.length === 0) {
|
|
2205
|
+
// 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 ở
|
|
2206
|
+
// `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.
|
|
2207
|
+
if (name.trim() !== '')
|
|
2208
|
+
this.#draftName.set(name);
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
const draft = this.#draft();
|
|
2212
|
+
const from = pointer[pointer.length - 1];
|
|
2213
|
+
if (!draft || name.trim() === '' || name === from)
|
|
2214
|
+
return;
|
|
2215
|
+
const parentPointer = pointer.slice(0, -2);
|
|
2216
|
+
const parent = getSdApiContractNodeAt(draft, parentPointer);
|
|
2217
|
+
if (!parent)
|
|
2218
|
+
return;
|
|
2219
|
+
const renamed = renameSdApiContractProperty(parent, from, name);
|
|
2220
|
+
// why: helper trả về CHÍNH object cũ khi trùng key. Không áp được thì thôi — `#typedName` đã giữ
|
|
2221
|
+
// chữ người dùng gõ và `nameError()` đã báo, nên không có gì bị nuốt im lặng.
|
|
2222
|
+
if (renamed === parent)
|
|
2223
|
+
return;
|
|
2224
|
+
this.#draft.set(setSdApiContractNodeAt(draft, parentPointer, renamed));
|
|
2225
|
+
// why dời pointer theo: nếu không, pointer vẫn trỏ key CŨ — `current()` trả null và form trắng xoá.
|
|
2226
|
+
this.#pointer.set([...parentPointer, 'properties', name]);
|
|
2227
|
+
}
|
|
2228
|
+
setType(value) {
|
|
2229
|
+
const draft = this.#draft();
|
|
2230
|
+
if (!draft || typeof value !== 'string')
|
|
2231
|
+
return;
|
|
2232
|
+
if (!SD_API_CONTRACT_DATA_TYPES.includes(value))
|
|
2233
|
+
return;
|
|
2234
|
+
this.applyCurrent(changeSdApiContractNodeType(draft, value));
|
|
2235
|
+
}
|
|
2236
|
+
setRequired(value) {
|
|
2237
|
+
const draft = this.#draft();
|
|
2238
|
+
if (!draft)
|
|
2239
|
+
return;
|
|
2240
|
+
if (value === 'true') {
|
|
2241
|
+
this.applyCurrent({ ...draft, required: true });
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2244
|
+
// why: bỏ hẳn key thay vì ghi `false` — key vắng và `false` nghĩa như nhau với consumer, và JSON
|
|
2245
|
+
// gọn hơn. Một `false` do người khác viết tay vẫn được giữ khi nạp vào.
|
|
2246
|
+
const next = { ...draft };
|
|
2247
|
+
delete next['required'];
|
|
2248
|
+
this.applyCurrent(next);
|
|
2249
|
+
}
|
|
2250
|
+
setText(key, value) {
|
|
2251
|
+
const draft = this.#draft();
|
|
2252
|
+
if (!draft)
|
|
2253
|
+
return;
|
|
2254
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
2255
|
+
const next = { ...draft };
|
|
2256
|
+
if (text === '')
|
|
2257
|
+
delete next[key];
|
|
2258
|
+
else
|
|
2259
|
+
next[key] = text;
|
|
2260
|
+
this.applyCurrent(next);
|
|
2261
|
+
}
|
|
2262
|
+
setTransform(value) {
|
|
2263
|
+
const draft = this.#draft();
|
|
2264
|
+
if (!draft)
|
|
2265
|
+
return;
|
|
2266
|
+
const next = { ...draft };
|
|
2267
|
+
if (value === 'ISOString' || value === 'UTCString')
|
|
2268
|
+
next['transform'] = value;
|
|
2269
|
+
else
|
|
2270
|
+
delete next['transform'];
|
|
2271
|
+
this.applyCurrent(next);
|
|
2272
|
+
}
|
|
2273
|
+
applyCurrent(node) {
|
|
2274
|
+
const draft = this.#draft();
|
|
2275
|
+
if (!draft)
|
|
2276
|
+
return;
|
|
2277
|
+
this.#draft.set(setSdApiContractNodeAt(draft, this.#pointer(), node));
|
|
2278
|
+
}
|
|
2279
|
+
enter(key) {
|
|
2280
|
+
this.#pointer.set([...this.#pointer(), 'properties', key]);
|
|
2281
|
+
this.#syncTypedName();
|
|
2282
|
+
this.#discardPrompt.set(false);
|
|
2283
|
+
}
|
|
2284
|
+
/** `depth` counts breadcrumb entries, so 0 is the draft root. */
|
|
2285
|
+
backTo(depth) {
|
|
2286
|
+
this.#pointer.set(this.#pointer().slice(0, depth * 2));
|
|
2287
|
+
this.#syncTypedName();
|
|
2288
|
+
this.#discardPrompt.set(false);
|
|
2289
|
+
}
|
|
2290
|
+
addChild() {
|
|
2291
|
+
const node = this.current();
|
|
2292
|
+
const draft = this.#draft();
|
|
2293
|
+
if (!node || !draft)
|
|
2294
|
+
return;
|
|
2295
|
+
const key = uniqueChildKey(node.properties ?? {});
|
|
2296
|
+
const next = addSdApiContractProperty(node, key, createSdApiContractNode('string'));
|
|
2297
|
+
this.#draft.set(setSdApiContractNodeAt(draft, this.#pointer(), next));
|
|
2298
|
+
}
|
|
2299
|
+
removeChild(key) {
|
|
2300
|
+
const node = this.current();
|
|
2301
|
+
const draft = this.#draft();
|
|
2302
|
+
if (!node || !draft)
|
|
2303
|
+
return;
|
|
2304
|
+
this.#draft.set(setSdApiContractNodeAt(draft, this.#pointer(), removeSdApiContractProperty(node, key)));
|
|
2305
|
+
}
|
|
2306
|
+
// -------------------------------------------------------------------------
|
|
2307
|
+
// Chốt sổ
|
|
2308
|
+
// -------------------------------------------------------------------------
|
|
2309
|
+
save() {
|
|
2310
|
+
const draft = this.#draft();
|
|
2311
|
+
if (!draft || !this.canSave())
|
|
2312
|
+
return;
|
|
2313
|
+
this.#discardPrompt.set(false);
|
|
2314
|
+
this.nodeCommit.emit({ name: this.#draftName().trim(), node: draft });
|
|
2315
|
+
// why: forceClose bỏ qua `beforeClose`. Save đã là chủ ý rõ ràng — hỏi lại "bỏ thay đổi?" ngay sau
|
|
2316
|
+
// khi vừa lưu là vô nghĩa.
|
|
2317
|
+
this.#markCommitted();
|
|
2318
|
+
this.drawerRef().forceClose();
|
|
2319
|
+
}
|
|
2320
|
+
requestCancel() {
|
|
2321
|
+
void this.drawerRef().requestClose();
|
|
2322
|
+
}
|
|
2323
|
+
confirmDiscard() {
|
|
2324
|
+
this.#discardPrompt.set(false);
|
|
2325
|
+
this.#markCommitted();
|
|
2326
|
+
this.drawerRef().forceClose();
|
|
2327
|
+
}
|
|
2328
|
+
cancelDiscard() {
|
|
2329
|
+
this.#discardPrompt.set(false);
|
|
2330
|
+
}
|
|
2331
|
+
/**
|
|
2332
|
+
* Blocks the close while the draft is dirty and raises an inline prompt instead.
|
|
2333
|
+
*
|
|
2334
|
+
* why inline chứ không `window.confirm`: một component thư viện không được dựng dialog của browser
|
|
2335
|
+
* — nó không style được, không test được, và chặn cả tab.
|
|
2336
|
+
*/
|
|
2337
|
+
guardClose() {
|
|
2338
|
+
if (!this.dirty())
|
|
2339
|
+
return true;
|
|
2340
|
+
this.#discardPrompt.set(true);
|
|
2341
|
+
return false;
|
|
2342
|
+
}
|
|
2343
|
+
onClosed() {
|
|
2344
|
+
this.#discardPrompt.set(false);
|
|
2345
|
+
}
|
|
2346
|
+
// -------------------------------------------------------------------------
|
|
2347
|
+
#seed(name, node, siblingNames) {
|
|
2348
|
+
this.#editingName.set(name);
|
|
2349
|
+
this.#draftName.set(name ?? '');
|
|
2350
|
+
this.#draft.set(node);
|
|
2351
|
+
this.#pointer.set([]);
|
|
2352
|
+
this.#typedName.set(name ?? '');
|
|
2353
|
+
this.#rootSiblings.set([...siblingNames]);
|
|
2354
|
+
this.#discardPrompt.set(false);
|
|
2355
|
+
this.#seedName = name ?? '';
|
|
2356
|
+
this.#seedJson = JSON.stringify(node);
|
|
2357
|
+
}
|
|
2358
|
+
/** Re-points the name field at whatever level the author just moved to. */
|
|
2359
|
+
#syncTypedName() {
|
|
2360
|
+
const pointer = this.#pointer();
|
|
2361
|
+
this.#typedName.set(pointer.length === 0 ? this.#draftName() : pointer[pointer.length - 1]);
|
|
2362
|
+
}
|
|
2363
|
+
/** Re-baselines the seed so a closing drawer is never considered dirty any more. */
|
|
2364
|
+
#markCommitted() {
|
|
2365
|
+
const draft = this.#draft();
|
|
2366
|
+
this.#seedName = this.#draftName();
|
|
2367
|
+
this.#seedJson = draft ? JSON.stringify(draft) : '';
|
|
2368
|
+
}
|
|
2369
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.21", ngImport: i0, type: SdApiContractNodeDrawer, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2370
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.21", 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: `
|
|
2371
|
+
@let _autoId = autoId();
|
|
2372
|
+
@let _current = current();
|
|
2373
|
+
@let _nameError = nameError();
|
|
2374
|
+
|
|
2375
|
+
<sd-side-drawer
|
|
2376
|
+
#drawer
|
|
2377
|
+
width="560px"
|
|
2378
|
+
disableBackdropClose
|
|
2379
|
+
[title]="title()"
|
|
2380
|
+
[autoId]="_autoId"
|
|
2381
|
+
[beforeClose]="closeGuard"
|
|
2382
|
+
(sdClosed)="onClosed()">
|
|
2383
|
+
@if (_current) {
|
|
2384
|
+
<div class="sd-acb-drawer">
|
|
2385
|
+
@let _breadcrumb = breadcrumb();
|
|
2386
|
+
@if (_breadcrumb.length > 1) {
|
|
2387
|
+
<nav class="sd-acb-drawer__crumbs" [attr.aria-label]="title()">
|
|
2388
|
+
@for (crumb of _breadcrumb; track $index) {
|
|
2389
|
+
@if ($index < _breadcrumb.length - 1) {
|
|
2390
|
+
<button
|
|
2391
|
+
type="button"
|
|
2392
|
+
class="sd-acb-drawer__crumb"
|
|
2393
|
+
[attr.data-autoid]="_autoId ? _autoId + '-crumb-' + $index : null"
|
|
2394
|
+
(click)="backTo($index)">
|
|
2395
|
+
{{ crumb }}
|
|
2396
|
+
</button>
|
|
2397
|
+
<span class="sd-acb-drawer__crumb-sep">›</span>
|
|
2398
|
+
} @else {
|
|
2399
|
+
<span class="sd-acb-drawer__crumb sd-acb-drawer__crumb--current">{{ crumb }}</span>
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
</nav>
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
<sd-input
|
|
2406
|
+
size="sm"
|
|
2407
|
+
[autoId]="_autoId ? _autoId + '-name' : undefined"
|
|
2408
|
+
[label]="'core.component.api-contract-builder.node.name' | sdTranslate"
|
|
2409
|
+
[model]="currentName()"
|
|
2410
|
+
[inlineError]="_nameError ?? undefined"
|
|
2411
|
+
(sdChange)="setName($event)"></sd-input>
|
|
2412
|
+
|
|
2413
|
+
<sd-select
|
|
2414
|
+
size="sm"
|
|
2415
|
+
hideInlineError
|
|
2416
|
+
[clearable]="false"
|
|
2417
|
+
[autoId]="_autoId ? _autoId + '-type' : undefined"
|
|
2418
|
+
[label]="'core.component.api-contract-builder.node.type' | sdTranslate"
|
|
2419
|
+
[items]="typeOptions"
|
|
2420
|
+
valueField="value"
|
|
2421
|
+
displayField="label"
|
|
2422
|
+
[model]="_current.type"
|
|
2423
|
+
(sdChange)="setType($event)"></sd-select>
|
|
2424
|
+
|
|
2425
|
+
<sd-select
|
|
2426
|
+
size="sm"
|
|
2427
|
+
hideInlineError
|
|
2428
|
+
[clearable]="false"
|
|
2429
|
+
[autoId]="_autoId ? _autoId + '-required' : undefined"
|
|
2430
|
+
[label]="'core.component.api-contract-builder.node.required' | sdTranslate"
|
|
2431
|
+
[items]="requiredOptions"
|
|
2432
|
+
valueField="value"
|
|
2433
|
+
displayField="label"
|
|
2434
|
+
[model]="_current.required === true ? 'true' : 'false'"
|
|
2435
|
+
(sdChange)="setRequired($event)"></sd-select>
|
|
2436
|
+
|
|
2437
|
+
<sd-input
|
|
2438
|
+
size="sm"
|
|
2439
|
+
hideInlineError
|
|
2440
|
+
[autoId]="_autoId ? _autoId + '-label' : undefined"
|
|
2441
|
+
[label]="'core.component.api-contract-builder.node.label' | sdTranslate"
|
|
2442
|
+
[model]="_current.label ?? ''"
|
|
2443
|
+
(sdChange)="setText('label', $event)"></sd-input>
|
|
2444
|
+
|
|
2445
|
+
<sd-input
|
|
2446
|
+
size="sm"
|
|
2447
|
+
hideInlineError
|
|
2448
|
+
[autoId]="_autoId ? _autoId + '-description' : undefined"
|
|
2449
|
+
[label]="'core.component.api-contract-builder.node.description' | sdTranslate"
|
|
2450
|
+
[model]="_current.description ?? ''"
|
|
2451
|
+
(sdChange)="setText('description', $event)"></sd-input>
|
|
2452
|
+
|
|
2453
|
+
@if (allowTransform() && (_current.type === 'date' || _current.type === 'datetime')) {
|
|
2454
|
+
<sd-select
|
|
2455
|
+
size="sm"
|
|
2456
|
+
hideInlineError
|
|
2457
|
+
[clearable]="false"
|
|
2458
|
+
[autoId]="_autoId ? _autoId + '-transform' : undefined"
|
|
2459
|
+
[label]="'core.component.api-contract-builder.node.transform' | sdTranslate"
|
|
2460
|
+
[items]="transformOptions"
|
|
2461
|
+
valueField="value"
|
|
2462
|
+
displayField="label"
|
|
2463
|
+
[model]="_current.transform ?? ''"
|
|
2464
|
+
(sdChange)="setTransform($event)"></sd-select>
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
@if (layer() === 'mapping') {
|
|
2468
|
+
<sd-api-contract-source-editor
|
|
2469
|
+
[node]="_current"
|
|
2470
|
+
[suggestions]="suggestions()"
|
|
2471
|
+
[autoId]="_autoId ? _autoId + '-value' : undefined"
|
|
2472
|
+
(nodeChange)="applyCurrent($event)"></sd-api-contract-source-editor>
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
@if (_current.type === 'object') {
|
|
2476
|
+
@let _children = children();
|
|
2477
|
+
<div class="sd-acb-drawer__children">
|
|
2478
|
+
<h5 class="sd-acb-drawer__children-title">
|
|
2479
|
+
{{ 'core.component.api-contract-builder.node.add-property' | sdTranslate }}
|
|
2480
|
+
</h5>
|
|
2481
|
+
|
|
2482
|
+
@if (!_children.length) {
|
|
2483
|
+
<p class="sd-acb-drawer__empty">{{ 'core.component.api-contract-builder.node.empty' | sdTranslate }}</p>
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
@for (child of _children; track child.key) {
|
|
2487
|
+
<sd-api-contract-node-summary
|
|
2488
|
+
[name]="child.key"
|
|
2489
|
+
[node]="child.node"
|
|
2490
|
+
[autoId]="_autoId ? _autoId + '-child-' + child.key : undefined"
|
|
2491
|
+
(edit)="enter(child.key)"
|
|
2492
|
+
(remove)="removeChild(child.key)"></sd-api-contract-node-summary>
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
<sd-button
|
|
2496
|
+
type="light"
|
|
2497
|
+
size="sm"
|
|
2498
|
+
prefixIcon="add"
|
|
2499
|
+
[autoId]="_autoId ? _autoId + '-add-child' : undefined"
|
|
2500
|
+
[title]="'core.component.api-contract-builder.node.add-property' | sdTranslate"
|
|
2501
|
+
(click)="addChild()"></sd-button>
|
|
2502
|
+
</div>
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
@if (discardPrompt()) {
|
|
2506
|
+
<div class="sd-acb-drawer__confirm" role="alertdialog">
|
|
2507
|
+
<span>{{ 'core.component.api-contract-builder.drawer.discard-confirm' | sdTranslate }}</span>
|
|
2508
|
+
<sd-button
|
|
2509
|
+
type="text"
|
|
2510
|
+
size="sm"
|
|
2511
|
+
color="error"
|
|
2512
|
+
[autoId]="_autoId ? _autoId + '-discard' : undefined"
|
|
2513
|
+
[title]="'core.component.api-contract-builder.confirm.yes' | sdTranslate"
|
|
2514
|
+
(click)="confirmDiscard()"></sd-button>
|
|
2515
|
+
<sd-button
|
|
2516
|
+
type="text"
|
|
2517
|
+
size="sm"
|
|
2518
|
+
[autoId]="_autoId ? _autoId + '-keep' : undefined"
|
|
2519
|
+
[title]="'core.component.api-contract-builder.confirm.no' | sdTranslate"
|
|
2520
|
+
(click)="cancelDiscard()"></sd-button>
|
|
2521
|
+
</div>
|
|
2522
|
+
}
|
|
2523
|
+
</div>
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
<sd-button
|
|
2527
|
+
sdFooterRight
|
|
2528
|
+
type="text"
|
|
2529
|
+
[autoId]="_autoId ? _autoId + '-cancel' : undefined"
|
|
2530
|
+
[title]="'core.component.api-contract-builder.drawer.cancel' | sdTranslate"
|
|
2531
|
+
(click)="requestCancel()"></sd-button>
|
|
2532
|
+
<sd-button
|
|
2533
|
+
sdFooterRight
|
|
2534
|
+
type="fill"
|
|
2535
|
+
color="primary"
|
|
2536
|
+
[autoId]="_autoId ? _autoId + '-save' : undefined"
|
|
2537
|
+
[disabled]="!canSave()"
|
|
2538
|
+
[title]="'core.component.api-contract-builder.drawer.save' | sdTranslate"
|
|
2539
|
+
(click)="save()"></sd-button>
|
|
2540
|
+
</sd-side-drawer>
|
|
2541
|
+
`, 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 });
|
|
2542
|
+
}
|
|
2543
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.21", ngImport: i0, type: SdApiContractNodeDrawer, decorators: [{
|
|
2544
|
+
type: Component,
|
|
2545
|
+
args: [{ selector: 'sd-api-contract-node-drawer', standalone: true, imports: [SdSideDrawer, SdButton, SdInput, SdSelect, SdApiContractNodeSummary, SdApiContractSourceEditor, SdTranslatePipe], changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
2546
|
+
@let _autoId = autoId();
|
|
2547
|
+
@let _current = current();
|
|
2548
|
+
@let _nameError = nameError();
|
|
2549
|
+
|
|
2550
|
+
<sd-side-drawer
|
|
2551
|
+
#drawer
|
|
2552
|
+
width="560px"
|
|
2553
|
+
disableBackdropClose
|
|
2554
|
+
[title]="title()"
|
|
2555
|
+
[autoId]="_autoId"
|
|
2556
|
+
[beforeClose]="closeGuard"
|
|
2557
|
+
(sdClosed)="onClosed()">
|
|
2558
|
+
@if (_current) {
|
|
2559
|
+
<div class="sd-acb-drawer">
|
|
2560
|
+
@let _breadcrumb = breadcrumb();
|
|
2561
|
+
@if (_breadcrumb.length > 1) {
|
|
2562
|
+
<nav class="sd-acb-drawer__crumbs" [attr.aria-label]="title()">
|
|
2563
|
+
@for (crumb of _breadcrumb; track $index) {
|
|
2564
|
+
@if ($index < _breadcrumb.length - 1) {
|
|
2565
|
+
<button
|
|
2566
|
+
type="button"
|
|
2567
|
+
class="sd-acb-drawer__crumb"
|
|
2568
|
+
[attr.data-autoid]="_autoId ? _autoId + '-crumb-' + $index : null"
|
|
2569
|
+
(click)="backTo($index)">
|
|
2570
|
+
{{ crumb }}
|
|
2571
|
+
</button>
|
|
2572
|
+
<span class="sd-acb-drawer__crumb-sep">›</span>
|
|
2573
|
+
} @else {
|
|
2574
|
+
<span class="sd-acb-drawer__crumb sd-acb-drawer__crumb--current">{{ crumb }}</span>
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
</nav>
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
<sd-input
|
|
2581
|
+
size="sm"
|
|
2582
|
+
[autoId]="_autoId ? _autoId + '-name' : undefined"
|
|
2583
|
+
[label]="'core.component.api-contract-builder.node.name' | sdTranslate"
|
|
2584
|
+
[model]="currentName()"
|
|
2585
|
+
[inlineError]="_nameError ?? undefined"
|
|
2586
|
+
(sdChange)="setName($event)"></sd-input>
|
|
2587
|
+
|
|
2588
|
+
<sd-select
|
|
2589
|
+
size="sm"
|
|
2590
|
+
hideInlineError
|
|
2591
|
+
[clearable]="false"
|
|
2592
|
+
[autoId]="_autoId ? _autoId + '-type' : undefined"
|
|
2593
|
+
[label]="'core.component.api-contract-builder.node.type' | sdTranslate"
|
|
2594
|
+
[items]="typeOptions"
|
|
2595
|
+
valueField="value"
|
|
2596
|
+
displayField="label"
|
|
2597
|
+
[model]="_current.type"
|
|
2598
|
+
(sdChange)="setType($event)"></sd-select>
|
|
2599
|
+
|
|
2600
|
+
<sd-select
|
|
2601
|
+
size="sm"
|
|
2602
|
+
hideInlineError
|
|
2603
|
+
[clearable]="false"
|
|
2604
|
+
[autoId]="_autoId ? _autoId + '-required' : undefined"
|
|
2605
|
+
[label]="'core.component.api-contract-builder.node.required' | sdTranslate"
|
|
2606
|
+
[items]="requiredOptions"
|
|
2607
|
+
valueField="value"
|
|
2608
|
+
displayField="label"
|
|
2609
|
+
[model]="_current.required === true ? 'true' : 'false'"
|
|
2610
|
+
(sdChange)="setRequired($event)"></sd-select>
|
|
2611
|
+
|
|
2612
|
+
<sd-input
|
|
2613
|
+
size="sm"
|
|
2614
|
+
hideInlineError
|
|
2615
|
+
[autoId]="_autoId ? _autoId + '-label' : undefined"
|
|
2616
|
+
[label]="'core.component.api-contract-builder.node.label' | sdTranslate"
|
|
2617
|
+
[model]="_current.label ?? ''"
|
|
2618
|
+
(sdChange)="setText('label', $event)"></sd-input>
|
|
2619
|
+
|
|
2620
|
+
<sd-input
|
|
2621
|
+
size="sm"
|
|
2622
|
+
hideInlineError
|
|
2623
|
+
[autoId]="_autoId ? _autoId + '-description' : undefined"
|
|
2624
|
+
[label]="'core.component.api-contract-builder.node.description' | sdTranslate"
|
|
2625
|
+
[model]="_current.description ?? ''"
|
|
2626
|
+
(sdChange)="setText('description', $event)"></sd-input>
|
|
2627
|
+
|
|
2628
|
+
@if (allowTransform() && (_current.type === 'date' || _current.type === 'datetime')) {
|
|
2629
|
+
<sd-select
|
|
2630
|
+
size="sm"
|
|
2631
|
+
hideInlineError
|
|
2632
|
+
[clearable]="false"
|
|
2633
|
+
[autoId]="_autoId ? _autoId + '-transform' : undefined"
|
|
2634
|
+
[label]="'core.component.api-contract-builder.node.transform' | sdTranslate"
|
|
2635
|
+
[items]="transformOptions"
|
|
2636
|
+
valueField="value"
|
|
2637
|
+
displayField="label"
|
|
2638
|
+
[model]="_current.transform ?? ''"
|
|
2639
|
+
(sdChange)="setTransform($event)"></sd-select>
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
@if (layer() === 'mapping') {
|
|
2643
|
+
<sd-api-contract-source-editor
|
|
2644
|
+
[node]="_current"
|
|
2645
|
+
[suggestions]="suggestions()"
|
|
2646
|
+
[autoId]="_autoId ? _autoId + '-value' : undefined"
|
|
2647
|
+
(nodeChange)="applyCurrent($event)"></sd-api-contract-source-editor>
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
@if (_current.type === 'object') {
|
|
2651
|
+
@let _children = children();
|
|
2652
|
+
<div class="sd-acb-drawer__children">
|
|
2653
|
+
<h5 class="sd-acb-drawer__children-title">
|
|
2654
|
+
{{ 'core.component.api-contract-builder.node.add-property' | sdTranslate }}
|
|
2655
|
+
</h5>
|
|
2656
|
+
|
|
2657
|
+
@if (!_children.length) {
|
|
2658
|
+
<p class="sd-acb-drawer__empty">{{ 'core.component.api-contract-builder.node.empty' | sdTranslate }}</p>
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
@for (child of _children; track child.key) {
|
|
2662
|
+
<sd-api-contract-node-summary
|
|
2663
|
+
[name]="child.key"
|
|
2664
|
+
[node]="child.node"
|
|
2665
|
+
[autoId]="_autoId ? _autoId + '-child-' + child.key : undefined"
|
|
2666
|
+
(edit)="enter(child.key)"
|
|
2667
|
+
(remove)="removeChild(child.key)"></sd-api-contract-node-summary>
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
<sd-button
|
|
2671
|
+
type="light"
|
|
2672
|
+
size="sm"
|
|
2673
|
+
prefixIcon="add"
|
|
2674
|
+
[autoId]="_autoId ? _autoId + '-add-child' : undefined"
|
|
2675
|
+
[title]="'core.component.api-contract-builder.node.add-property' | sdTranslate"
|
|
2676
|
+
(click)="addChild()"></sd-button>
|
|
2677
|
+
</div>
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
@if (discardPrompt()) {
|
|
2681
|
+
<div class="sd-acb-drawer__confirm" role="alertdialog">
|
|
2682
|
+
<span>{{ 'core.component.api-contract-builder.drawer.discard-confirm' | sdTranslate }}</span>
|
|
2683
|
+
<sd-button
|
|
2684
|
+
type="text"
|
|
2685
|
+
size="sm"
|
|
2686
|
+
color="error"
|
|
2687
|
+
[autoId]="_autoId ? _autoId + '-discard' : undefined"
|
|
2688
|
+
[title]="'core.component.api-contract-builder.confirm.yes' | sdTranslate"
|
|
2689
|
+
(click)="confirmDiscard()"></sd-button>
|
|
2690
|
+
<sd-button
|
|
2691
|
+
type="text"
|
|
2692
|
+
size="sm"
|
|
2693
|
+
[autoId]="_autoId ? _autoId + '-keep' : undefined"
|
|
2694
|
+
[title]="'core.component.api-contract-builder.confirm.no' | sdTranslate"
|
|
2695
|
+
(click)="cancelDiscard()"></sd-button>
|
|
2696
|
+
</div>
|
|
2697
|
+
}
|
|
2698
|
+
</div>
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
<sd-button
|
|
2702
|
+
sdFooterRight
|
|
2703
|
+
type="text"
|
|
2704
|
+
[autoId]="_autoId ? _autoId + '-cancel' : undefined"
|
|
2705
|
+
[title]="'core.component.api-contract-builder.drawer.cancel' | sdTranslate"
|
|
2706
|
+
(click)="requestCancel()"></sd-button>
|
|
2707
|
+
<sd-button
|
|
2708
|
+
sdFooterRight
|
|
2709
|
+
type="fill"
|
|
2710
|
+
color="primary"
|
|
2711
|
+
[autoId]="_autoId ? _autoId + '-save' : undefined"
|
|
2712
|
+
[disabled]="!canSave()"
|
|
2713
|
+
[title]="'core.component.api-contract-builder.drawer.save' | sdTranslate"
|
|
2714
|
+
(click)="save()"></sd-button>
|
|
2715
|
+
</sd-side-drawer>
|
|
2716
|
+
`, 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"] }]
|
|
2717
|
+
}] });
|
|
2718
|
+
/** First free `truong`, `truong2`, … so adding twice never collides. */
|
|
2719
|
+
function uniqueChildKey(properties) {
|
|
2720
|
+
const base = 'truong';
|
|
2721
|
+
if (!Object.prototype.hasOwnProperty.call(properties, base))
|
|
2722
|
+
return base;
|
|
2723
|
+
for (let index = 2;; index += 1) {
|
|
2724
|
+
const candidate = `${base}${index}`;
|
|
2725
|
+
if (!Object.prototype.hasOwnProperty.call(properties, candidate))
|
|
2726
|
+
return candidate;
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
/**
|
|
2731
|
+
* One schema layer — `input.schema`, `req.body`, `res.body`, `output.schema` — rendered as a flat list
|
|
2732
|
+
* of the fields it declares.
|
|
2733
|
+
*
|
|
2734
|
+
* It used to be a recursive tree of editable seven-column rows. It is now a list you read: adding and
|
|
2735
|
+
* editing ask the builder to open its drawer, and only removal is applied straight away. The list is
|
|
2736
|
+
* deliberately FLAT — an `object` field shows how many fields it holds and the author drills into it
|
|
2737
|
+
* inside the drawer, so a deeply nested node is edited with a drawer's worth of room instead of a
|
|
2738
|
+
* 100px cell.
|
|
2739
|
+
*/
|
|
2740
|
+
class SdApiContractNodeEditor {
|
|
2741
|
+
/** The layer's root node. */
|
|
2742
|
+
node = input.required();
|
|
2743
|
+
/** Diagnostic path of the layer itself, e.g. `input.schema`. */
|
|
2744
|
+
basePath = input.required();
|
|
2745
|
+
disabled = input(false, { transform: booleanAttribute });
|
|
2746
|
+
autoId = input();
|
|
2747
|
+
nodeChange = output();
|
|
2748
|
+
editRequest = output();
|
|
2749
|
+
/**
|
|
2750
|
+
* Which node actually holds the fields.
|
|
2751
|
+
*
|
|
2752
|
+
* why: một tầng `array` (`output.schema` sau khi nhận `${res.body.items}`) khai field ở `items`, chứ
|
|
2753
|
+
* 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
|
|
2754
|
+
* của contract mẫu thành không sửa được.
|
|
2755
|
+
*/
|
|
2756
|
+
#container = computed(() => {
|
|
2757
|
+
const node = this.node();
|
|
2758
|
+
return node.type === 'array' && node.items ? node.items : node;
|
|
2759
|
+
});
|
|
2760
|
+
/** `['items']` for an array layer, `[]` otherwise — the prefix a commit has to be written under. */
|
|
2761
|
+
#containerPointer = computed(() => {
|
|
2762
|
+
const node = this.node();
|
|
2763
|
+
return node.type === 'array' && node.items ? ['items'] : [];
|
|
2764
|
+
});
|
|
2765
|
+
entries = computed(() => {
|
|
2766
|
+
const properties = this.#container().properties;
|
|
2767
|
+
if (!properties)
|
|
2768
|
+
return [];
|
|
2769
|
+
return Object.keys(properties).map(key => ({ key, node: properties[key] }));
|
|
2770
|
+
});
|
|
2771
|
+
#keys = computed(() => this.entries().map(entry => entry.key));
|
|
2772
|
+
requestAdd() {
|
|
2773
|
+
this.editRequest.emit({ name: null, node: null, siblingNames: this.#keys(), pointer: this.#containerPointer() });
|
|
2774
|
+
}
|
|
2775
|
+
requestEdit(key, node) {
|
|
2776
|
+
this.editRequest.emit({ name: key, node, siblingNames: this.#keys(), pointer: this.#containerPointer() });
|
|
2777
|
+
}
|
|
2778
|
+
removeProperty(key) {
|
|
2779
|
+
const stripped = removeSdApiContractProperty(this.#container(), key);
|
|
2780
|
+
this.nodeChange.emit(setSdApiContractNodeAt(this.node(), this.#containerPointer(), stripped));
|
|
2781
|
+
}
|
|
2782
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.21", ngImport: i0, type: SdApiContractNodeEditor, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2783
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.21", 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: "pipe", type: SdTranslatePipe, name: "sdTranslate" }, { kind: "component", type: SdApiContractNodeSummary, selector: "sd-api-contract-node-summary", inputs: ["name", "node", "readonly", "autoId"], outputs: ["edit", "remove"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
2784
|
+
}
|
|
2785
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.21", ngImport: i0, type: SdApiContractNodeEditor, decorators: [{
|
|
2786
|
+
type: Component,
|
|
2787
|
+
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"] }]
|
|
2788
|
+
}] });
|
|
2789
|
+
|
|
2790
|
+
/**
|
|
2791
|
+
* One keyed collection of nodes — `req.path` / `req.query` / `req.headers` / `res.headers` — rendered
|
|
2792
|
+
* as a list you read, not a list you type into.
|
|
2793
|
+
*
|
|
2794
|
+
* Every entry is a collapsed row. Adding and editing both ask the builder to open its drawer; only
|
|
2795
|
+
* removal is applied straight away, because there is nothing to stage about deleting a row. That is
|
|
2796
|
+
* why `add()` emits an edit request rather than an entry: a half-declared field must never reach the
|
|
2797
|
+
* contract just because someone clicked the add button and changed their mind.
|
|
2798
|
+
*/
|
|
2799
|
+
class SdApiContractRecordEditor {
|
|
2800
|
+
record = input();
|
|
2801
|
+
/** Diagnostic path of the collection itself, e.g. `req.query`. */
|
|
2802
|
+
basePath = input.required();
|
|
2803
|
+
disabled = input(false, { transform: booleanAttribute });
|
|
2804
|
+
emptyLabel = input('');
|
|
2805
|
+
autoId = input();
|
|
2806
|
+
recordChange = output();
|
|
2807
|
+
editRequest = output();
|
|
2808
|
+
entries = computed(() => {
|
|
2809
|
+
const record = this.record();
|
|
2810
|
+
if (!record)
|
|
2811
|
+
return [];
|
|
2812
|
+
const base = this.basePath();
|
|
2813
|
+
return Object.keys(record).map(key => ({ key, node: record[key], path: `${base}.${key}` }));
|
|
2814
|
+
});
|
|
2815
|
+
#keys = computed(() => this.entries().map(entry => entry.key));
|
|
2816
|
+
requestAdd() {
|
|
2817
|
+
this.editRequest.emit({ name: null, node: null, siblingNames: this.#keys() });
|
|
2818
|
+
}
|
|
2819
|
+
requestEdit(entry) {
|
|
2820
|
+
this.editRequest.emit({ name: entry.key, node: entry.node, siblingNames: this.#keys() });
|
|
2821
|
+
}
|
|
2822
|
+
remove(key) {
|
|
2823
|
+
const record = this.record();
|
|
2824
|
+
if (!record)
|
|
2825
|
+
return;
|
|
2826
|
+
this.recordChange.emit(sdApiContractRecordRemove(record, key));
|
|
2827
|
+
}
|
|
2828
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.21", ngImport: i0, type: SdApiContractRecordEditor, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2829
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.21", 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: `
|
|
2830
|
+
@let _entries = entries();
|
|
2831
|
+
@let _disabled = disabled();
|
|
2832
|
+
@let _autoId = autoId();
|
|
2833
|
+
|
|
2834
|
+
<div class="sd-acb-record">
|
|
2835
|
+
@if (!_entries.length) {
|
|
2836
|
+
<p class="sd-acb-record__empty">{{ emptyLabel() }}</p>
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
@for (entry of _entries; track entry.key) {
|
|
2840
|
+
<sd-api-contract-node-summary
|
|
2841
|
+
[name]="entry.key"
|
|
2842
|
+
[node]="entry.node"
|
|
2843
|
+
[readonly]="_disabled"
|
|
2844
|
+
[autoId]="_autoId ? _autoId + '-' + entry.key : undefined"
|
|
2845
|
+
(edit)="requestEdit(entry)"
|
|
2846
|
+
(remove)="remove(entry.key)"></sd-api-contract-node-summary>
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
@if (!_disabled) {
|
|
2850
|
+
<sd-button
|
|
2851
|
+
class="sd-acb-record__add"
|
|
2852
|
+
type="light"
|
|
2853
|
+
size="sm"
|
|
2854
|
+
prefixIcon="add"
|
|
2855
|
+
[autoId]="_autoId ? _autoId + '-add' : undefined"
|
|
2856
|
+
[title]="'core.component.api-contract-builder.request.add-entry' | sdTranslate"
|
|
2857
|
+
(click)="requestAdd()"></sd-button>
|
|
2858
|
+
}
|
|
2859
|
+
</div>
|
|
2860
|
+
`, 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: "pipe", type: SdTranslatePipe, name: "sdTranslate" }, { kind: "component", type: SdApiContractNodeSummary, selector: "sd-api-contract-node-summary", inputs: ["name", "node", "readonly", "autoId"], outputs: ["edit", "remove"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
2861
|
+
}
|
|
2862
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.21", ngImport: i0, type: SdApiContractRecordEditor, decorators: [{
|
|
2863
|
+
type: Component,
|
|
2864
|
+
args: [{ selector: 'sd-api-contract-record-editor', standalone: true, imports: [SdButton, SdTranslatePipe, SdApiContractNodeSummary], changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
2865
|
+
@let _entries = entries();
|
|
2866
|
+
@let _disabled = disabled();
|
|
2867
|
+
@let _autoId = autoId();
|
|
2868
|
+
|
|
2869
|
+
<div class="sd-acb-record">
|
|
2870
|
+
@if (!_entries.length) {
|
|
2871
|
+
<p class="sd-acb-record__empty">{{ emptyLabel() }}</p>
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
@for (entry of _entries; track entry.key) {
|
|
2875
|
+
<sd-api-contract-node-summary
|
|
2876
|
+
[name]="entry.key"
|
|
2877
|
+
[node]="entry.node"
|
|
2878
|
+
[readonly]="_disabled"
|
|
2879
|
+
[autoId]="_autoId ? _autoId + '-' + entry.key : undefined"
|
|
2880
|
+
(edit)="requestEdit(entry)"
|
|
2881
|
+
(remove)="remove(entry.key)"></sd-api-contract-node-summary>
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
@if (!_disabled) {
|
|
2885
|
+
<sd-button
|
|
2886
|
+
class="sd-acb-record__add"
|
|
2887
|
+
type="light"
|
|
2888
|
+
size="sm"
|
|
2889
|
+
prefixIcon="add"
|
|
2890
|
+
[autoId]="_autoId ? _autoId + '-add' : undefined"
|
|
2891
|
+
[title]="'core.component.api-contract-builder.request.add-entry' | sdTranslate"
|
|
2892
|
+
(click)="requestAdd()"></sd-button>
|
|
2893
|
+
}
|
|
2894
|
+
</div>
|
|
2895
|
+
`, 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"] }]
|
|
2896
|
+
}] });
|
|
2897
|
+
|
|
2898
|
+
const STEP_GENERAL = 0;
|
|
2899
|
+
const STEP_INPUT = 1;
|
|
2900
|
+
const STEP_REQUEST = 2;
|
|
2901
|
+
const STEP_RESPONSE = 3;
|
|
2902
|
+
const STEP_OUTPUT = 4;
|
|
2903
|
+
const STEP_REVIEW = 5;
|
|
2904
|
+
/**
|
|
2905
|
+
* Visual builder for an `SdApiContract`.
|
|
2906
|
+
*
|
|
2907
|
+
* The component is a **design-time** tool: it never performs a request, never resolves an
|
|
2908
|
+
* expression and never holds a secret. It edits, validates and serializes the contract; executing
|
|
2909
|
+
* it is a separate concern for a future `form-builder` / `form-render` integration.
|
|
2910
|
+
*
|
|
2911
|
+
* @example
|
|
2912
|
+
* ```html
|
|
2913
|
+
* <sd-api-contract-builder [(model)]="contract" autoId="product-search"></sd-api-contract-builder>
|
|
2914
|
+
* ```
|
|
2915
|
+
*/
|
|
2916
|
+
class SdApiContractBuilder {
|
|
2917
|
+
#i18n = inject(I18nService);
|
|
2918
|
+
#configuration = resolveSdApiContractConfiguration(inject(SD_API_CONTRACT_CONFIGURATION, { optional: true }));
|
|
2919
|
+
model = model(null);
|
|
2920
|
+
mode = input('edit');
|
|
2921
|
+
disabled = input(false, { transform: booleanAttribute });
|
|
2922
|
+
autoId = input();
|
|
2923
|
+
/** Fires whenever the diagnostics change, including once for the initially seeded contract. */
|
|
2924
|
+
diagnosticsChange = output();
|
|
2925
|
+
/** Fires only when validity flips, so a consumer can gate a Save button without debouncing. */
|
|
2926
|
+
validChange = output();
|
|
2927
|
+
// 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 —
|
|
2928
|
+
// mọi thao tác dựng contract mới rồi mới `model.set`.
|
|
2929
|
+
#draft = signal(null);
|
|
2930
|
+
// 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ó,
|
|
2931
|
+
// effect seed sẽ clone lại contract ta vừa emit và tạo vòng lặp phản hồi.
|
|
2932
|
+
#lastEmitted = null;
|
|
2933
|
+
#lastValid = null;
|
|
2934
|
+
activeStep = signal(STEP_GENERAL);
|
|
2935
|
+
contractVersion = SD_API_CONTRACT_VERSION;
|
|
2936
|
+
responseFieldToken = signal(null);
|
|
2937
|
+
urlPlaceholder = '${env.baseUrl}/products/{id}';
|
|
2938
|
+
draft = this.#draft.asReadonly();
|
|
2939
|
+
readonly = computed(() => this.disabled() || this.mode() === 'view');
|
|
2940
|
+
isView = computed(() => this.mode() === 'view');
|
|
2941
|
+
steps = [
|
|
2942
|
+
{ index: STEP_GENERAL, key: 'general', label: this.#i18n.t('core.component.api-contract-builder.step.general') },
|
|
2943
|
+
{ index: STEP_INPUT, key: 'input', label: this.#i18n.t('core.component.api-contract-builder.step.input') },
|
|
2944
|
+
{ index: STEP_REQUEST, key: 'request', label: this.#i18n.t('core.component.api-contract-builder.step.request') },
|
|
2945
|
+
{ index: STEP_RESPONSE, key: 'response', label: this.#i18n.t('core.component.api-contract-builder.step.response') },
|
|
2946
|
+
{ index: STEP_OUTPUT, key: 'output', label: this.#i18n.t('core.component.api-contract-builder.step.output') },
|
|
2947
|
+
{ index: STEP_REVIEW, key: 'review', label: this.#i18n.t('core.component.api-contract-builder.step.review') },
|
|
2948
|
+
];
|
|
2949
|
+
methodOptions = SD_API_CONTRACT_HTTP_METHODS.map(method => ({ value: method, label: method }));
|
|
2950
|
+
allTypes = SD_API_CONTRACT_DATA_TYPES;
|
|
2951
|
+
scalarTypes = SD_API_CONTRACT_SCALAR_DATA_TYPES;
|
|
2952
|
+
queryTypes = [...SD_API_CONTRACT_SCALAR_DATA_TYPES, 'array'];
|
|
2953
|
+
#envSuggestions = Object.keys(this.#configuration.env).map(key => {
|
|
2954
|
+
const variable = this.#configuration.env[key];
|
|
2955
|
+
const sensitiveMark = variable.sensitive ? ` · ${this.#i18n.t('core.component.api-contract-builder.mapping.sensitive')}` : '';
|
|
2956
|
+
return {
|
|
2957
|
+
expression: formatSdApiContractExpression('env', [key]),
|
|
2958
|
+
path: `env.${key}`,
|
|
2959
|
+
root: 'env',
|
|
2960
|
+
type: variable.type,
|
|
2961
|
+
display: `env.${key} — ${variable.type}${sensitiveMark}`,
|
|
2962
|
+
};
|
|
2963
|
+
});
|
|
2964
|
+
// 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ó
|
|
2965
|
+
// mốc này thì `applyNodeCommit` không biết ghi kết quả vào `req.query` hay `res.headers`.
|
|
2966
|
+
#drawerTarget = signal(null);
|
|
2967
|
+
nodeDrawer = viewChild('nodeDrawer');
|
|
2968
|
+
drawerLayer = computed(() => this.#drawerTarget()?.section === 'input.schema' ? 'schema' : 'mapping');
|
|
2969
|
+
// why chỉ input/output schema: `transform` là thuộc tính của schema frontend. `req`/`res` mô tả HTTP,
|
|
2970
|
+
// ở đó một giá trị thời gian đã là chuỗi trên đường truyền rồi.
|
|
2971
|
+
drawerAllowsTransform = computed(() => {
|
|
2972
|
+
const section = this.#drawerTarget()?.section;
|
|
2973
|
+
return section === 'input.schema' || section === 'output.schema';
|
|
2974
|
+
});
|
|
2975
|
+
drawerSuggestions = computed(() => {
|
|
2976
|
+
const section = this.#drawerTarget()?.section;
|
|
2977
|
+
if (section === undefined)
|
|
2978
|
+
return [];
|
|
2979
|
+
if (section === 'output.schema')
|
|
2980
|
+
return this.outputSuggestions();
|
|
2981
|
+
if (section === 'res.headers' || section === 'res.body')
|
|
2982
|
+
return this.outputSuggestions();
|
|
2983
|
+
return this.requestSuggestions();
|
|
2984
|
+
});
|
|
2985
|
+
// 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ừ
|
|
2986
|
+
// `validateSdApiContract` — nó chưa bao giờ thấy contract — nên phải mang riêng rồi trộn vào
|
|
2987
|
+
// 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.
|
|
2988
|
+
#pasteError = signal(null);
|
|
2989
|
+
diagnostics = computed(() => {
|
|
2990
|
+
const draft = this.#draft();
|
|
2991
|
+
const base = draft ? validateSdApiContract(draft, this.#configuration) : [];
|
|
2992
|
+
const pasteError = this.#pasteError();
|
|
2993
|
+
return pasteError ? [pasteError, ...base] : base;
|
|
2994
|
+
});
|
|
2995
|
+
json = computed(() => serializeSdApiContract(this.#draft()));
|
|
2996
|
+
// 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 đó —
|
|
2997
|
+
// 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á.
|
|
2998
|
+
#inputSuggestions = computed(() => {
|
|
2999
|
+
const schema = this.#draft()?.input?.schema;
|
|
3000
|
+
if (!schema)
|
|
3001
|
+
return [];
|
|
3002
|
+
return listSdApiContractSchemaFields(schema, { arrays: 'stop' }).map(field => ({
|
|
3003
|
+
expression: formatSdApiContractExpression('input', field.segments),
|
|
3004
|
+
path: `input.${field.path}`,
|
|
3005
|
+
root: 'input',
|
|
3006
|
+
type: field.type,
|
|
3007
|
+
display: `input.${field.path} — ${field.type}${field.label ? ` (${field.label})` : ''}`,
|
|
3008
|
+
}));
|
|
3009
|
+
});
|
|
3010
|
+
#responseSuggestions = computed(() => {
|
|
3011
|
+
const response = this.#draft()?.res;
|
|
3012
|
+
if (!response)
|
|
3013
|
+
return [];
|
|
3014
|
+
return listSdApiContractResponseFields(response).map(field => ({
|
|
3015
|
+
expression: formatSdApiContractExpression('res', field.segments),
|
|
3016
|
+
path: `res.${field.path}`,
|
|
3017
|
+
root: 'res',
|
|
3018
|
+
type: field.type,
|
|
3019
|
+
display: `res.${field.path} — ${field.type}`,
|
|
3020
|
+
}));
|
|
3021
|
+
});
|
|
3022
|
+
requestSuggestions = computed(() => [...this.#inputSuggestions(), ...this.#envSuggestions]);
|
|
3023
|
+
outputSuggestions = computed(() => [
|
|
3024
|
+
...this.#responseSuggestions(),
|
|
3025
|
+
...this.#inputSuggestions(),
|
|
3026
|
+
...this.#envSuggestions,
|
|
3027
|
+
]);
|
|
3028
|
+
/** Response paths offered by the "use a response field as the output" action. */
|
|
3029
|
+
responseFieldOptions = computed(() => {
|
|
3030
|
+
const response = this.#draft()?.res;
|
|
3031
|
+
if (!response)
|
|
3032
|
+
return [];
|
|
3033
|
+
return listSdApiContractResponseFields(response).map(field => ({ value: field.path, label: `${field.path} — ${field.type}` }));
|
|
3034
|
+
});
|
|
3035
|
+
/** Leaf fields a dropdown / table consumer will see once this contract runs. */
|
|
3036
|
+
outputFields = computed(() => {
|
|
3037
|
+
const schema = this.#draft()?.output?.schema;
|
|
3038
|
+
if (!schema)
|
|
3039
|
+
return [];
|
|
3040
|
+
return listSdApiContractSchemaFields(schema).filter(field => field.leaf);
|
|
3041
|
+
});
|
|
3042
|
+
statusText = computed(() => {
|
|
3043
|
+
const status = this.#draft()?.res?.status;
|
|
3044
|
+
if (status === undefined)
|
|
3045
|
+
return '';
|
|
3046
|
+
return Array.isArray(status) ? status.join(', ') : String(status);
|
|
3047
|
+
});
|
|
3048
|
+
get autoIdAttr() {
|
|
3049
|
+
return this.autoId() ?? null;
|
|
3050
|
+
}
|
|
3051
|
+
constructor() {
|
|
3052
|
+
effect(() => {
|
|
3053
|
+
const external = this.model();
|
|
3054
|
+
untracked(() => {
|
|
3055
|
+
if (external === this.#lastEmitted)
|
|
3056
|
+
return;
|
|
3057
|
+
// 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.
|
|
3058
|
+
// 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.
|
|
3059
|
+
this.#draft.set(external ? cloneSdApiContract(external) : null);
|
|
3060
|
+
});
|
|
3061
|
+
});
|
|
3062
|
+
effect(() => {
|
|
3063
|
+
const diagnostics = this.diagnostics();
|
|
3064
|
+
untracked(() => {
|
|
3065
|
+
this.diagnosticsChange.emit(diagnostics);
|
|
3066
|
+
const valid = !diagnostics.some(diagnostic => diagnostic.severity === 'error');
|
|
3067
|
+
if (valid !== this.#lastValid) {
|
|
3068
|
+
this.#lastValid = valid;
|
|
3069
|
+
this.validChange.emit(valid);
|
|
3070
|
+
}
|
|
3071
|
+
});
|
|
3072
|
+
});
|
|
3073
|
+
}
|
|
3074
|
+
// -------------------------------------------------------------------------
|
|
3075
|
+
// Navigation
|
|
3076
|
+
// -------------------------------------------------------------------------
|
|
3077
|
+
goToStep(index) {
|
|
3078
|
+
this.activeStep.set(index);
|
|
3079
|
+
}
|
|
3080
|
+
goToDiagnostic(diagnostic) {
|
|
3081
|
+
this.activeStep.set(stepForPath(diagnostic.path));
|
|
3082
|
+
}
|
|
3083
|
+
// -------------------------------------------------------------------------
|
|
3084
|
+
// Contract-level edits
|
|
3085
|
+
// -------------------------------------------------------------------------
|
|
3086
|
+
createContract() {
|
|
3087
|
+
this.#commit({
|
|
3088
|
+
contractVersion: SD_API_CONTRACT_VERSION,
|
|
3089
|
+
code: '',
|
|
3090
|
+
name: '',
|
|
3091
|
+
input: { schema: { type: 'object', properties: {} } },
|
|
3092
|
+
req: { method: 'GET', url: '' },
|
|
3093
|
+
res: { status: 200 },
|
|
3094
|
+
output: { schema: { type: 'object', properties: {} } },
|
|
3095
|
+
});
|
|
3096
|
+
}
|
|
3097
|
+
setText(key, value) {
|
|
3098
|
+
const draft = this.#draft();
|
|
3099
|
+
if (!draft)
|
|
3100
|
+
return;
|
|
3101
|
+
const text = typeof value === 'string' ? value : '';
|
|
3102
|
+
if (key === 'description' && !text) {
|
|
3103
|
+
const { description: _dropped, ...rest } = draft;
|
|
3104
|
+
this.#commit(rest);
|
|
3105
|
+
return;
|
|
3106
|
+
}
|
|
3107
|
+
this.#commit({ ...draft, [key]: text });
|
|
3108
|
+
}
|
|
3109
|
+
setInputSchema(node) {
|
|
3110
|
+
const draft = this.#draft();
|
|
3111
|
+
if (!draft)
|
|
3112
|
+
return;
|
|
3113
|
+
this.#commit({ ...draft, input: { schema: node } });
|
|
3114
|
+
}
|
|
3115
|
+
setMethod(value) {
|
|
3116
|
+
const draft = this.#draft();
|
|
3117
|
+
if (!draft || typeof value !== 'string')
|
|
3118
|
+
return;
|
|
3119
|
+
this.#commit({ ...draft, req: { ...draft.req, method: value } });
|
|
3120
|
+
}
|
|
3121
|
+
setUrl(value) {
|
|
3122
|
+
const draft = this.#draft();
|
|
3123
|
+
if (!draft)
|
|
3124
|
+
return;
|
|
3125
|
+
this.#commit({ ...draft, req: { ...draft.req, url: typeof value === 'string' ? value : '' } });
|
|
3126
|
+
}
|
|
3127
|
+
// -------------------------------------------------------------------------
|
|
3128
|
+
// Drawer
|
|
3129
|
+
// -------------------------------------------------------------------------
|
|
3130
|
+
/**
|
|
3131
|
+
* Opens the one drawer for whichever list asked.
|
|
3132
|
+
*
|
|
3133
|
+
* 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
|
|
3134
|
+
* biết `mode`/`disabled` thật. Hai lớp gác rẻ hơn một đường mở drawer lọt trong chế độ xem.
|
|
3135
|
+
*/
|
|
3136
|
+
openNodeDrawer(section, request) {
|
|
3137
|
+
if (this.readonly())
|
|
3138
|
+
return;
|
|
3139
|
+
this.#drawerTarget.set({ section, request });
|
|
3140
|
+
const drawer = this.nodeDrawer();
|
|
3141
|
+
if (!drawer)
|
|
3142
|
+
return;
|
|
3143
|
+
if (request.name === null || request.node === null)
|
|
3144
|
+
drawer.openForAdd(request.siblingNames);
|
|
3145
|
+
else
|
|
3146
|
+
drawer.openForEdit(request.name, request.node, request.siblingNames);
|
|
3147
|
+
}
|
|
3148
|
+
/**
|
|
3149
|
+
* Writes a committed node back where it came from — one `modelChange`, at Save time.
|
|
3150
|
+
*
|
|
3151
|
+
* A rename goes through `sdApiContractRecordRename` first so the entry keeps its position in the
|
|
3152
|
+
* JSON instead of jumping to the end. The drawer has already refused a duplicate name, so the
|
|
3153
|
+
* rename cannot collide by the time it reaches here.
|
|
3154
|
+
*/
|
|
3155
|
+
applyNodeCommit(commit) {
|
|
3156
|
+
const target = this.#drawerTarget();
|
|
3157
|
+
const draft = this.#draft();
|
|
3158
|
+
this.#drawerTarget.set(null);
|
|
3159
|
+
if (!target || !draft)
|
|
3160
|
+
return;
|
|
3161
|
+
const previous = target.request.name;
|
|
3162
|
+
const pointer = target.request.pointer ?? [];
|
|
3163
|
+
switch (target.section) {
|
|
3164
|
+
case 'req.path':
|
|
3165
|
+
case 'req.query':
|
|
3166
|
+
case 'req.headers': {
|
|
3167
|
+
const key = target.section.slice('req.'.length);
|
|
3168
|
+
const record = (draft.req?.[key] ?? {});
|
|
3169
|
+
this.setRequestRecord(key, commitIntoRecord(record, previous, commit));
|
|
3170
|
+
return;
|
|
3171
|
+
}
|
|
3172
|
+
case 'res.headers': {
|
|
3173
|
+
const record = (draft.res?.headers ?? {});
|
|
3174
|
+
this.setResponseHeaders(commitIntoRecord(record, previous, commit));
|
|
3175
|
+
return;
|
|
3176
|
+
}
|
|
3177
|
+
case 'input.schema': {
|
|
3178
|
+
const root = draft.input?.schema;
|
|
3179
|
+
if (!root)
|
|
3180
|
+
return;
|
|
3181
|
+
this.setInputSchema(commitIntoProperties(root, pointer, previous, commit));
|
|
3182
|
+
return;
|
|
3183
|
+
}
|
|
3184
|
+
case 'req.body': {
|
|
3185
|
+
const root = draft.req?.body;
|
|
3186
|
+
if (!root)
|
|
3187
|
+
return;
|
|
3188
|
+
this.setRequestBody(commitIntoProperties(root, pointer, previous, commit));
|
|
3189
|
+
return;
|
|
3190
|
+
}
|
|
3191
|
+
case 'res.body': {
|
|
3192
|
+
const root = draft.res?.body;
|
|
3193
|
+
if (!root)
|
|
3194
|
+
return;
|
|
3195
|
+
this.setResponseBody(commitIntoProperties(root, pointer, previous, commit));
|
|
3196
|
+
return;
|
|
3197
|
+
}
|
|
3198
|
+
case 'output.schema': {
|
|
3199
|
+
const root = draft.output?.schema;
|
|
3200
|
+
if (!root)
|
|
3201
|
+
return;
|
|
3202
|
+
this.setOutputSchema(commitIntoProperties(root, pointer, previous, commit));
|
|
3203
|
+
return;
|
|
3204
|
+
}
|
|
3205
|
+
default:
|
|
3206
|
+
return;
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
setRequestRecord(section, record) {
|
|
3210
|
+
const draft = this.#draft();
|
|
3211
|
+
if (!draft)
|
|
3212
|
+
return;
|
|
3213
|
+
this.#commit({ ...draft, req: { ...draft.req, [section]: record } });
|
|
3214
|
+
}
|
|
3215
|
+
setRequestBody(node) {
|
|
3216
|
+
const draft = this.#draft();
|
|
3217
|
+
if (!draft)
|
|
3218
|
+
return;
|
|
3219
|
+
this.#commit({ ...draft, req: { ...draft.req, body: node } });
|
|
3220
|
+
}
|
|
3221
|
+
addRequestBody() {
|
|
3222
|
+
this.setRequestBody(createSdApiContractNode('object'));
|
|
3223
|
+
}
|
|
3224
|
+
removeRequestBody() {
|
|
3225
|
+
const draft = this.#draft();
|
|
3226
|
+
if (!draft)
|
|
3227
|
+
return;
|
|
3228
|
+
const { body: _dropped, ...req } = draft.req;
|
|
3229
|
+
this.#commit({ ...draft, req });
|
|
3230
|
+
}
|
|
3231
|
+
setStatus(value) {
|
|
3232
|
+
const draft = this.#draft();
|
|
3233
|
+
if (!draft)
|
|
3234
|
+
return;
|
|
3235
|
+
const parts = String(value ?? '')
|
|
3236
|
+
.split(',')
|
|
3237
|
+
.map(part => part.trim())
|
|
3238
|
+
.filter(part => part.length > 0)
|
|
3239
|
+
.map(part => Number(part));
|
|
3240
|
+
// why: KHÔNG bỏ giá trị không phải số — validator phải báo `res.status.invalid` cho người dùng
|
|
3241
|
+
// thấy, im lặng nuốt đi là "âm thầm sửa contract sai".
|
|
3242
|
+
const status = parts.length === 1 ? parts[0] : parts;
|
|
3243
|
+
this.#commit({ ...draft, res: { ...draft.res, status } });
|
|
3244
|
+
}
|
|
3245
|
+
setResponseHeaders(record) {
|
|
3246
|
+
const draft = this.#draft();
|
|
3247
|
+
if (!draft)
|
|
3248
|
+
return;
|
|
3249
|
+
this.#commit({ ...draft, res: { ...draft.res, headers: record } });
|
|
3250
|
+
}
|
|
3251
|
+
setResponseBody(node) {
|
|
3252
|
+
const draft = this.#draft();
|
|
3253
|
+
if (!draft)
|
|
3254
|
+
return;
|
|
3255
|
+
this.#commit({ ...draft, res: { ...draft.res, body: node } });
|
|
3256
|
+
}
|
|
3257
|
+
addResponseBody() {
|
|
3258
|
+
this.setResponseBody(createSdApiContractNode('object'));
|
|
3259
|
+
}
|
|
3260
|
+
removeResponseBody() {
|
|
3261
|
+
const draft = this.#draft();
|
|
3262
|
+
if (!draft)
|
|
3263
|
+
return;
|
|
3264
|
+
const { body: _dropped, ...res } = draft.res;
|
|
3265
|
+
this.#commit({ ...draft, res });
|
|
3266
|
+
}
|
|
3267
|
+
setOutputSchema(node) {
|
|
3268
|
+
const draft = this.#draft();
|
|
3269
|
+
if (!draft)
|
|
3270
|
+
return;
|
|
3271
|
+
this.#commit({ ...draft, output: { schema: node } });
|
|
3272
|
+
}
|
|
3273
|
+
/**
|
|
3274
|
+
* Adopts a response subtree as the output schema.
|
|
3275
|
+
*
|
|
3276
|
+
* The subtree is **deep-copied**, never referenced: editing the output afterwards must not reach
|
|
3277
|
+
* back into the response declaration. An array or scalar target takes a whole-node `source`; an
|
|
3278
|
+
* object target keeps its shape and each branch gets its own `source`, because an object with both
|
|
3279
|
+
* a whole-node source and child mappings is invalid by design.
|
|
3280
|
+
*/
|
|
3281
|
+
useResponseFieldAsOutput(value) {
|
|
3282
|
+
const draft = this.#draft();
|
|
3283
|
+
if (!draft || typeof value !== 'string' || !value)
|
|
3284
|
+
return;
|
|
3285
|
+
const segments = value.split('.');
|
|
3286
|
+
const resolved = resolveSdApiContractResponsePath(draft.res, segments);
|
|
3287
|
+
this.responseFieldToken.set(null);
|
|
3288
|
+
if (!resolved)
|
|
3289
|
+
return;
|
|
3290
|
+
const schema = resolved.node
|
|
3291
|
+
? adoptResponseNode(resolved.node, segments)
|
|
3292
|
+
: { type: resolved.type, source: formatSdApiContractExpression('res', segments) };
|
|
3293
|
+
this.setOutputSchema(schema);
|
|
3294
|
+
}
|
|
3295
|
+
/**
|
|
3296
|
+
* Adopts a contract pasted into the review editor.
|
|
3297
|
+
*
|
|
3298
|
+
* `<sd-code-editor language="json">` emits the PARSED value when the text is valid JSON and the
|
|
3299
|
+
* raw STRING while it is still half-typed. A string therefore means "not parseable yet": keep the
|
|
3300
|
+
* current draft and report it, because replacing a contract with a fragment of text would destroy
|
|
3301
|
+
* the author's work on a keystroke.
|
|
3302
|
+
*
|
|
3303
|
+
* A parseable object is adopted VERBATIM — no field is added, removed or repaired. Whatever is
|
|
3304
|
+
* wrong with it surfaces through `validateSdApiContract`, which is the whole point of pasting.
|
|
3305
|
+
*/
|
|
3306
|
+
applyPastedJson(value) {
|
|
3307
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
3308
|
+
this.#pasteError.set({
|
|
3309
|
+
code: 'contract.invalid',
|
|
3310
|
+
severity: 'error',
|
|
3311
|
+
path: '',
|
|
3312
|
+
message: 'The pasted text is not valid JSON. The contract was left unchanged.',
|
|
3313
|
+
});
|
|
3314
|
+
return;
|
|
3315
|
+
}
|
|
3316
|
+
this.#pasteError.set(null);
|
|
3317
|
+
this.#commit(cloneSdApiContract(value));
|
|
3318
|
+
}
|
|
3319
|
+
#commit(next) {
|
|
3320
|
+
this.#lastEmitted = next;
|
|
3321
|
+
this.#draft.set(next);
|
|
3322
|
+
this.model.set(next);
|
|
3323
|
+
}
|
|
3324
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.21", ngImport: i0, type: SdApiContractBuilder, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
3325
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.21", 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: "pipe", type: SdTranslatePipe, name: "sdTranslate" }, { 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"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3326
|
+
}
|
|
3327
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.21", ngImport: i0, type: SdApiContractBuilder, decorators: [{
|
|
3328
|
+
type: Component,
|
|
3329
|
+
args: [{ selector: 'sd-api-contract-builder', standalone: true, imports: [
|
|
3330
|
+
SdButton,
|
|
3331
|
+
SdCodeEditor,
|
|
3332
|
+
SdIcon,
|
|
3333
|
+
SdInput,
|
|
3334
|
+
SdSelect,
|
|
3335
|
+
SdTranslatePipe,
|
|
3336
|
+
SdApiContractDiagnosticList,
|
|
3337
|
+
SdApiContractNodeEditor,
|
|
3338
|
+
SdApiContractNodeDrawer,
|
|
3339
|
+
SdApiContractRecordEditor,
|
|
3340
|
+
], 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"] }]
|
|
3341
|
+
}], ctorParameters: () => [], propDecorators: { autoIdAttr: [{
|
|
3342
|
+
type: HostBinding,
|
|
3343
|
+
args: ['attr.data-autoId']
|
|
3344
|
+
}] } });
|
|
3345
|
+
/**
|
|
3346
|
+
* Places a committed node back under a schema root's `properties`.
|
|
3347
|
+
*
|
|
3348
|
+
* why phân biệt add với overwrite: `addSdApiContractProperty` CỐ Ý không ghi đè key đã tồn tại (nó bảo
|
|
3349
|
+
* caller phải tự dedupe). Sửa một field đang có thì phải đi qua `setSdApiContractNodeAt`, nếu không
|
|
3350
|
+
* mọi lần Lưu trên field cũ sẽ im lặng không có tác dụng.
|
|
3351
|
+
*/
|
|
3352
|
+
function commitIntoProperties(root, pointer, previous, commit) {
|
|
3353
|
+
// why đi qua pointer: danh sách nói cho ta biết nó đang đọc `properties` hay `items.properties`.
|
|
3354
|
+
// 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.
|
|
3355
|
+
const container = getSdApiContractNodeAt(root, pointer);
|
|
3356
|
+
if (!container)
|
|
3357
|
+
return root;
|
|
3358
|
+
let next = container;
|
|
3359
|
+
if (previous !== null && previous !== commit.name) {
|
|
3360
|
+
next = renameSdApiContractProperty(next, previous, commit.name);
|
|
3361
|
+
}
|
|
3362
|
+
const exists = next.properties && Object.prototype.hasOwnProperty.call(next.properties, commit.name);
|
|
3363
|
+
next = exists
|
|
3364
|
+
? setSdApiContractNodeAt(next, ['properties', commit.name], commit.node)
|
|
3365
|
+
: addSdApiContractProperty(next, commit.name, commit.node);
|
|
3366
|
+
return setSdApiContractNodeAt(root, pointer, next);
|
|
3367
|
+
}
|
|
3368
|
+
/** Places a committed node back into a keyed record, keeping the author's key order. */
|
|
3369
|
+
function commitIntoRecord(record, previous, commit) {
|
|
3370
|
+
if (previous !== null && previous !== commit.name) {
|
|
3371
|
+
const renamed = sdApiContractRecordRename(record, previous, commit.name);
|
|
3372
|
+
return sdApiContractRecordSet(renamed, commit.name, commit.node);
|
|
3373
|
+
}
|
|
3374
|
+
return sdApiContractRecordSet(record, commit.name, commit.node);
|
|
3375
|
+
}
|
|
3376
|
+
/** Recursively rebuilds a response subtree as a mapped output subtree. */
|
|
3377
|
+
function adoptResponseNode(node, segments) {
|
|
3378
|
+
if (node.type === 'object' && node.properties) {
|
|
3379
|
+
const properties = {};
|
|
3380
|
+
for (const key of Object.keys(node.properties)) {
|
|
3381
|
+
properties[key] = adoptResponseNode(node.properties[key], [...segments, key]);
|
|
3382
|
+
}
|
|
3383
|
+
const next = { type: 'object', properties };
|
|
3384
|
+
if (node.required !== undefined)
|
|
3385
|
+
next.required = node.required;
|
|
3386
|
+
if (node.label !== undefined)
|
|
3387
|
+
next.label = node.label;
|
|
3388
|
+
if (node.description !== undefined)
|
|
3389
|
+
next.description = node.description;
|
|
3390
|
+
return next;
|
|
3391
|
+
}
|
|
3392
|
+
return { ...cloneSdApiContractNode(node), source: formatSdApiContractExpression('res', segments) };
|
|
3393
|
+
}
|
|
3394
|
+
function stepForPath(path) {
|
|
3395
|
+
if (path.startsWith('input.'))
|
|
3396
|
+
return STEP_INPUT;
|
|
3397
|
+
if (path.startsWith('req'))
|
|
3398
|
+
return STEP_REQUEST;
|
|
3399
|
+
if (path.startsWith('res'))
|
|
3400
|
+
return STEP_RESPONSE;
|
|
3401
|
+
if (path.startsWith('output'))
|
|
3402
|
+
return STEP_OUTPUT;
|
|
3403
|
+
return STEP_GENERAL;
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
/**
|
|
3407
|
+
* Generated bundle index. Do not edit.
|
|
3408
|
+
*/
|
|
3409
|
+
|
|
3410
|
+
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 };
|
|
3411
|
+
//# sourceMappingURL=sdcorejs-angular-components-api-contract-builder.mjs.map
|