Skip to content

Commit 9de8b40

Browse files
Vojtěch Václav Portešvojtechportes
authored andcommitted
feat: Added usage limit into the text mode
1 parent 3a56230 commit 9de8b40

2 files changed

Lines changed: 109 additions & 7 deletions

File tree

src/builder/builder.test.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,6 +1218,40 @@ describe('#components/Builder', () => {
12181218
);
12191219
});
12201220

1221+
it('Reports usageLimit violations as text mode semantic diagnostics', () => {
1222+
const onChange = jest.fn();
1223+
const limitedFields: IBuilderFieldProps[] = [
1224+
{
1225+
field: 'MOCK_FIELD',
1226+
label: 'Mock Field',
1227+
type: 'TEXT',
1228+
operators: ['EQUAL'],
1229+
usageLimit: {
1230+
max: 1,
1231+
scope: 'global',
1232+
},
1233+
},
1234+
];
1235+
const { container } = render(
1236+
<Builder
1237+
fields={limitedFields}
1238+
data={[]}
1239+
textMode
1240+
defaultMode="text"
1241+
onChange={onChange}
1242+
/>
1243+
);
1244+
1245+
fireEvent.change(getByDataTest(container, 'TextModeEditor'), {
1246+
target: { value: "MOCK_FIELD = 'alpha' AND MOCK_FIELD = 'beta'" },
1247+
});
1248+
1249+
expect(getByDataTest(container, 'TextModeError').textContent).toContain(
1250+
'at most 1 times in this scope'
1251+
);
1252+
expect(onChange).not.toHaveBeenCalled();
1253+
});
1254+
12211255
it('Parses valid SQL text back into builder data', async () => {
12221256
const onChange = jest.fn();
12231257
const { container } = render(

src/builder/text-mode/utils/validate-builder-sql-text-semantics.ts

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
import { IStrings } from '../../../constants/strings';
2+
import { ParsedNode } from '../../../query-formats/sql/sql-token.types';
3+
import { getBuilderValidationMessage } from '../../../utils/validation/get-builder-validation-message.util';
24
import { getValidationString } from '../../../utils/validation/get-validation-string.util';
35
import { IBuilderFieldProps } from '../../types';
6+
import {
7+
resolveBuilderFieldUsageLimitKey,
8+
resolveBuilderFieldUsageLimitScope,
9+
} from '../../utils/resolve-builder-field-usage.util';
410
import { ITextModeDiagnostic } from '../types/text-mode-diagnostic';
5-
import { ParsedNode } from '../../../query-formats/sql/sql-token.types';
6-
import { collectParsedSqlRules } from '../../../query-formats/sql/utils/collect-parsed-sql-rules';
711
import { resolveFieldAllowedValues } from './resolve-field-allowed-values';
812

13+
interface IParsedRuleEntry {
14+
parentScopeId: string;
15+
rule: Exclude<ParsedNode, { kind: 'group' }>;
16+
}
17+
918
const createFieldNotFoundDiagnostic = (
1019
fieldName: string,
1120
start: number,
@@ -56,15 +65,55 @@ const createOperatorNotAllowedDiagnostic = (
5665
end,
5766
});
5867

68+
const createUsageLimitExceededDiagnostic = (
69+
field: IBuilderFieldProps,
70+
start: number,
71+
end: number,
72+
strings: IStrings
73+
): ITextModeDiagnostic => ({
74+
code: 'usage_limit_exceeded',
75+
message: getBuilderValidationMessage(
76+
field.usageLimit?.message,
77+
getValidationString(
78+
strings.validation,
79+
'usageLimitExceeded',
80+
`Field "${field.field}" can appear at most ${field.usageLimit?.max} times in this scope`,
81+
{
82+
field: field.label || field.field,
83+
max: field.usageLimit?.max,
84+
}
85+
),
86+
{
87+
field,
88+
usageLimit: field.usageLimit,
89+
}
90+
),
91+
start,
92+
end,
93+
});
94+
95+
const collectParsedRuleEntries = (
96+
nodes: ParsedNode[],
97+
parentScopeId = '__root__'
98+
): IParsedRuleEntry[] =>
99+
nodes.flatMap((node, index) => {
100+
if ('kind' in node) {
101+
return collectParsedRuleEntries(node.children, `${parentScopeId}.${index}`);
102+
}
103+
104+
return [{ rule: node, parentScopeId }];
105+
});
106+
59107
export const validateBuilderSqlTextSemantics = (
60108
nodes: ParsedNode[],
61109
fields: IBuilderFieldProps[],
62110
strings: IStrings
63111
): ITextModeDiagnostic[] => {
64112
const diagnostics: ITextModeDiagnostic[] = [];
65-
const parsedRules = collectParsedSqlRules(nodes);
113+
const parsedRules = collectParsedRuleEntries(nodes);
114+
const usageCounts = new Map<string, number>();
66115

67-
parsedRules.forEach((rule) => {
116+
parsedRules.forEach(({ rule, parentScopeId }) => {
68117
const field = fields.find((fieldItem) => fieldItem.field === rule.field);
69118
const operator = rule.operator;
70119

@@ -80,6 +129,27 @@ export const validateBuilderSqlTextSemantics = (
80129
return;
81130
}
82131

132+
if (field.usageLimit) {
133+
const scope = resolveBuilderFieldUsageLimitScope(field);
134+
const usageBucketKey = `${scope}:${resolveBuilderFieldUsageLimitKey(field)}:${
135+
scope === 'parent' ? parentScopeId : 'all'
136+
}`;
137+
const nextUsageCount = (usageCounts.get(usageBucketKey) || 0) + 1;
138+
139+
usageCounts.set(usageBucketKey, nextUsageCount);
140+
141+
if (nextUsageCount > field.usageLimit.max) {
142+
diagnostics.push(
143+
createUsageLimitExceededDiagnostic(
144+
field,
145+
rule.source.field.start,
146+
rule.source.field.end,
147+
strings
148+
)
149+
);
150+
}
151+
}
152+
83153
if (
84154
operator &&
85155
field.operators &&
@@ -121,9 +191,7 @@ export const validateBuilderSqlTextSemantics = (
121191
return;
122192
}
123193

124-
diagnostics.push(
125-
createOneOfDiagnostic(range.start, range.end, strings)
126-
);
194+
diagnostics.push(createOneOfDiagnostic(range.start, range.end, strings));
127195
});
128196

129197
return;

0 commit comments

Comments
 (0)