Skip to content

Commit 2426a21

Browse files
dominikkaderapatriksimekCopilot
authored
feat(validator): add support for filter type (#20)
We're adding support for the `filter` type of field, which was missing until now. --------- Co-authored-by: Patrik Simek <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent bbeed51 commit 2426a21

File tree

11 files changed

+586
-5
lines changed

11 files changed

+586
-5
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ const result = await validateFormanWithDomains(
185185
- email → string
186186
- file → string
187187
- filename → string
188+
- filter → array
188189
- folder → string
189190
- hidden → string
190191
- hook → number

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@makehq/forman-schema",
3-
"version": "1.5.0",
3+
"version": "1.6.0",
44
"description": "Forman Schema Tools",
55
"license": "MIT",
66
"author": "Make",

src/forman.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { JSONSchema7 } from 'json-schema';
1+
import type { JSONSchema7, JSONSchema7Definition } from 'json-schema';
22
import type {
33
FormanSchemaField,
44
FormanSchemaValue,
@@ -7,7 +7,16 @@ import type {
77
FormanSchemaOption,
88
FormanSchemaOptionGroup,
99
} from './types';
10-
import { noEmpty, isObject, isOptionGroup, normalizeFormanFieldType, isVisualType } from './utils';
10+
import {
11+
noEmpty,
12+
isObject,
13+
isOptionGroup,
14+
normalizeFormanFieldType,
15+
isVisualType,
16+
IML_UNARY_FILTER_OPERATORS,
17+
IML_BINARY_FILTER_OPERATORS,
18+
IML_FILTER_ENTRY_TYPES,
19+
} from './utils';
1120

1221
/**
1322
* Context for schema conversion operations
@@ -93,6 +102,7 @@ const FORMAN_TYPE_MAP: Readonly<Record<string, JSONSchema7['type']>> = {
93102
email: 'string',
94103
filename: 'string',
95104
file: 'string',
105+
filter: 'array',
96106
folder: 'string',
97107
hidden: undefined,
98108
integer: 'number',
@@ -198,6 +208,8 @@ export function toJSONSchemaInternal(
198208
case 'file':
199209
case 'folder':
200210
return handleSelectType(normalizedField, result, context);
211+
case 'filter':
212+
return handleFilterType(normalizedField, result, context);
201213
default:
202214
return handlePrimitiveType(normalizedField, result);
203215
}
@@ -311,6 +323,54 @@ function handleArrayType(field: FormanSchemaField, result: JSONSchema7, context:
311323
return result;
312324
}
313325

326+
/**
327+
* Handles filter type conversion
328+
* @param field The field to convert
329+
* @param result The prepared JSON Schema field
330+
* @param context The context for the conversion
331+
* @returns The converted JSON Schema field
332+
*/
333+
function handleFilterType(field: FormanSchemaField, result: JSONSchema7, context: ConversionContext): JSONSchema7 {
334+
const filterItems: JSONSchema7Definition = {
335+
oneOf: [
336+
{
337+
type: 'object',
338+
properties: {
339+
a: { type: IML_FILTER_ENTRY_TYPES },
340+
o: { enum: IML_UNARY_FILTER_OPERATORS },
341+
},
342+
required: ['a', 'o'],
343+
},
344+
{
345+
type: 'object',
346+
properties: {
347+
a: { type: IML_FILTER_ENTRY_TYPES },
348+
b: { type: IML_FILTER_ENTRY_TYPES },
349+
o: { enum: IML_BINARY_FILTER_OPERATORS },
350+
},
351+
required: ['a', 'b', 'o'],
352+
},
353+
],
354+
};
355+
const logic = field.logic ?? 'default';
356+
357+
result.items = ['and', 'or'].includes(logic)
358+
? filterItems
359+
: {
360+
type: 'array',
361+
items: filterItems,
362+
};
363+
// Store this to the JSON Schema to allow safe conversion back to the Forman Schema
364+
Object.defineProperty(result, 'x-filter', {
365+
configurable: true,
366+
enumerable: true,
367+
writable: true,
368+
value: logic,
369+
});
370+
371+
return result;
372+
}
373+
314374
/**
315375
* Handles select type conversion
316376
* @param field The field to convert

src/json.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,17 @@ export function toFormanSchema(field: JSONSchema7): FormanSchemaField {
4747
spec,
4848
};
4949
case 'array':
50+
// If the array is flagged as filter field root, then short-circuit to it
51+
const filterLogic = Object.getOwnPropertyDescriptor(field, 'x-filter');
52+
if (filterLogic) {
53+
return {
54+
type: 'filter',
55+
label: noEmpty(field.title),
56+
help: noEmpty(field.description),
57+
logic: filterLogic.value === 'default' ? undefined : filterLogic.value,
58+
};
59+
}
60+
5061
// For arrays, create an array type with spec from items
5162
const items = field.items && isObject<JSONSchema7>(field.items) ? field.items : undefined;
5263

src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type FormanSchemaFieldType =
2323
| 'email'
2424
| 'filename'
2525
| 'file'
26+
| 'filter'
2627
| 'folder'
2728
| 'hidden'
2829
| 'integer'
@@ -119,6 +120,8 @@ export type FormanSchemaField = {
119120
sort?: string;
120121
/** Adds an extra button to the field which opens an extra form. When the form is submitted, a specified RPC is called and the result is set as a new value of the parameter. */
121122
rpc?: FormanSchemaRPCButton;
123+
/** Definition of boolean logic to apply (filter only) */
124+
logic?: 'and' | 'or' | 'reverse';
122125
} & Record<`x-${string}`, unknown>;
123126

124127
/**

src/utils.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,79 @@ export function buildRestoreStructure(
226226

227227
return result;
228228
}
229+
230+
/**
231+
* Constants for IML filter entry types
232+
*/
233+
export const IML_FILTER_ENTRY_TYPES = ['null' as const, 'boolean' as const, 'number' as const, 'string' as const];
234+
235+
/**
236+
* Constants for unary IML filter operators
237+
*/
238+
export const IML_UNARY_FILTER_OPERATORS = ['exist' as const, 'notexist' as const];
239+
240+
/**
241+
* Constants for binary IML filter operators
242+
*/
243+
export const IML_BINARY_FILTER_OPERATORS = [
244+
'text:equal' as const,
245+
'text:equal:ci' as const,
246+
'text:notequal' as const,
247+
'text:notequal:ci' as const,
248+
'text:contain' as const,
249+
'text:contain:ci' as const,
250+
'text:notcontain' as const,
251+
'text:notcontain:ci' as const,
252+
'text:startwith' as const,
253+
'text:startwith:ci' as const,
254+
'text:notstartwith' as const,
255+
'text:notstartwith:ci' as const,
256+
'text:endwith' as const,
257+
'text:endwith:ci' as const,
258+
'text:notendwith' as const,
259+
'text:notendwith:ci' as const,
260+
'text:pattern' as const,
261+
'text:pattern:ci' as const,
262+
'text:notpattern' as const,
263+
'text:notpattern:ci' as const,
264+
'number:equal' as const,
265+
'number:notequal' as const,
266+
'number:greater' as const,
267+
'number:less' as const,
268+
'number:greaterorequal' as const,
269+
'number:lessorequal' as const,
270+
'date:equal' as const,
271+
'date:notequal' as const,
272+
'date:greater' as const,
273+
'date:less' as const,
274+
'date:greaterorequal' as const,
275+
'date:lessorequal' as const,
276+
'time:equal' as const,
277+
'time:notequal' as const,
278+
'time:greater' as const,
279+
'time:less' as const,
280+
'time:greaterorequal' as const,
281+
'time:lessorequal' as const,
282+
'semver:equal' as const,
283+
'semver:notequal' as const,
284+
'semver:greater' as const,
285+
'semver:less' as const,
286+
'semver:greaterorequal' as const,
287+
'semver:lessorequal' as const,
288+
'array:contain' as const,
289+
'array:contain:ci' as const,
290+
'array:notcontain' as const,
291+
'array:notcontain:ci' as const,
292+
'array:equal' as const,
293+
'array:notequal' as const,
294+
'array:greater' as const,
295+
'array:less' as const,
296+
'array:greaterorequal' as const,
297+
'array:lessorequal' as const,
298+
'boolean:equal' as const,
299+
'boolean:notequal' as const,
300+
];
301+
/**
302+
* Constants for all IML filter operators
303+
*/
304+
export const IML_FILTER_OPERATORS = [...IML_UNARY_FILTER_OPERATORS, ...IML_BINARY_FILTER_OPERATORS];

src/validator.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
normalizeFormanFieldType,
1818
isVisualType,
1919
isReferenceType,
20+
IML_FILTER_OPERATORS,
2021
} from './utils';
2122

2223
/**
@@ -88,6 +89,7 @@ const FORMAN_TYPE_MAP: Readonly<Record<string, string | undefined>> = {
8889
email: 'string',
8990
filename: 'string',
9091
file: 'string',
92+
filter: 'array',
9193
folder: 'string',
9294
hidden: undefined,
9395
integer: 'number',
@@ -297,6 +299,8 @@ async function validateFormanValue(
297299
case 'file':
298300
case 'folder':
299301
return handleSelectType(value, normalizedField, context);
302+
case 'filter':
303+
return handleFilterType(value, normalizedField, context);
300304
default:
301305
return handlePrimitiveType(value, normalizedField, context);
302306
}
@@ -459,6 +463,54 @@ async function handleArrayType(
459463
};
460464
}
461465

466+
/**
467+
* Handles filter type validation
468+
* @param value Value to validate
469+
* @param field Forman Field Definition
470+
* @param context The context for the validation
471+
* @returns Validation result
472+
*/
473+
async function handleFilterType(value: unknown, field: FormanSchemaField, context: ValidationContext) {
474+
// The filter is technically just an array or arrays, or an array. Craft the inline definition and pass to array validator instead.
475+
const filterEntry: FormanSchemaField = {
476+
type: 'collection',
477+
spec: [
478+
{
479+
name: 'a',
480+
type: 'any',
481+
required: true,
482+
},
483+
{
484+
name: 'b',
485+
type: 'any',
486+
},
487+
{
488+
name: 'o',
489+
type: 'text',
490+
validate: {
491+
enum: IML_FILTER_OPERATORS,
492+
},
493+
},
494+
],
495+
};
496+
497+
const inlineSchema: FormanSchemaField = ['and', 'or'].includes(field.logic ?? 'default')
498+
? {
499+
name: field.name,
500+
type: 'array',
501+
spec: filterEntry,
502+
}
503+
: {
504+
name: field.name,
505+
type: 'array',
506+
spec: {
507+
type: 'array',
508+
spec: filterEntry,
509+
},
510+
};
511+
return handleArrayType(value as unknown[], inlineSchema, context);
512+
}
513+
462514
/**
463515
* Handles select type validation
464516
* @param field The field to convert

test/mocks/forman.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,5 +125,22 @@
125125
"name": "dynamicCollection",
126126
"type": "dynamicCollection",
127127
"label": "Dynamic Collection"
128+
},
129+
{
130+
"name": "filter",
131+
"type": "filter",
132+
"label": "Filter"
133+
},
134+
{
135+
"name": "flatFilter",
136+
"type": "filter",
137+
"logic": "and",
138+
"label": "Flat Filter"
139+
},
140+
{
141+
"name": "reversedFilter",
142+
"type": "filter",
143+
"logic": "reverse",
144+
"label": "Reversed Filter"
128145
}
129146
]

0 commit comments

Comments
 (0)