Skip to content

Commit 4b42fc6

Browse files
authored
feat: add schema validation for variation value (#2760)
1 parent f511adc commit 4b42fc6

14 files changed

Lines changed: 1175 additions & 26 deletions

File tree

ui/dashboard/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@types/chart.js": "^4.0.1",
4444
"@types/js-cookie": "^3.0.6",
4545
"@types/react-date-range": "^1.4.10",
46+
"ajv": "^8.20.0",
4647
"axios": "^1.18.1",
4748
"chart.js": "^4.5.1",
4849
"chartjs-adapter-luxon": "^1.3.1",
@@ -64,6 +65,7 @@
6465
"prettier": "^3.9.6",
6566
"prism-react-renderer": "^2.4.1",
6667
"query-string": "^9.4.1",
68+
"re2js": "^2.8.6",
6769
"react": "^19.2.8",
6870
"react-chartjs-2": "^5.3.1",
6971
"react-date-range": "^2.0.1",

ui/dashboard/src/@locales/en/form.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,35 @@
136136
"no-tags-found": "No tags were found in the selected environment",
137137
"placeholder-search": "Search",
138138
"feature-flags": {
139+
"value-schema": {
140+
"title": "Value Schema",
141+
"tooltip": "Restrict which values this flag's variations can have. The available validators depend on the flag type:\nString: Enum or Regex\nNumber: Enum\nJSON: JSON Schema\nBoolean and YAML flags do not support value schemas.",
142+
"no-schema": "No schema is defined. Variation values are accepted without validation.",
143+
"has-schema": "Variation values are validated against this schema.",
144+
"add": "Add Schema",
145+
"edit": "Edit Schema",
146+
"remove": "Remove Schema",
147+
"add-title": "Add Value Schema",
148+
"edit-title": "Edit Value Schema",
149+
"type": "Schema Type",
150+
"type-enum": "Enum",
151+
"type-regex": "Regex",
152+
"type-json-schema": "JSON Schema",
153+
"enum-values": "Allowed Values",
154+
"enum-values-placeholder": "Type a value and press enter",
155+
"enum-values-number-note": "Each allowed value must be a number.",
156+
"regex-pattern": "Pattern",
157+
"regex-pattern-placeholder": "Enter a regular expression",
158+
"regex-note": "The pattern is evaluated using Go regular expression syntax.",
159+
"json-schema": "JSON Schema",
160+
"json-schema-note": "Variation values are validated using JSON Schema draft 2020-12.",
161+
"description-placeholder": "Explain the purpose of this schema (optional)",
162+
"test": "Test against current variations",
163+
"test-pass": "All variations match the schema.",
164+
"test-fail": "{{count}} variation(s) do not match the schema.",
165+
"test-valid": "Valid",
166+
"test-invalid": "Invalid"
167+
},
139168
"clone-title": "Clone Feature Flag",
140169
"clone-desc": "This will copy the targeting rules from the original flag to a new flag in the destination environment. The new flag will be created in a disabled state. Prerequisites, flag-based rules, and segment-based rules are not included.",
141170
"flag-id": "Flag ID",

ui/dashboard/src/@locales/en/message.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
"validation": {
44
"must-be-number": "This must be a number",
55
"must-be-json": "This must be a JSON",
6+
"value-schema-violation": "This value doesn't match the flag's value schema",
7+
"value-schema-enum-not-number": "All allowed values must be numbers",
8+
"value-schema-invalid-regex": "The pattern is not a valid RE2 regular expression",
9+
"value-schema-invalid-json-schema": "The document is not a valid JSON Schema",
610
"must-be-yaml": "This must be a YAML",
711
"must-be-unique": "This must be unique",
812
"max-length-number": "The maximum length for this field is {{count}} numbers.",

ui/dashboard/src/@locales/ja/form.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,35 @@
135135
"no-tags-found": "選択した環境にタグが見つかりませんでした",
136136
"placeholder-search": "検索",
137137
"feature-flags": {
138+
"value-schema": {
139+
"title": "値スキーマ",
140+
"tooltip": "このフラグのバリエーションに設定できる値を制限します。利用できるバリデーターはフラグの種類によって異なります:\n文字列: Enum または Regex\n数値: Enum\nJSON: JSONスキーマ\nブーリアンおよびYAMLフラグは値スキーマをサポートしていません。",
141+
"no-schema": "スキーマが定義されていません。バリエーション値は検証なしで受け付けられます。",
142+
"has-schema": "バリエーション値はこのスキーマに対して検証されます。",
143+
"add": "スキーマを追加",
144+
"edit": "スキーマを編集",
145+
"remove": "スキーマを削除",
146+
"add-title": "値スキーマを追加",
147+
"edit-title": "値スキーマを編集",
148+
"type": "スキーマタイプ",
149+
"type-enum": "列挙型",
150+
"type-regex": "正規表現",
151+
"type-json-schema": "JSONスキーマ",
152+
"enum-values": "許可される値",
153+
"enum-values-placeholder": "値を入力してEnterキーを押してください",
154+
"enum-values-number-note": "許可される値はすべて数値である必要があります。",
155+
"regex-pattern": "パターン",
156+
"regex-pattern-placeholder": "正規表現を入力",
157+
"regex-note": "パターンはGoの正規表現構文で評価されます。",
158+
"json-schema": "JSONスキーマ",
159+
"json-schema-note": "バリエーション値はJSONスキーマ draft 2020-12で検証されます。",
160+
"description-placeholder": "このスキーマの目的を説明してください(任意)",
161+
"test": "現在のバリエーションでテスト",
162+
"test-pass": "すべてのバリエーションがスキーマに一致しています。",
163+
"test-fail": "{{count}}件のバリエーションがスキーマに一致していません。",
164+
"test-valid": "有効",
165+
"test-invalid": "無効"
166+
},
138167
"clone-title": "フィーチャーフラグを複製",
139168
"clone-desc": "元のフラグのターゲティングルールをフラグがオフの状態で宛先環境の新しいフラグにコピーします。前提条件、フラグを条件としたルール、およびセグメントを条件としたルールはコピーされません。",
140169
"flag-id": "フラグID",

ui/dashboard/src/@locales/ja/message.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
"validation": {
44
"must-be-number": "数値を入力してください",
55
"must-be-json": "JSONを入力してください",
6+
"value-schema-violation": "この値はフラグの値スキーマに一致していません",
7+
"value-schema-enum-not-number": "許可される値はすべて数値である必要があります",
8+
"value-schema-invalid-regex": "有効なRE2正規表現ではありません",
9+
"value-schema-invalid-json-schema": "有効なJSONスキーマではありません",
610
"must-be-yaml": "YAMLを入力してください",
711
"must-be-unique": "一意の値を入力してください",
812
"max-length-number": "このフィールドの最大長は{{count}}桁です。",

ui/dashboard/src/pages/create-flag/form-schema.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@ import {
99
import { FormSchemaProps } from 'hooks/use-form-schema';
1010
import * as yaml from 'js-yaml';
1111
import * as yup from 'yup';
12-
import { FeatureVariation, FeatureVariationType } from '@types';
12+
import {
13+
FeatureVariation,
14+
FeatureVariationType,
15+
VariationValueSchema
16+
} from '@types';
1317
import { isNumber } from 'utils/chart';
1418
import { isJsonString } from 'utils/converts';
1519
import { isUniqueValue } from 'utils/function';
20+
import { createValueValidator } from 'utils/variation-value-schema';
1621
import { FlagSwitchVariationType } from './types';
1722

1823
const nameSchema = ({ requiredMessage, translation }: FormSchemaProps) =>
@@ -129,6 +134,31 @@ export const createVariationsSchema = ({
129134
}
130135
return true;
131136
})
137+
.test('matchesValueSchema', (value, context) => {
138+
const root = context.from && context.from[1].value;
139+
const valueSchema: VariationValueSchema | null | undefined =
140+
root?.variationValueSchema;
141+
if (!valueSchema || !value) return true;
142+
const validator = createValueValidator(
143+
valueSchema,
144+
root.variationType
145+
);
146+
// Unable to validate client-side; the backend validates on save.
147+
if (!validator) return true;
148+
const result = validator(value);
149+
if (result.valid) return true;
150+
const message = translation(
151+
'message:validation.value-schema-violation'
152+
);
153+
return context.createError({
154+
// Append the failing JSON path/rule when the validator can
155+
// identify it, so users know which field to fix.
156+
message: result.detail
157+
? `${message} (${result.detail})`
158+
: message,
159+
path: context.path
160+
});
161+
})
132162
.test('isUnique', function (_, context) {
133163
const type = context.from && context.from[1].value.variationType;
134164
const variations: FeatureVariation[] =

ui/dashboard/src/pages/feature-flag-details/variation/form-schema.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { FormSchemaProps } from 'hooks/use-form-schema';
22
import * as yup from 'yup';
3-
import { FeatureVariationType } from '@types';
3+
import { FeatureVariationType, VariationValueSchema } from '@types';
44
import {
55
createVariationsSchema,
66
VariationSchema
@@ -14,11 +14,16 @@ export const variationsFormSchema = ({
1414
variationType: yup.mixed<FeatureVariationType>().required(requiredMessage),
1515
onVariation: yup.string(),
1616
offVariation: yup.string(),
17+
variationValueSchema: yup
18+
.mixed<VariationValueSchema>()
19+
.nullable()
20+
.default(null),
1721
variations: createVariationsSchema({ requiredMessage, translation })
1822
});
1923
export interface VariationForm {
2024
variationType: FeatureVariationType;
2125
onVariation: string;
2226
offVariation: string;
27+
variationValueSchema?: VariationValueSchema | null;
2328
variations: VariationSchema[];
2429
}

ui/dashboard/src/pages/feature-flag-details/variation/index.tsx

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import ConfirmationRequiredModal, {
3636
import { SCHEDULE_TYPE_SCHEDULE } from '../elements/confirm-required-modal/form-schema';
3737
import ScheduledChangesBanner from '../elements/scheduled-changes-banner';
3838
import { variationsFormSchema } from './form-schema';
39+
import SchemaSection from './schema-section';
3940
import SubmitBar from './submit-bar';
4041
import VariationsSection from './variations-section';
4142

@@ -87,16 +88,23 @@ const Variation = ({ feature, editable }: VariationProps) => {
8788
variations: feature.variations,
8889
variationType: feature.variationType,
8990
offVariation: feature.offVariation,
90-
onVariation: ''
91+
onVariation: '',
92+
variationValueSchema: feature.variationValueSchema ?? null
9193
},
9294
mode: 'onChange'
9395
});
9496

9597
const {
9698
getValues,
99+
watch,
97100
formState: { isDirty, isSubmitting }
98101
} = form;
99102

103+
const isSchemaChanged = !isEqual(
104+
watch('variationValueSchema') ?? null,
105+
feature.variationValueSchema ?? null
106+
);
107+
100108
const handleCheckVariations = useCallback(
101109
(variations: FeatureVariation[]) => {
102110
const { variations: featureVariations } = feature;
@@ -139,9 +147,14 @@ const Variation = ({ feature, editable }: VariationProps) => {
139147
async (additionalValues?: ConfirmRequiredValues) => {
140148
if (editable) {
141149
try {
142-
const { variations, offVariation } = form.getValues();
150+
const { variations, offVariation, variationValueSchema } =
151+
form.getValues();
143152
const { comment, resetSampling, scheduleType, scheduleAt } =
144153
additionalValues || {};
154+
const isSchemaUpdated = !isEqual(
155+
variationValueSchema ?? null,
156+
feature.variationValueSchema ?? null
157+
);
145158

146159
const isScheduleUpdate = scheduleType === SCHEDULE_TYPE_SCHEDULE;
147160

@@ -179,6 +192,11 @@ const Variation = ({ feature, editable }: VariationProps) => {
179192
comment,
180193
resetSamplingSeed: resetSampling,
181194
offVariation,
195+
...(isSchemaUpdated
196+
? variationValueSchema
197+
? { variationValueSchema }
198+
: { clearVariationValueSchema: true }
199+
: {}),
182200
...handleCheckVariations(variations)
183201
});
184202
if (resp) {
@@ -202,7 +220,8 @@ const Variation = ({ feature, editable }: VariationProps) => {
202220
useEffect(() => {
203221
form.reset({
204222
...getValues(),
205-
variations: feature.variations
223+
variations: feature.variations,
224+
variationValueSchema: feature.variationValueSchema ?? null
206225
});
207226
}, [feature]);
208227
useUnsavedLeavePage({ isShow: isDirty && !isSubmitting });
@@ -267,6 +286,7 @@ const Variation = ({ feature, editable }: VariationProps) => {
267286
)}
268287
/>
269288
)}
289+
<SchemaSection feature={feature} editable={editable} />
270290
<VariationsSection
271291
editable={editable && !waitingRunningRollouts.length}
272292
feature={feature}
@@ -279,7 +299,11 @@ const Variation = ({ feature, editable }: VariationProps) => {
279299
<ConfirmationRequiredModal
280300
feature={feature}
281301
isOpen={openConfirmDialog}
282-
isShowScheduleSelect={SCHEDULED_FLAG_CHANGES_ENABLED}
302+
// Schema changes are not supported by scheduled flag changes,
303+
// so only offer scheduling when the schema is unchanged.
304+
isShowScheduleSelect={
305+
SCHEDULED_FLAG_CHANGES_ENABLED && !isSchemaChanged
306+
}
283307
onClose={onCloseConfirmDialog}
284308
onSubmit={additionalValues =>
285309
form.handleSubmit(() => onSubmit(additionalValues))()

0 commit comments

Comments
 (0)