@rytass/bpm-core-client 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/eslint.config.mjs +19 -0
- package/jest.config.cts +10 -0
- package/package.json +3 -4
- package/project.json +66 -0
- package/src/lib/api-auth-client.ts +182 -0
- package/src/lib/client-config.ts +99 -0
- package/src/lib/form/form-api.spec.ts +81 -0
- package/src/lib/form/form-api.ts +402 -0
- package/src/lib/form/form-rendering.ts +580 -0
- package/src/lib/graphql-client.spec.ts +65 -0
- package/src/lib/graphql-client.ts +128 -0
- package/src/lib/member-api.ts +129 -0
- package/src/lib/organization/organization-api.spec.ts +90 -0
- package/src/lib/organization/organization-api.ts +711 -0
- package/src/lib/template/template-api.spec.ts +88 -0
- package/src/lib/template/template-api.ts +894 -0
- package/src/lib/workflow/workflow-api.spec.ts +132 -0
- package/src/lib/workflow/workflow-api.ts +2124 -0
- package/tsconfig.json +27 -0
- package/tsconfig.lib.json +16 -0
- package/tsconfig.spec.json +16 -0
- package/src/index.js +0 -8
- package/src/index.js.map +0 -1
- package/src/lib/api-auth-client.d.ts +0 -73
- package/src/lib/api-auth-client.js +0 -129
- package/src/lib/api-auth-client.js.map +0 -1
- package/src/lib/client-config.d.ts +0 -81
- package/src/lib/client-config.js +0 -63
- package/src/lib/client-config.js.map +0 -1
- package/src/lib/form/form-api.d.ts +0 -54
- package/src/lib/form/form-api.js +0 -240
- package/src/lib/form/form-api.js.map +0 -1
- package/src/lib/form/form-rendering.d.ts +0 -52
- package/src/lib/form/form-rendering.js +0 -341
- package/src/lib/form/form-rendering.js.map +0 -1
- package/src/lib/form/index.js +0 -6
- package/src/lib/form/index.js.map +0 -1
- package/src/lib/graphql-client.d.ts +0 -45
- package/src/lib/graphql-client.js +0 -101
- package/src/lib/graphql-client.js.map +0 -1
- package/src/lib/member-api.d.ts +0 -49
- package/src/lib/member-api.js +0 -72
- package/src/lib/member-api.js.map +0 -1
- package/src/lib/organization/index.js +0 -5
- package/src/lib/organization/index.js.map +0 -1
- package/src/lib/organization/organization-api.d.ts +0 -173
- package/src/lib/organization/organization-api.js +0 -394
- package/src/lib/organization/organization-api.js.map +0 -1
- package/src/lib/template/index.js +0 -5
- package/src/lib/template/index.js.map +0 -1
- package/src/lib/template/template-api.d.ts +0 -174
- package/src/lib/template/template-api.js +0 -469
- package/src/lib/template/template-api.js.map +0 -1
- package/src/lib/workflow/index.js +0 -5
- package/src/lib/workflow/index.js.map +0 -1
- package/src/lib/workflow/workflow-api.d.ts +0 -517
- package/src/lib/workflow/workflow-api.js +0 -1068
- package/src/lib/workflow/workflow-api.js.map +0 -1
- /package/src/{index.d.ts → index.ts} +0 -0
- /package/src/lib/form/{index.d.ts → index.ts} +0 -0
- /package/src/lib/organization/{index.d.ts → index.ts} +0 -0
- /package/src/lib/template/{index.d.ts → index.ts} +0 -0
- /package/src/lib/workflow/{index.d.ts → index.ts} +0 -0
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DateFieldDefinition,
|
|
3
|
+
FormDefinitionSchema,
|
|
4
|
+
FormFieldDefinition,
|
|
5
|
+
FormFieldOption,
|
|
6
|
+
FormFieldValue,
|
|
7
|
+
FormUiSchema,
|
|
8
|
+
NumberFieldDefinition,
|
|
9
|
+
SelectFieldDefinition,
|
|
10
|
+
} from '@rytass/bpm-core-shared/form';
|
|
11
|
+
|
|
12
|
+
export type ConditionOperator =
|
|
13
|
+
| 'equals'
|
|
14
|
+
| 'greaterThan'
|
|
15
|
+
| 'greaterThanOrEqual'
|
|
16
|
+
| 'lessThan'
|
|
17
|
+
| 'lessThanOrEqual'
|
|
18
|
+
| 'notEquals';
|
|
19
|
+
|
|
20
|
+
export type ParsedConditionRule = Readonly<{
|
|
21
|
+
fieldKey: string;
|
|
22
|
+
operator: ConditionOperator;
|
|
23
|
+
value: string;
|
|
24
|
+
}>;
|
|
25
|
+
|
|
26
|
+
export type FormRendererValues = Readonly<
|
|
27
|
+
Record<string, FormFieldValue | undefined>
|
|
28
|
+
>;
|
|
29
|
+
|
|
30
|
+
export interface FormRendererValidationResult {
|
|
31
|
+
readonly errors: Readonly<Record<string, string>>;
|
|
32
|
+
readonly firstInvalidFieldKey: string | null;
|
|
33
|
+
readonly valid: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildFormRendererValues(
|
|
37
|
+
fields: readonly FormFieldDefinition[],
|
|
38
|
+
currentValues: FormRendererValues,
|
|
39
|
+
): FormRendererValues {
|
|
40
|
+
return fields.reduce<FormRendererValues>(
|
|
41
|
+
(values, field) => ({
|
|
42
|
+
...values,
|
|
43
|
+
[field.fieldKey]:
|
|
44
|
+
typeof currentValues[field.fieldKey] === 'undefined'
|
|
45
|
+
? readInitialFormRendererValue(field)
|
|
46
|
+
: currentValues[field.fieldKey],
|
|
47
|
+
}),
|
|
48
|
+
{},
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function readVisibleFormRendererFields(
|
|
53
|
+
schema: FormDefinitionSchema,
|
|
54
|
+
uiSchema: FormUiSchema,
|
|
55
|
+
values: FormRendererValues,
|
|
56
|
+
): readonly FormFieldDefinition[] {
|
|
57
|
+
const fieldsByKey = new Map(
|
|
58
|
+
schema.fields.map((field) => [field.fieldKey, field]),
|
|
59
|
+
);
|
|
60
|
+
const orderedFields = [
|
|
61
|
+
...uiSchema.layout
|
|
62
|
+
.map((item) => fieldsByKey.get(item.fieldKey))
|
|
63
|
+
.filter((field): field is FormFieldDefinition => Boolean(field)),
|
|
64
|
+
...schema.fields.filter(
|
|
65
|
+
(field) =>
|
|
66
|
+
!uiSchema.layout.some((item) => item.fieldKey === field.fieldKey),
|
|
67
|
+
),
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
return orderedFields.filter((field) =>
|
|
71
|
+
isFormRendererFieldVisible(field, schema.fields, values),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function validateFormRendererValues({
|
|
76
|
+
schema,
|
|
77
|
+
uiSchema,
|
|
78
|
+
values,
|
|
79
|
+
}: {
|
|
80
|
+
readonly schema: FormDefinitionSchema;
|
|
81
|
+
readonly uiSchema: FormUiSchema;
|
|
82
|
+
readonly values: FormRendererValues;
|
|
83
|
+
}): FormRendererValidationResult {
|
|
84
|
+
const visibleFields = readVisibleFormRendererFields(schema, uiSchema, values);
|
|
85
|
+
const errors = visibleFields.reduce<Readonly<Record<string, string>>>(
|
|
86
|
+
(currentErrors, field) =>
|
|
87
|
+
isFormRendererFieldRequired(field, schema.fields, values) &&
|
|
88
|
+
!isFormRendererFieldValuePresent(values[field.fieldKey])
|
|
89
|
+
? {
|
|
90
|
+
...currentErrors,
|
|
91
|
+
[field.fieldKey]: `${field.label || field.fieldKey}為必填欄位。`,
|
|
92
|
+
}
|
|
93
|
+
: currentErrors,
|
|
94
|
+
{},
|
|
95
|
+
);
|
|
96
|
+
const firstInvalidFieldKey = visibleFields.find(
|
|
97
|
+
(field) => errors[field.fieldKey],
|
|
98
|
+
)?.fieldKey;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
errors,
|
|
102
|
+
firstInvalidFieldKey: firstInvalidFieldKey ?? null,
|
|
103
|
+
valid: Object.keys(errors).length === 0,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function focusFormRendererField(fieldKey: string): void {
|
|
108
|
+
const fieldElement = document.querySelector<HTMLElement>(
|
|
109
|
+
`[data-form-field-key="${CSS.escape(fieldKey)}"]`,
|
|
110
|
+
);
|
|
111
|
+
const focusableElement = fieldElement?.querySelector<HTMLElement>(
|
|
112
|
+
'input, textarea, button, [tabindex]:not([tabindex="-1"])',
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
focusableElement?.focus();
|
|
116
|
+
fieldElement?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function isFormRendererFieldVisible(
|
|
120
|
+
field: FormFieldDefinition,
|
|
121
|
+
fields: readonly FormFieldDefinition[],
|
|
122
|
+
values: FormRendererValues,
|
|
123
|
+
): boolean {
|
|
124
|
+
return field.visibleWhen
|
|
125
|
+
? evaluateConditionExpression(field.visibleWhen, fields, values, true)
|
|
126
|
+
: true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function isFormRendererFieldRequired(
|
|
130
|
+
field: FormFieldDefinition,
|
|
131
|
+
fields: readonly FormFieldDefinition[],
|
|
132
|
+
values: FormRendererValues,
|
|
133
|
+
): boolean {
|
|
134
|
+
return (
|
|
135
|
+
field.required ||
|
|
136
|
+
Boolean(
|
|
137
|
+
field.requiredWhen
|
|
138
|
+
? evaluateConditionExpression(field.requiredWhen, fields, values, false)
|
|
139
|
+
: false,
|
|
140
|
+
)
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function isFormRendererFieldReadonly(
|
|
145
|
+
field: FormFieldDefinition,
|
|
146
|
+
fields: readonly FormFieldDefinition[],
|
|
147
|
+
values: FormRendererValues,
|
|
148
|
+
): boolean {
|
|
149
|
+
return field.readonlyWhen
|
|
150
|
+
? evaluateConditionExpression(field.readonlyWhen, fields, values, false)
|
|
151
|
+
: false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function evaluateConditionExpression(
|
|
155
|
+
expression: string,
|
|
156
|
+
fields: readonly FormFieldDefinition[],
|
|
157
|
+
values: FormRendererValues,
|
|
158
|
+
fallback: boolean,
|
|
159
|
+
): boolean {
|
|
160
|
+
const rule = parseConditionRule(expression);
|
|
161
|
+
|
|
162
|
+
if (!rule) {
|
|
163
|
+
return fallback;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const field = fields.find(
|
|
167
|
+
(candidate) => candidate.fieldKey === rule.fieldKey,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (!field) {
|
|
171
|
+
return fallback;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return evaluateConditionRule(rule, field, values[field.fieldKey]);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function parseConditionRule(
|
|
178
|
+
expression: string,
|
|
179
|
+
): ParsedConditionRule | null {
|
|
180
|
+
const match = expression
|
|
181
|
+
.trim()
|
|
182
|
+
.match(/^form\.([A-Za-z][A-Za-z0-9_]*)\s*(==|!=|>=|<=|>|<)\s*(.+)$/u);
|
|
183
|
+
|
|
184
|
+
if (!match) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const operator = readConditionOperatorFromSymbol(match[2]);
|
|
189
|
+
|
|
190
|
+
if (!operator) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
fieldKey: match[1],
|
|
196
|
+
operator,
|
|
197
|
+
value: parseConditionLiteral(match[3]),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function buildConditionExpression(
|
|
202
|
+
field: FormFieldDefinition,
|
|
203
|
+
operator: ConditionOperator,
|
|
204
|
+
value: string,
|
|
205
|
+
): string {
|
|
206
|
+
return `form.${field.fieldKey} ${readConditionOperatorSymbol(operator)} ${formatConditionLiteral(field, value)}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function readDefaultConditionOperator(
|
|
210
|
+
field: FormFieldDefinition,
|
|
211
|
+
): ConditionOperator {
|
|
212
|
+
return isComparableConditionField(field) ? 'greaterThan' : 'equals';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function readDefaultConditionValue(field: FormFieldDefinition): string {
|
|
216
|
+
if (field.type === 'boolean') {
|
|
217
|
+
return 'true';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (isSelectFieldDefinition(field)) {
|
|
221
|
+
return field.options[0]?.value ?? '';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return '';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function readConditionOperatorOptions(
|
|
228
|
+
field: FormFieldDefinition,
|
|
229
|
+
): readonly { readonly id: ConditionOperator; readonly name: string }[] {
|
|
230
|
+
const operatorIds: readonly ConditionOperator[] = isComparableConditionField(
|
|
231
|
+
field,
|
|
232
|
+
)
|
|
233
|
+
? [
|
|
234
|
+
'equals',
|
|
235
|
+
'notEquals',
|
|
236
|
+
'greaterThan',
|
|
237
|
+
'greaterThanOrEqual',
|
|
238
|
+
'lessThan',
|
|
239
|
+
'lessThanOrEqual',
|
|
240
|
+
]
|
|
241
|
+
: ['equals', 'notEquals'];
|
|
242
|
+
|
|
243
|
+
return CONDITION_OPERATOR_OPTIONS.filter((option) =>
|
|
244
|
+
operatorIds.includes(option.id),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function readConditionOperatorOption(
|
|
249
|
+
value: string | undefined,
|
|
250
|
+
): ConditionOperator | null {
|
|
251
|
+
return CONDITION_OPERATOR_OPTIONS.some((option) => option.id === value)
|
|
252
|
+
? (value as ConditionOperator)
|
|
253
|
+
: null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function readDatePickerValue(
|
|
257
|
+
value: FormFieldValue | string | undefined,
|
|
258
|
+
): string | undefined {
|
|
259
|
+
return typeof value === 'string' && value.trim() ? value : undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function formatDatePickerValue(
|
|
263
|
+
value: string | undefined,
|
|
264
|
+
): string | undefined {
|
|
265
|
+
const date = value ? parseDatePickerValue(value) : null;
|
|
266
|
+
|
|
267
|
+
return date ? formatDateParts(date) : undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function formatDateTimePickerValue(
|
|
271
|
+
value: string | undefined,
|
|
272
|
+
): string | undefined {
|
|
273
|
+
const date = value ? parseDatePickerValue(value) : null;
|
|
274
|
+
|
|
275
|
+
return date ? date.toISOString() : undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function isNumberFieldDefinition(
|
|
279
|
+
field: FormFieldDefinition,
|
|
280
|
+
): field is NumberFieldDefinition {
|
|
281
|
+
return field.type === 'number' || field.type === 'money';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function isDateFieldDefinition(
|
|
285
|
+
field: FormFieldDefinition,
|
|
286
|
+
): field is DateFieldDefinition {
|
|
287
|
+
return field.type === 'date' || field.type === 'datetime';
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function isSelectFieldDefinition(
|
|
291
|
+
field: FormFieldDefinition,
|
|
292
|
+
): field is SelectFieldDefinition {
|
|
293
|
+
return (
|
|
294
|
+
field.type === 'select' ||
|
|
295
|
+
field.type === 'radio' ||
|
|
296
|
+
field.type === 'checkbox'
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function readSelectOption<TOption extends { readonly id: string }>(
|
|
301
|
+
options: readonly TOption[],
|
|
302
|
+
id: string,
|
|
303
|
+
): TOption | null {
|
|
304
|
+
return options.find((option) => option.id === id) ?? null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function readFieldOptionAsSelectOption(option: FormFieldOption): {
|
|
308
|
+
readonly id: string;
|
|
309
|
+
readonly name: string;
|
|
310
|
+
} {
|
|
311
|
+
return {
|
|
312
|
+
id: option.value,
|
|
313
|
+
name: option.label,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function parseOptionalNumberInput(value: string): number | undefined {
|
|
318
|
+
const trimmedValue = value.trim();
|
|
319
|
+
|
|
320
|
+
if (!trimmedValue) {
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const nextValue = Number(trimmedValue);
|
|
325
|
+
|
|
326
|
+
return Number.isFinite(nextValue) ? nextValue : undefined;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function clampOptionalNumber(
|
|
330
|
+
value: number | undefined,
|
|
331
|
+
range: {
|
|
332
|
+
readonly max?: number;
|
|
333
|
+
readonly min?: number;
|
|
334
|
+
},
|
|
335
|
+
): number | undefined {
|
|
336
|
+
if (typeof value === 'undefined') {
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (typeof range.min === 'number' && value < range.min) {
|
|
341
|
+
return range.min;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (typeof range.max === 'number' && value > range.max) {
|
|
345
|
+
return range.max;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return value;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const CONDITION_OPERATOR_OPTIONS: readonly {
|
|
352
|
+
readonly id: ConditionOperator;
|
|
353
|
+
readonly name: string;
|
|
354
|
+
}[] = [
|
|
355
|
+
{ id: 'equals', name: '等於' },
|
|
356
|
+
{ id: 'notEquals', name: '不等於' },
|
|
357
|
+
{ id: 'greaterThan', name: '大於' },
|
|
358
|
+
{ id: 'greaterThanOrEqual', name: '大於等於' },
|
|
359
|
+
{ id: 'lessThan', name: '小於' },
|
|
360
|
+
{ id: 'lessThanOrEqual', name: '小於等於' },
|
|
361
|
+
];
|
|
362
|
+
|
|
363
|
+
function readInitialFormRendererValue(
|
|
364
|
+
field: FormFieldDefinition,
|
|
365
|
+
): FormFieldValue | undefined {
|
|
366
|
+
if (typeof field.defaultValue !== 'undefined') {
|
|
367
|
+
return field.defaultValue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (field.type === 'boolean') {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (field.type === 'checkbox' || field.type === 'file_upload') {
|
|
375
|
+
return [];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function isFormRendererFieldValuePresent(
|
|
382
|
+
value: FormFieldValue | undefined,
|
|
383
|
+
): boolean {
|
|
384
|
+
if (typeof value === 'undefined' || value === null) {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (typeof value === 'string') {
|
|
389
|
+
return value.trim().length > 0;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (Array.isArray(value)) {
|
|
393
|
+
return value.length > 0;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function evaluateConditionRule(
|
|
400
|
+
rule: ParsedConditionRule,
|
|
401
|
+
field: FormFieldDefinition,
|
|
402
|
+
value: FormFieldValue | undefined,
|
|
403
|
+
): boolean {
|
|
404
|
+
if (Array.isArray(value)) {
|
|
405
|
+
return evaluateArrayCondition(value, rule);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (field.type === 'boolean') {
|
|
409
|
+
return compareConditionValues(
|
|
410
|
+
value === true ? 'true' : 'false',
|
|
411
|
+
rule.value,
|
|
412
|
+
rule.operator,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (isNumberFieldDefinition(field)) {
|
|
417
|
+
return compareNumericCondition(value, rule);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return compareConditionValues(
|
|
421
|
+
typeof value === 'undefined' || value === null ? '' : String(value),
|
|
422
|
+
rule.value,
|
|
423
|
+
rule.operator,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function evaluateArrayCondition(
|
|
428
|
+
value: readonly string[],
|
|
429
|
+
rule: ParsedConditionRule,
|
|
430
|
+
): boolean {
|
|
431
|
+
if (rule.operator === 'equals') {
|
|
432
|
+
return value.includes(rule.value);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (rule.operator === 'notEquals') {
|
|
436
|
+
return !value.includes(rule.value);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function compareNumericCondition(
|
|
443
|
+
value: FormFieldValue | undefined,
|
|
444
|
+
rule: ParsedConditionRule,
|
|
445
|
+
): boolean {
|
|
446
|
+
const actualValue = typeof value === 'number' ? value : Number(value);
|
|
447
|
+
const expectedValue = Number(rule.value);
|
|
448
|
+
|
|
449
|
+
if (!Number.isFinite(actualValue) || !Number.isFinite(expectedValue)) {
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return compareConditionValues(actualValue, expectedValue, rule.operator);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function compareConditionValues(
|
|
457
|
+
actualValue: number | string,
|
|
458
|
+
expectedValue: number | string,
|
|
459
|
+
operator: ConditionOperator,
|
|
460
|
+
): boolean {
|
|
461
|
+
if (operator === 'equals') {
|
|
462
|
+
return actualValue === expectedValue;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (operator === 'notEquals') {
|
|
466
|
+
return actualValue !== expectedValue;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (operator === 'greaterThan') {
|
|
470
|
+
return actualValue > expectedValue;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (operator === 'greaterThanOrEqual') {
|
|
474
|
+
return actualValue >= expectedValue;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (operator === 'lessThan') {
|
|
478
|
+
return actualValue < expectedValue;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return actualValue <= expectedValue;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function readConditionOperatorFromSymbol(
|
|
485
|
+
symbol: string | undefined,
|
|
486
|
+
): ConditionOperator | null {
|
|
487
|
+
const operators: Readonly<Record<string, ConditionOperator>> = {
|
|
488
|
+
'!=': 'notEquals',
|
|
489
|
+
'<': 'lessThan',
|
|
490
|
+
'<=': 'lessThanOrEqual',
|
|
491
|
+
'==': 'equals',
|
|
492
|
+
'>': 'greaterThan',
|
|
493
|
+
'>=': 'greaterThanOrEqual',
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
return symbol ? (operators[symbol] ?? null) : null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function readConditionOperatorSymbol(operator: ConditionOperator): string {
|
|
500
|
+
const symbols: Readonly<Record<ConditionOperator, string>> = {
|
|
501
|
+
equals: '==',
|
|
502
|
+
greaterThan: '>',
|
|
503
|
+
greaterThanOrEqual: '>=',
|
|
504
|
+
lessThan: '<',
|
|
505
|
+
lessThanOrEqual: '<=',
|
|
506
|
+
notEquals: '!=',
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
return symbols[operator];
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function parseConditionLiteral(value: string): string {
|
|
513
|
+
const trimmedValue = value.trim();
|
|
514
|
+
|
|
515
|
+
if (
|
|
516
|
+
(trimmedValue.startsWith('"') && trimmedValue.endsWith('"')) ||
|
|
517
|
+
(trimmedValue.startsWith("'") && trimmedValue.endsWith("'"))
|
|
518
|
+
) {
|
|
519
|
+
return trimmedValue.slice(1, -1);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return trimmedValue;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function formatConditionLiteral(
|
|
526
|
+
field: FormFieldDefinition,
|
|
527
|
+
value: string,
|
|
528
|
+
): string {
|
|
529
|
+
if (field.type === 'boolean') {
|
|
530
|
+
return value === 'false' ? 'false' : 'true';
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (isNumberFieldDefinition(field)) {
|
|
534
|
+
return String(parseOptionalNumberInput(value) ?? 0);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return JSON.stringify(value);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function isComparableConditionField(field: FormFieldDefinition): boolean {
|
|
541
|
+
return (
|
|
542
|
+
field.type === 'date' ||
|
|
543
|
+
field.type === 'datetime' ||
|
|
544
|
+
field.type === 'money' ||
|
|
545
|
+
field.type === 'number'
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const DATE_VALUE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u;
|
|
550
|
+
const DATE_TIME_VALUE_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/u;
|
|
551
|
+
|
|
552
|
+
function parseDatePickerValue(value: string): Date | null {
|
|
553
|
+
if (DATE_TIME_VALUE_PATTERN.test(value)) {
|
|
554
|
+
const [datePart = '', timePart = '00:00'] = value.split('T');
|
|
555
|
+
const [year = 0, month = 1, day = 1] = datePart.split('-').map(Number);
|
|
556
|
+
const [hour = 0, minute = 0] = timePart.split(':').map(Number);
|
|
557
|
+
|
|
558
|
+
return new Date(year, month - 1, day, hour, minute);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (DATE_VALUE_PATTERN.test(value)) {
|
|
562
|
+
const [year = 0, month = 1, day = 1] = value.split('-').map(Number);
|
|
563
|
+
|
|
564
|
+
return new Date(year, month - 1, day);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const date = new Date(value);
|
|
568
|
+
|
|
569
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function formatDateParts(date: Date): string {
|
|
573
|
+
return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(
|
|
574
|
+
date.getDate(),
|
|
575
|
+
)}`;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function padDatePart(value: number): string {
|
|
579
|
+
return String(value).padStart(2, '0');
|
|
580
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readGraphQlEndpoint,
|
|
3
|
+
resolveDefaultGraphQlEndpoint,
|
|
4
|
+
} from './graphql-client';
|
|
5
|
+
import {
|
|
6
|
+
readApiBaseUrl,
|
|
7
|
+
resolveApiBaseUrlFromGraphQlEndpoint,
|
|
8
|
+
} from './api-auth-client';
|
|
9
|
+
|
|
10
|
+
describe('client API endpoint resolution', () => {
|
|
11
|
+
const originalNextPublicApiUrl = process.env.NEXT_PUBLIC_API_URL;
|
|
12
|
+
const originalNextPublicApiAuthUrl = process.env.NEXT_PUBLIC_API_AUTH_URL;
|
|
13
|
+
|
|
14
|
+
afterEach((): void => {
|
|
15
|
+
restoreEnvValue('NEXT_PUBLIC_API_URL', originalNextPublicApiUrl);
|
|
16
|
+
restoreEnvValue('NEXT_PUBLIC_API_AUTH_URL', originalNextPublicApiAuthUrl);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('uses the local API when the browser is on localhost', (): void => {
|
|
20
|
+
expect(resolveDefaultGraphQlEndpoint('localhost')).toBe(
|
|
21
|
+
'http://localhost:17603/graphql',
|
|
22
|
+
);
|
|
23
|
+
expect(resolveDefaultGraphQlEndpoint('127.0.0.1')).toBe(
|
|
24
|
+
'http://localhost:17603/graphql',
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('uses same-origin API paths for deployed hostnames', (): void => {
|
|
29
|
+
expect(resolveDefaultGraphQlEndpoint('bpm-core-staging.rytass.info')).toBe(
|
|
30
|
+
'/graphql',
|
|
31
|
+
);
|
|
32
|
+
expect(resolveDefaultGraphQlEndpoint(null)).toBe('/graphql');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('derives API auth URL from same-origin GraphQL URL', (): void => {
|
|
36
|
+
expect(resolveApiBaseUrlFromGraphQlEndpoint('/graphql')).toBe('');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('derives API auth URL from explicit absolute GraphQL URL', (): void => {
|
|
40
|
+
process.env.NEXT_PUBLIC_API_URL = 'https://example.test/graphql';
|
|
41
|
+
delete process.env.NEXT_PUBLIC_API_AUTH_URL;
|
|
42
|
+
|
|
43
|
+
expect(readApiBaseUrl()).toBe('https://example.test');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('prefers explicit endpoint configuration', (): void => {
|
|
47
|
+
process.env.NEXT_PUBLIC_API_URL = 'https://api.example.test/graphql';
|
|
48
|
+
process.env.NEXT_PUBLIC_API_AUTH_URL = 'https://auth.example.test/';
|
|
49
|
+
|
|
50
|
+
expect(readGraphQlEndpoint()).toBe('https://api.example.test/graphql');
|
|
51
|
+
expect(readApiBaseUrl()).toBe('https://auth.example.test');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function restoreEnvValue(
|
|
56
|
+
key: 'NEXT_PUBLIC_API_AUTH_URL' | 'NEXT_PUBLIC_API_URL',
|
|
57
|
+
value: string | undefined,
|
|
58
|
+
): void {
|
|
59
|
+
if (value === undefined) {
|
|
60
|
+
delete process.env[key];
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
process.env[key] = value;
|
|
65
|
+
}
|