diff --git a/package-lock.json b/package-lock.json index b28ef4601..62b02f314 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11619,6 +11619,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", + "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.17.1", "dev": true, @@ -24146,6 +24152,7 @@ "@googlemaps/js-api-loader": "^1.16.6", "@monaco-editor/loader": "^1.3.3", "@tato30/vue-pdf": "^1.9.6", + "ajv": "^8.17.1", "arquero": "^5.2.0", "chroma-js": "^2.4.2", "fuse.js": "7.0.0", @@ -24224,6 +24231,28 @@ } } }, + "src/ui/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "src/ui/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "tests/e2e": { "name": "writer-e2e", "version": "1.0.0", diff --git a/src/ui/package.json b/src/ui/package.json index d33ef4159..a4071b3cb 100644 --- a/src/ui/package.json +++ b/src/ui/package.json @@ -25,6 +25,7 @@ "@googlemaps/js-api-loader": "^1.16.6", "@monaco-editor/loader": "^1.3.3", "@tato30/vue-pdf": "^1.9.6", + "ajv": "^8.17.1", "arquero": "^5.2.0", "chroma-js": "^2.4.2", "fuse.js": "7.0.0", diff --git a/src/ui/src/builder/settings/BuilderFieldsAlign.vue b/src/ui/src/builder/settings/BuilderFieldsAlign.vue index 71da71685..4cce84c8b 100644 --- a/src/ui/src/builder/settings/BuilderFieldsAlign.vue +++ b/src/ui/src/builder/settings/BuilderFieldsAlign.vue @@ -24,6 +24,7 @@ v-if="mode == 'css'" ref="freehandInputEl" :value="component.content[fieldKey]" + :error="error" @input="handleInputCss" /> </div> @@ -38,6 +39,7 @@ import { nextTick, onBeforeUnmount, onMounted, + PropType, Ref, ref, toRefs, @@ -135,11 +137,15 @@ const focusEls: Record<Mode, Ref<HTMLInputElement>> = { default: null, }; -const props = defineProps<{ - componentId: Component["id"]; - fieldKey: string; - direction: "horizontal" | "vertical"; -}>(); +const props = defineProps({ + componentId: { type: String as PropType<Component["id"]>, required: true }, + fieldKey: { type: String, required: true }, + direction: { + type: String as PropType<"horizontal" | "vertical">, + required: true, + }, + error: { type: String, required: false, default: undefined }, +}); const { componentId, fieldKey, direction } = toRefs(props); const component = computed(() => wf.getComponentById(componentId.value)); diff --git a/src/ui/src/builder/settings/BuilderFieldsColor.vue b/src/ui/src/builder/settings/BuilderFieldsColor.vue index 43ab8c9a7..a4ca5cd3d 100644 --- a/src/ui/src/builder/settings/BuilderFieldsColor.vue +++ b/src/ui/src/builder/settings/BuilderFieldsColor.vue @@ -24,6 +24,7 @@ v-if="mode == 'css'" ref="freehandInputEl" :value="component.content[fieldKey]" + :error="error" @input="handleInput" /> </div> @@ -40,6 +41,7 @@ import { Ref, ref, toRefs, + PropType, } from "vue"; import { Component } from "@/writerTypes"; import { useComponentActions } from "../useComponentActions"; @@ -65,10 +67,12 @@ const focusEls: Record<Mode, Ref<HTMLInputElement>> = { default: null, }; -const props = defineProps<{ - componentId: Component["id"]; - fieldKey: string; -}>(); +const props = defineProps({ + componentId: { type: String as PropType<Component["id"]>, required: true }, + fieldKey: { type: String, required: true }, + error: { type: String, required: false, default: undefined }, +}); + const { componentId, fieldKey } = toRefs(props); const component = computed(() => wf.getComponentById(componentId.value)); diff --git a/src/ui/src/builder/settings/BuilderFieldsKeyValue.vue b/src/ui/src/builder/settings/BuilderFieldsKeyValue.vue index c74ac0df3..f6b7c324e 100644 --- a/src/ui/src/builder/settings/BuilderFieldsKeyValue.vue +++ b/src/ui/src/builder/settings/BuilderFieldsKeyValue.vue @@ -13,7 +13,10 @@ /> <template v-if="mode == 'assisted'"> - <div class="staticList"> + <div + class="staticList" + :class="{ 'staticList--invalid': error !== undefined }" + > <div v-for="(entryValue, entryKey) in assistedEntries" :key="entryKey" @@ -54,6 +57,7 @@ <BuilderFieldsObject :component-id="component.id" :field-key="fieldKey" + :error="error" ></BuilderFieldsObject> </template> </div> @@ -87,6 +91,7 @@ const props = defineProps({ componentId: { type: String, required: true }, fieldKey: { type: String, required: true }, instancePath: { type: Array as PropType<InstancePath>, required: true }, + error: { type: String, required: false, default: undefined }, }); const { componentId, fieldKey } = toRefs(props); @@ -192,6 +197,9 @@ onMounted(async () => { border: 1px solid var(--builderSeparatorColor); border-radius: 8px; } +.staticList--invalid { + border-color: var(--wdsColorOrange5); +} .staticList:empty::before { content: "No entries yet."; diff --git a/src/ui/src/builder/settings/BuilderFieldsObject.vue b/src/ui/src/builder/settings/BuilderFieldsObject.vue index 8dba465c6..dbe6f01fe 100644 --- a/src/ui/src/builder/settings/BuilderFieldsObject.vue +++ b/src/ui/src/builder/settings/BuilderFieldsObject.vue @@ -6,6 +6,7 @@ variant="code" :value="component.content[fieldKey]" :placeholder="templateField?.default" + :error="error" @input=" (ev: Event) => formatAndSetContentValue((ev.target as HTMLInputElement).value) @@ -14,20 +15,21 @@ </template> <script setup lang="ts"> -import { toRefs, inject, computed } from "vue"; -import { Component } from "@/writerTypes"; +import { toRefs, inject, computed, PropType } from "vue"; import { useComponentActions } from "../useComponentActions"; import injectionKeys from "@/injectionKeys"; import BuilderTemplateInput from "./BuilderTemplateInput.vue"; +import { Component } from "@/writerTypes"; const wf = inject(injectionKeys.core); const ssbm = inject(injectionKeys.builderManager); const { setContentValue } = useComponentActions(wf, ssbm); -const props = defineProps<{ - componentId: Component["id"]; - fieldKey: string; -}>(); +const props = defineProps({ + componentId: { type: String as PropType<Component["id"]>, required: true }, + fieldKey: { type: String, required: true }, + error: { type: String, required: false, default: undefined }, +}); const { componentId, fieldKey } = toRefs(props); const component = computed(() => wf.getComponentById(componentId.value)); diff --git a/src/ui/src/builder/settings/BuilderFieldsPadding.vue b/src/ui/src/builder/settings/BuilderFieldsPadding.vue index 0312f516c..8d3b62335 100644 --- a/src/ui/src/builder/settings/BuilderFieldsPadding.vue +++ b/src/ui/src/builder/settings/BuilderFieldsPadding.vue @@ -94,6 +94,7 @@ v-if="mode == 'css'" ref="freehandInputEl" :value="component.content[fieldKey]" + :error="error" @input="handleInputCss" /> </div> @@ -108,6 +109,7 @@ import { nextTick, onBeforeUnmount, onMounted, + PropType, Ref, ref, toRefs, @@ -183,10 +185,11 @@ const focusEls: Record<Mode, Ref<HTMLInputElement>> = { default: null, }; -const props = defineProps<{ - componentId: Component["id"]; - fieldKey: string; -}>(); +const props = defineProps({ + componentId: { type: String as PropType<Component["id"]>, required: true }, + fieldKey: { type: String, required: true }, + error: { type: String, required: false, default: undefined }, +}); const { componentId, fieldKey } = toRefs(props); const component = computed(() => wf.getComponentById(componentId.value)); diff --git a/src/ui/src/builder/settings/BuilderFieldsShadow.vue b/src/ui/src/builder/settings/BuilderFieldsShadow.vue index e8d01cf4d..ece3c2f1e 100644 --- a/src/ui/src/builder/settings/BuilderFieldsShadow.vue +++ b/src/ui/src/builder/settings/BuilderFieldsShadow.vue @@ -93,6 +93,7 @@ v-if="mode == 'css'" ref="freehandInputEl" :value="component.content[fieldKey]" + :error="error" @input="handleCSSInput" /> </div> @@ -106,6 +107,7 @@ import { nextTick, onBeforeUnmount, onMounted, + PropType, Ref, ref, toRefs, @@ -138,10 +140,12 @@ const focusEls: Record<Mode, Ref<HTMLInputElement>> = { default: null, }; -const props = defineProps<{ - componentId: Component["id"]; - fieldKey: string; -}>(); +const props = defineProps({ + componentId: { type: String as PropType<Component["id"]>, required: true }, + fieldKey: { type: String, required: true }, + error: { type: String, required: false, default: undefined }, +}); + const { componentId, fieldKey } = toRefs(props); const component = computed(() => wf.getComponentById(componentId.value)); diff --git a/src/ui/src/builder/settings/BuilderFieldsText.vue b/src/ui/src/builder/settings/BuilderFieldsText.vue index 944174c00..310854cde 100644 --- a/src/ui/src/builder/settings/BuilderFieldsText.vue +++ b/src/ui/src/builder/settings/BuilderFieldsText.vue @@ -12,6 +12,7 @@ :value="component.content[fieldKey]" :placeholder="templateField?.default" :options="options" + :error="error" @input="handleInput" /> </template> @@ -23,6 +24,7 @@ :input-id="inputId" :value="component.content[fieldKey]" :placeholder="templateField?.default" + :error="error" @input="handleInput" /> </template> @@ -30,7 +32,7 @@ </template> <script setup lang="ts"> -import { toRefs, inject, computed } from "vue"; +import { toRefs, inject, computed, PropType } from "vue"; import { Component, FieldControl } from "@/writerTypes"; import { useComponentActions } from "../useComponentActions"; import injectionKeys from "@/injectionKeys"; @@ -40,10 +42,11 @@ const wf = inject(injectionKeys.core); const ssbm = inject(injectionKeys.builderManager); const { setContentValue } = useComponentActions(wf, ssbm); -const props = defineProps<{ - componentId: Component["id"]; - fieldKey: string; -}>(); +const props = defineProps({ + componentId: { type: String as PropType<Component["id"]>, required: true }, + fieldKey: { type: String, required: true }, + error: { type: String, required: false, default: undefined }, +}); const { componentId, fieldKey } = toRefs(props); const component = computed(() => wf.getComponentById(componentId.value)); const templateField = computed(() => { diff --git a/src/ui/src/builder/settings/BuilderFieldsWidth.vue b/src/ui/src/builder/settings/BuilderFieldsWidth.vue index 155c64912..1b3ed3cc6 100644 --- a/src/ui/src/builder/settings/BuilderFieldsWidth.vue +++ b/src/ui/src/builder/settings/BuilderFieldsWidth.vue @@ -23,6 +23,7 @@ ref="fixedEl" type="number" :model-value="valuePickFixed" + :invalid="error !== undefined" @update:model-value="handleInputFixed" /> <div>px</div> @@ -47,6 +48,7 @@ import { nextTick, onBeforeUnmount, onMounted, + PropType, Ref, ref, toRefs, @@ -111,10 +113,11 @@ const focusEls: Record<Mode, Ref<HTMLInputElement>> = { default: null, }; -const props = defineProps<{ - componentId: Component["id"]; - fieldKey: string; -}>(); +const props = defineProps({ + componentId: { type: String as PropType<Component["id"]>, required: true }, + fieldKey: { type: String, required: true }, + error: { type: String, required: false, default: undefined }, +}); const { componentId, fieldKey } = toRefs(props); const component = computed(() => wf.getComponentById(componentId.value)); diff --git a/src/ui/src/builder/settings/BuilderSettingsBinding.vue b/src/ui/src/builder/settings/BuilderSettingsBinding.vue index cd3d30db2..7975a9f9f 100644 --- a/src/ui/src/builder/settings/BuilderSettingsBinding.vue +++ b/src/ui/src/builder/settings/BuilderSettingsBinding.vue @@ -26,6 +26,7 @@ import { useComponentActions } from "../useComponentActions"; import injectionKeys from "@/injectionKeys"; import BuilderTemplateInput from "./BuilderTemplateInput.vue"; import WdsFieldWrapper from "@/wds/WdsFieldWrapper.vue"; +import BuilderSectionTitle from "./BuilderSectionTitle.vue"; const hint = 'Links this component to a state element, in a two-way fashion. Reference the state element directly, i.e. use "my_var" instead of "@{my_var}".'; diff --git a/src/ui/src/builder/settings/BuilderSettingsProperties.vue b/src/ui/src/builder/settings/BuilderSettingsProperties.vue index a23176d7b..f89d6ae19 100644 --- a/src/ui/src/builder/settings/BuilderSettingsProperties.vue +++ b/src/ui/src/builder/settings/BuilderSettingsProperties.vue @@ -29,17 +29,20 @@ " :hint="fieldValue.desc" :unit="fieldValue.type" + :error="errorsByFields[fieldKey]" > <BuilderFieldsColor v-if="fieldValue.type == FieldType.Color" :field-key="fieldKey" :component-id="selectedComponent.id" + :error="errorsByFields[fieldKey]" ></BuilderFieldsColor> <BuilderFieldsShadow v-if="fieldValue.type == FieldType.Shadow" :field-key="fieldKey" :component-id="selectedComponent.id" + :error="errorsByFields[fieldKey]" ></BuilderFieldsShadow> <BuilderFieldsKeyValue @@ -47,36 +50,42 @@ :field-key="fieldKey" :component-id="selectedComponent.id" :instance-path="selectedInstancePath" + :error="errorsByFields[fieldKey]" ></BuilderFieldsKeyValue> <BuilderFieldsText v-if="fieldValue.type == FieldType.Text" :field-key="fieldKey" :component-id="selectedComponent.id" + :error="errorsByFields[fieldKey]" ></BuilderFieldsText> <BuilderFieldsText v-if="fieldValue.type == FieldType.Number" :field-key="fieldKey" :component-id="selectedComponent.id" + :error="errorsByFields[fieldKey]" ></BuilderFieldsText> <BuilderFieldsText v-if="fieldValue.type == FieldType.IdKey" :field-key="fieldKey" :component-id="selectedComponent.id" + :error="errorsByFields[fieldKey]" ></BuilderFieldsText> <BuilderFieldsObject v-if="fieldValue.type == FieldType.Object" :field-key="fieldKey" :component-id="selectedComponent.id" + :error="errorsByFields[fieldKey]" ></BuilderFieldsObject> <BuilderFieldsWidth v-if="fieldValue.type == FieldType.Width" :field-key="fieldKey" :component-id="selectedComponent.id" + :error="errorsByFields[fieldKey]" ></BuilderFieldsWidth> <BuilderFieldsAlign @@ -84,6 +93,7 @@ direction="horizontal" :field-key="fieldKey" :component-id="selectedComponent.id" + :error="errorsByFields[fieldKey]" ></BuilderFieldsAlign> <BuilderFieldsAlign @@ -91,12 +101,14 @@ direction="vertical" :field-key="fieldKey" :component-id="selectedComponent.id" + :error="errorsByFields[fieldKey]" ></BuilderFieldsAlign> <BuilderFieldsPadding v-if="fieldValue.type == FieldType.Padding" :field-key="fieldKey" :component-id="selectedComponent.id" + :error="errorsByFields[fieldKey]" ></BuilderFieldsPadding> <BuilderFieldsTools @@ -126,6 +138,7 @@ import BuilderFieldsText from "./BuilderFieldsText.vue"; import BuilderFieldsWidth from "./BuilderFieldsWidth.vue"; import BuilderFieldsTools from "./BuilderFieldsTools.vue"; import WdsFieldWrapper from "@/wds/WdsFieldWrapper.vue"; +import { useFieldsErrors } from "@/renderer/useFieldsErrors"; const wf = inject(injectionKeys.core); const ssbm = inject(injectionKeys.builderManager); @@ -138,12 +151,16 @@ const selectedComponent = computed(() => { return wf.getComponentById(ssbm.firstSelectedId.value); }); -const fields = computed(() => { +const componentDefinition = computed(() => { const { type } = selectedComponent.value; - const definition = wf.getComponentDefinition(type); - return definition.fields; + return wf.getComponentDefinition(type); +}); +const fields = computed(() => { + return componentDefinition.value?.fields; }); +const errorsByFields = useFieldsErrors(wf, selectedInstancePath); + const fieldCategories = computed(() => { return [ FieldCategory.General, diff --git a/src/ui/src/builder/settings/BuilderTemplateInput.vue b/src/ui/src/builder/settings/BuilderTemplateInput.vue index 58f12bb27..65a617522 100644 --- a/src/ui/src/builder/settings/BuilderTemplateInput.vue +++ b/src/ui/src/builder/settings/BuilderTemplateInput.vue @@ -9,6 +9,7 @@ spellcheck="false" :placeholder="props.placeholder" :list="props.options ? `list-${props.inputId}` : undefined" + :invalid="error !== undefined" @input="handleInput" @blur="closeAutocompletion" /> @@ -38,6 +39,7 @@ spellcheck="false" rows="3" :placeholder="props.placeholder" + :invalid="error !== undefined" @input="handleInput" /> </template> @@ -99,6 +101,7 @@ const props = defineProps({ default: undefined, }, placeholder: { type: String, required: false, default: undefined }, + error: { type: String, required: false, default: undefined }, }); const ss = inject(injectionKeys.core); diff --git a/src/ui/src/components/core/content/CoreAnnotatedText.vue b/src/ui/src/components/core/content/CoreAnnotatedText.vue index b89a9bb03..ca6faaf80 100644 --- a/src/ui/src/components/core/content/CoreAnnotatedText.vue +++ b/src/ui/src/components/core/content/CoreAnnotatedText.vue @@ -34,6 +34,7 @@ import { } from "@/renderer/sharedStyleFields"; import SharedControlBar from "@/components/shared/SharedControlBar.vue"; import { WdsColor } from "@/wds/tokens"; +import { validatorAnotatedText } from "@/constants/validators"; export default { writer: { name: "Annotated text", @@ -44,7 +45,8 @@ export default { name: "Annotated text", type: FieldType.Object, desc: "Value array with text/annotations. Must be a JSON string or a state reference to an array.", - init: `["This ",["is", "Verb"]," some ",["annotated", "Adjective"], ["text", "Noun"]," for those of ",["you", "Pronoun"]," who ",["like", "Verb"]," this sort of ",["thing", "Noun"],". ","And here's a ",["word", "", "#faf"]," with a fancy background but no label."]`, + init: `["This ",["is", "Verb"]," some ",["annotated", "Adjective"], ["text", "Noun"]," for those of ",["you", "Pronoun"]," who ",["like", "Verb"]," this sort of ",["thing", "Noun"],". ","And here's a ",["word", "", "#faf"]," with a fancy background but no label."]`, + validator: validatorAnotatedText, }, referenceColor: { name: "Reference", diff --git a/src/ui/src/components/core/content/CoreChatbot.vue b/src/ui/src/components/core/content/CoreChatbot.vue index 521bfa16c..5f4b80fac 100644 --- a/src/ui/src/components/core/content/CoreChatbot.vue +++ b/src/ui/src/components/core/content/CoreChatbot.vue @@ -111,6 +111,7 @@ import prettyBytes from "pretty-bytes"; import WdsTextareaInput from "@/wds/WdsTextareaInput.vue"; import WdsControl from "@/wds/WdsControl.vue"; import { WdsColor } from "@/wds/tokens"; +import { validatorChatBotMessages } from "@/constants/validators"; const MAX_FILE_SIZE = 200 * 1024 * 1024; @@ -198,6 +199,7 @@ export default { init: initConversation, desc: "An array with messages or a writer.ai.Conversation object.", type: FieldType.Object, + validator: validatorChatBotMessages, }, assistantInitials: { name: "Assistant initials", diff --git a/src/ui/src/components/core/content/CoreDataframe.vue b/src/ui/src/components/core/content/CoreDataframe.vue index 650adf5e9..444d59e82 100644 --- a/src/ui/src/components/core/content/CoreDataframe.vue +++ b/src/ui/src/components/core/content/CoreDataframe.vue @@ -136,6 +136,10 @@ import { import WdsButton from "@/wds/WdsButton.vue"; import { WdsColor } from "@/wds/tokens"; import { useLogger } from "@/composables/useLogger"; +import { + validatorObjectRecordNotNested, + validatorPositiveNumber, +} from "@/constants/validators"; const description = "A component to display Pandas DataFrames."; @@ -244,6 +248,7 @@ export default { desc: "Define rows actions", type: FieldType.KeyValue, default: JSON.stringify({ remove: "Remove", open: "Open" }), + validator: validatorObjectRecordNotNested, }, useMarkdown: { name: "Use Markdown", @@ -261,6 +266,7 @@ export default { type: FieldType.Number, category: FieldCategory.Style, default: "10", + validator: validatorPositiveNumber, }, wrapText: { name: "Wrap text", diff --git a/src/ui/src/components/core/content/CoreDataframeLegacy.vue b/src/ui/src/components/core/content/CoreDataframeLegacy.vue index eb0a0f24c..90e712685 100644 --- a/src/ui/src/components/core/content/CoreDataframeLegacy.vue +++ b/src/ui/src/components/core/content/CoreDataframeLegacy.vue @@ -127,6 +127,7 @@ import WdsControl from "@/wds/WdsControl.vue"; import BaseMarkdown from "../base/BaseMarkdown.vue"; import { WdsColor } from "@/wds/tokens"; import { useLogger } from "@/composables/useLogger"; +import { validatorPositiveNumber } from "@/constants/validators"; const description = "A component to display Pandas DataFrames."; const defaultDataframe = `data:application/vnd.apache.arrow.file;base64,QVJST1cxAAD/////iAMAABAAAAAAAAoADgAGAAUACAAKAAAAAAEEABAAAAAAAAoADAAAAAQACAAKAAAAlAIAAAQAAAABAAAADAAAAAgADAAEAAgACAAAAGwCAAAEAAAAXwIAAHsiaW5kZXhfY29sdW1ucyI6IFsiX19pbmRleF9sZXZlbF8wX18iXSwgImNvbHVtbl9pbmRleGVzIjogW3sibmFtZSI6IG51bGwsICJmaWVsZF9uYW1lIjogbnVsbCwgInBhbmRhc190eXBlIjogInVuaWNvZGUiLCAibnVtcHlfdHlwZSI6ICJvYmplY3QiLCAibWV0YWRhdGEiOiB7ImVuY29kaW5nIjogIlVURi04In19XSwgImNvbHVtbnMiOiBbeyJuYW1lIjogImNvbF9hIiwgImZpZWxkX25hbWUiOiAiY29sX2EiLCAicGFuZGFzX3R5cGUiOiAiaW50NjQiLCAibnVtcHlfdHlwZSI6ICJpbnQ2NCIsICJtZXRhZGF0YSI6IG51bGx9LCB7Im5hbWUiOiAiY29sX2IiLCAiZmllbGRfbmFtZSI6ICJjb2xfYiIsICJwYW5kYXNfdHlwZSI6ICJpbnQ2NCIsICJudW1weV90eXBlIjogImludDY0IiwgIm1ldGFkYXRhIjogbnVsbH0sIHsibmFtZSI6IG51bGwsICJmaWVsZF9uYW1lIjogIl9faW5kZXhfbGV2ZWxfMF9fIiwgInBhbmRhc190eXBlIjogImludDY0IiwgIm51bXB5X3R5cGUiOiAiaW50NjQiLCAibWV0YWRhdGEiOiBudWxsfV0sICJjcmVhdG9yIjogeyJsaWJyYXJ5IjogInB5YXJyb3ciLCAidmVyc2lvbiI6ICIxMi4wLjAifSwgInBhbmRhc192ZXJzaW9uIjogIjEuNS4zIn0ABgAAAHBhbmRhcwAAAwAAAIgAAABEAAAABAAAAJT///8AAAECEAAAACQAAAAEAAAAAAAAABEAAABfX2luZGV4X2xldmVsXzBfXwAAAJD///8AAAABQAAAAND///8AAAECEAAAABgAAAAEAAAAAAAAAAUAAABjb2xfYgAAAMD///8AAAABQAAAABAAFAAIAAYABwAMAAAAEAAQAAAAAAABAhAAAAAgAAAABAAAAAAAAAAFAAAAY29sX2EAAAAIAAwACAAHAAgAAAAAAAABQAAAAAAAAAD/////6AAAABQAAAAAAAAADAAWAAYABQAIAAwADAAAAAADBAAYAAAAMAAAAAAAAAAAAAoAGAAMAAQACAAKAAAAfAAAABAAAAACAAAAAAAAAAAAAAAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAABAAAAAAAAAAAAAAAAMAAAACAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAIAAAAAAAAAAwAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAD/////AAAAABAAAAAMABQABgAIAAwAEAAMAAAAAAAEADwAAAAoAAAABAAAAAEAAACYAwAAAAAAAPAAAAAAAAAAMAAAAAAAAAAAAAAAAAAAAAAACgAMAAAABAAIAAoAAACUAgAABAAAAAEAAAAMAAAACAAMAAQACAAIAAAAbAIAAAQAAABfAgAAeyJpbmRleF9jb2x1bW5zIjogWyJfX2luZGV4X2xldmVsXzBfXyJdLCAiY29sdW1uX2luZGV4ZXMiOiBbeyJuYW1lIjogbnVsbCwgImZpZWxkX25hbWUiOiBudWxsLCAicGFuZGFzX3R5cGUiOiAidW5pY29kZSIsICJudW1weV90eXBlIjogIm9iamVjdCIsICJtZXRhZGF0YSI6IHsiZW5jb2RpbmciOiAiVVRGLTgifX1dLCAiY29sdW1ucyI6IFt7Im5hbWUiOiAiY29sX2EiLCAiZmllbGRfbmFtZSI6ICJjb2xfYSIsICJwYW5kYXNfdHlwZSI6ICJpbnQ2NCIsICJudW1weV90eXBlIjogImludDY0IiwgIm1ldGFkYXRhIjogbnVsbH0sIHsibmFtZSI6ICJjb2xfYiIsICJmaWVsZF9uYW1lIjogImNvbF9iIiwgInBhbmRhc190eXBlIjogImludDY0IiwgIm51bXB5X3R5cGUiOiAiaW50NjQiLCAibWV0YWRhdGEiOiBudWxsfSwgeyJuYW1lIjogbnVsbCwgImZpZWxkX25hbWUiOiAiX19pbmRleF9sZXZlbF8wX18iLCAicGFuZGFzX3R5cGUiOiAiaW50NjQiLCAibnVtcHlfdHlwZSI6ICJpbnQ2NCIsICJtZXRhZGF0YSI6IG51bGx9XSwgImNyZWF0b3IiOiB7ImxpYnJhcnkiOiAicHlhcnJvdyIsICJ2ZXJzaW9uIjogIjEyLjAuMCJ9LCAicGFuZGFzX3ZlcnNpb24iOiAiMS41LjMifQAGAAAAcGFuZGFzAAADAAAAiAAAAEQAAAAEAAAAlP///wAAAQIQAAAAJAAAAAQAAAAAAAAAEQAAAF9faW5kZXhfbGV2ZWxfMF9fAAAAkP///wAAAAFAAAAA0P///wAAAQIQAAAAGAAAAAQAAAAAAAAABQAAAGNvbF9iAAAAwP///wAAAAFAAAAAEAAUAAgABgAHAAwAAAAQABAAAAAAAAECEAAAACAAAAAEAAAAAAAAAAUAAABjb2xfYQAAAAgADAAIAAcACAAAAAAAAAFAAAAAsAMAAEFSUk9XMQ==`; @@ -188,6 +189,7 @@ export default { type: FieldType.Number, category: FieldCategory.Style, default: "10", + validator: validatorPositiveNumber, }, wrapText: { name: "Wrap text", diff --git a/src/ui/src/components/core/content/CoreIcon.vue b/src/ui/src/components/core/content/CoreIcon.vue index fab63f15f..149aa8270 100644 --- a/src/ui/src/components/core/content/CoreIcon.vue +++ b/src/ui/src/components/core/content/CoreIcon.vue @@ -19,6 +19,7 @@ <script lang="ts"> import { FieldCategory, FieldType } from "@/writerTypes"; import { cssClasses } from "@/renderer/sharedStyleFields"; +import { validatorPositiveNumber } from "@/constants/validators"; export default { writer: { @@ -39,6 +40,7 @@ export default { desc: `Icon size in px`, category: FieldCategory.Style, default: "14", + validator: validatorPositiveNumber, }, color: { name: "Icon color", diff --git a/src/ui/src/components/core/content/CoreJsonViewer.vue b/src/ui/src/components/core/content/CoreJsonViewer.vue index 7cee5b185..40de32155 100644 --- a/src/ui/src/components/core/content/CoreJsonViewer.vue +++ b/src/ui/src/components/core/content/CoreJsonViewer.vue @@ -21,6 +21,10 @@ import { FieldType, WriterComponentDefinition, } from "@/writerTypes"; +import { + validatorCssSize, + validatorPositiveNumber, +} from "@/constants/validators"; const description = "A component to explore JSON data as a hierarchy."; @@ -54,6 +58,7 @@ const definition: WriterComponentDefinition = { desc: "Sets the initial viewing depth of the JSON tree hierarchy. Use -1 to display the full hierarchy.", type: FieldType.Number, init: "0", + validator: validatorPositiveNumber, }, hideRoot: { name: "Hide root", @@ -82,6 +87,7 @@ const definition: WriterComponentDefinition = { type: FieldType.Width, category: FieldCategory.Style, applyStyleVariable: true, + validator: validatorCssSize, }, accentColor, secondaryTextColor, diff --git a/src/ui/src/components/core/content/CoreVideoPlayer.vue b/src/ui/src/components/core/content/CoreVideoPlayer.vue index b8651c42c..8226abe90 100644 --- a/src/ui/src/components/core/content/CoreVideoPlayer.vue +++ b/src/ui/src/components/core/content/CoreVideoPlayer.vue @@ -26,6 +26,7 @@ Afterwards, you can reference the video using the syntax \`@{vid_f}\`. <script lang="ts"> import { FieldType } from "@/writerTypes"; import { cssClasses } from "@/renderer/sharedStyleFields"; +import { validatorUri } from "@/constants/validators"; const description = "A video player component that can play various video formats."; @@ -41,6 +42,7 @@ export default { desc: "The URL of the video file. Alternatively, you can pass a file via state.", default: "", type: FieldType.Text, + validator: validatorUri, }, controls: { name: "Controls", diff --git a/src/ui/src/components/core/embed/CoreGoogleMaps.vue b/src/ui/src/components/core/embed/CoreGoogleMaps.vue index a0aaf0798..565ff1e8d 100644 --- a/src/ui/src/components/core/embed/CoreGoogleMaps.vue +++ b/src/ui/src/components/core/embed/CoreGoogleMaps.vue @@ -13,6 +13,11 @@ /* global google */ import { FieldType } from "@/writerTypes"; import { cssClasses } from "@/renderer/sharedStyleFields"; +import { + validatorGpsMarkers, + validatorGpsLat, + validatorGpsLng, +} from "@/constants/validators"; const description = "A component to embed a Google Map. It can be used to display a map with markers."; @@ -60,17 +65,20 @@ export default { name: "Latitude", default: "37.79322359164316", type: FieldType.Number, + validator: validatorGpsLat, }, lng: { name: "Longitude", default: "-122.39999318828129", type: FieldType.Number, + validator: validatorGpsLng, }, markers: { name: "Markers", default: JSON.stringify(markersDefaultData), desc: "Markers data", type: FieldType.Object, + validator: validatorGpsMarkers, }, cssClasses, }, diff --git a/src/ui/src/components/core/embed/CoreIFrame.vue b/src/ui/src/components/core/embed/CoreIFrame.vue index 7c887ecee..ee900d477 100644 --- a/src/ui/src/components/core/embed/CoreIFrame.vue +++ b/src/ui/src/components/core/embed/CoreIFrame.vue @@ -16,6 +16,7 @@ <script lang="ts"> import { FieldType } from "@/writerTypes"; import { cssClasses, separatorColor } from "@/renderer/sharedStyleFields"; +import { validatorUri } from "@/constants/validators"; const description = "A component to embed an external resource in an iframe."; @@ -37,6 +38,7 @@ export default { default: "", desc: "A valid URL", type: FieldType.Text, + validator: validatorUri, }, separatorColor, cssClasses, diff --git a/src/ui/src/components/core/embed/CoreMapbox.vue b/src/ui/src/components/core/embed/CoreMapbox.vue index 358e2af5b..d7f68a1a2 100644 --- a/src/ui/src/components/core/embed/CoreMapbox.vue +++ b/src/ui/src/components/core/embed/CoreMapbox.vue @@ -13,6 +13,11 @@ import { FieldType } from "@/writerTypes"; import { cssClasses } from "@/renderer/sharedStyleFields"; import { useLogger } from "@/composables/useLogger"; +import { + validatorGpsLat, + validatorGpsLng, + validatorGpsMarkers, +} from "@/constants/validators"; const markersDefaultData = [ { lat: 37.79322359164316, lng: -122.39999318828129, name: "Marker" }, @@ -47,17 +52,20 @@ export default { name: "Latitude", default: "37.79322359164316", type: FieldType.Number, + validator: validatorGpsLat, }, lng: { name: "Longitude", default: "-122.39999318828129", type: FieldType.Number, + validator: validatorGpsLng, }, markers: { name: "Markers", init: JSON.stringify(markersDefaultData, null, 2), desc: "", type: FieldType.Object, + validator: validatorGpsMarkers, }, controls: { name: "Controls visible", diff --git a/src/ui/src/components/core/embed/CorePDF.vue b/src/ui/src/components/core/embed/CorePDF.vue index 608576e64..fc297fcf6 100644 --- a/src/ui/src/components/core/embed/CorePDF.vue +++ b/src/ui/src/components/core/embed/CorePDF.vue @@ -53,6 +53,7 @@ import { containerBackgroundColor, } from "@/renderer/sharedStyleFields"; import WdsControl from "@/wds/WdsControl.vue"; +import { validatorArrayOfString } from "@/constants/validators"; const description = "A component to embed PDF documents."; @@ -72,6 +73,7 @@ export default { default: JSON.stringify([]), desc: "A list of highlights to be applied to the PDF as a JSON array of strings.", type: FieldType.Object, + validator: validatorArrayOfString, }, selectedMatch: { name: "Selected highlight match", diff --git a/src/ui/src/components/core/input/CoreCheckboxInput.vue b/src/ui/src/components/core/input/CoreCheckboxInput.vue index d2f8eb70c..8cf671b9b 100644 --- a/src/ui/src/components/core/input/CoreCheckboxInput.vue +++ b/src/ui/src/components/core/input/CoreCheckboxInput.vue @@ -45,6 +45,7 @@ import { primaryTextColor, } from "@/renderer/sharedStyleFields"; import BaseInputWrapper from "../base/BaseInputWrapper.vue"; +import { validatorObjectRecordNotNested } from "@/constants/validators"; const defaultOptions = { a: "Option A", b: "Option B" }; @@ -75,6 +76,7 @@ export default { desc: "Key-value object with options. Must be a JSON string or a state reference to a dictionary.", type: FieldType.KeyValue, default: JSON.stringify(defaultOptions, null, 2), + validator: validatorObjectRecordNotNested, }, orientation: { name: "Orientation", diff --git a/src/ui/src/components/core/input/CoreColorInput.vue b/src/ui/src/components/core/input/CoreColorInput.vue index 5bfe82af4..27dbacd91 100644 --- a/src/ui/src/components/core/input/CoreColorInput.vue +++ b/src/ui/src/components/core/input/CoreColorInput.vue @@ -20,6 +20,7 @@ import { FieldType, WriterComponentDefinition } from "@/writerTypes"; import BaseInputColor from "../base/BaseInputColor.vue"; import BaseInputWrapper from "../base/BaseInputWrapper.vue"; import { WdsColor } from "@/wds/tokens"; +import { validatorArrayOfString } from "@/constants/validators"; const description = "A user input component that allows users to select a color using a color picker interface."; @@ -52,6 +53,7 @@ const definition = { WdsColor.Gray6, WdsColor.Blue4, ]), + validator: validatorArrayOfString, }, cssClasses, }, diff --git a/src/ui/src/components/core/input/CoreDropdownInput.vue b/src/ui/src/components/core/input/CoreDropdownInput.vue index dd3ac51ba..874b0b89d 100644 --- a/src/ui/src/components/core/input/CoreDropdownInput.vue +++ b/src/ui/src/components/core/input/CoreDropdownInput.vue @@ -33,6 +33,7 @@ import { cssClasses } from "@/renderer/sharedStyleFields"; import BaseInputWrapper from "../base/BaseInputWrapper.vue"; import WdsDropdownInput from "@/wds/WdsDropdownInput.vue"; import { ComponentPublicInstance } from "vue"; +import { validatorObjectRecordNotNested } from "@/constants/validators"; const description = "A user input component that allows users to select a single value from a list of options using a dropdown menu."; @@ -60,6 +61,7 @@ export default { desc: "Key-value object with options. Must be a JSON string or a state reference to a dictionary.", type: FieldType.KeyValue, default: JSON.stringify(defaultOptions, null, 2), + validator: validatorObjectRecordNotNested, }, cssClasses, }, diff --git a/src/ui/src/components/core/input/CoreMultiselectInput.vue b/src/ui/src/components/core/input/CoreMultiselectInput.vue index d8826d452..bacb57e5c 100644 --- a/src/ui/src/components/core/input/CoreMultiselectInput.vue +++ b/src/ui/src/components/core/input/CoreMultiselectInput.vue @@ -31,6 +31,10 @@ import { import BaseInputWrapper from "../base/BaseInputWrapper.vue"; import { ComponentPublicInstance } from "vue"; import { WdsColor } from "@/wds/tokens"; +import { + validatorObjectRecordNotNested, + validatorPositiveNumber, +} from "@/constants/validators"; const description = "A user input component that allows users to select multiple values from a searchable list of options."; @@ -58,6 +62,7 @@ export default { desc: "Key-value object with options. Must be a JSON string or a state reference to a dictionary.", type: FieldType.KeyValue, default: JSON.stringify(defaultOptions, null, 2), + validator: validatorObjectRecordNotNested, }, placeholder: { name: "Placeholder", @@ -69,6 +74,7 @@ export default { desc: "The maximum allowable number of selected options. Set to zero for unlimited.", type: FieldType.Number, default: "0", + validator: validatorPositiveNumber, }, accentColor: { ...accentColor, diff --git a/src/ui/src/components/core/input/CoreRadioInput.vue b/src/ui/src/components/core/input/CoreRadioInput.vue index c61816b1d..28bcdd311 100644 --- a/src/ui/src/components/core/input/CoreRadioInput.vue +++ b/src/ui/src/components/core/input/CoreRadioInput.vue @@ -44,6 +44,7 @@ import { } from "@/renderer/sharedStyleFields"; import BaseInputWrapper from "../base/BaseInputWrapper.vue"; import { ComponentPublicInstance } from "vue"; +import { validatorObjectRecordNotNested } from "@/constants/validators"; const description = "A user input component that allows users to choose a single value from a list of options using radio buttons."; @@ -73,6 +74,7 @@ export default { desc: "Key-value object with options. Must be a JSON string or a state reference to a dictionary.", type: FieldType.KeyValue, default: JSON.stringify(defaultOptions, null, 2), + validator: validatorObjectRecordNotNested, }, orientation: { name: "Orientation", diff --git a/src/ui/src/components/core/input/CoreRatingInput.vue b/src/ui/src/components/core/input/CoreRatingInput.vue index 47d5f58d8..aa7ca3087 100644 --- a/src/ui/src/components/core/input/CoreRatingInput.vue +++ b/src/ui/src/components/core/input/CoreRatingInput.vue @@ -58,6 +58,7 @@ import { } from "@/renderer/sharedStyleFields"; import BaseInputWrapper from "../base/BaseInputWrapper.vue"; import { ComponentPublicInstance } from "vue"; +import { buildJsonSchemaForNumberBetween } from "@/constants/validators"; const description = "A user input component that allows users to provide a rating."; @@ -95,18 +96,21 @@ export default { type: FieldType.Number, default: "1", desc: "Valid values are 0 and 1.", + validator: buildJsonSchemaForNumberBetween(0, 1), }, maxValue: { name: "Max value", type: FieldType.Number, default: "5", desc: "Valid values are between 2 and 11.", + validator: buildJsonSchemaForNumberBetween(2, 11), }, valueStep: { name: "Step", type: FieldType.Number, default: "1", desc: "Valid values are between 0.25 and 1.", + validator: buildJsonSchemaForNumberBetween(0.25, 1), }, accentColor, primaryTextColor, diff --git a/src/ui/src/components/core/input/CoreSelectInput.vue b/src/ui/src/components/core/input/CoreSelectInput.vue index 81c3410fd..0c46e1ce0 100644 --- a/src/ui/src/components/core/input/CoreSelectInput.vue +++ b/src/ui/src/components/core/input/CoreSelectInput.vue @@ -31,6 +31,8 @@ import { import BaseInputWrapper from "../base/BaseInputWrapper.vue"; import { ComponentPublicInstance } from "vue"; import { WdsColor } from "@/wds/tokens"; +import { validatorObjectRecordNotNested } from "@/constants/validators"; +import { validatorPositiveNumber } from "@/constants/validators"; const description = "A user input component that allows users to select a single value from a searchable list of options."; @@ -58,6 +60,7 @@ export default { desc: "Key-value object with options. Must be a JSON string or a state reference to a dictionary.", type: FieldType.KeyValue, default: JSON.stringify(defaultOptions, null, 2), + validator: validatorObjectRecordNotNested, }, placeholder: { name: "Placeholder", @@ -69,6 +72,7 @@ export default { desc: "The maximum allowable number of selected options. Set to zero for unlimited.", type: FieldType.Number, default: "0", + validator: validatorPositiveNumber, }, accentColor, chipTextColor: { diff --git a/src/ui/src/components/core/input/CoreSliderInput.vue b/src/ui/src/components/core/input/CoreSliderInput.vue index 4d2bf49e1..005f12bd7 100644 --- a/src/ui/src/components/core/input/CoreSliderInput.vue +++ b/src/ui/src/components/core/input/CoreSliderInput.vue @@ -20,6 +20,7 @@ import { ComponentPublicInstance } from "vue"; import { accentColor, cssClasses } from "@/renderer/sharedStyleFields"; import { FieldCategory, FieldType } from "@/writerTypes"; import BaseInputWrapper from "../base/BaseInputWrapper.vue"; +import { validatorPositiveNumber } from "@/constants/validators"; const description = "A user input component that allows users to select numeric values using a slider with optional constraints like min, max, and step."; @@ -59,6 +60,7 @@ export default { type: FieldType.Number, default: "1", init: "1", + validator: validatorPositiveNumber, }, accentColor, popoverColor: { diff --git a/src/ui/src/components/core/input/CoreSliderRangeInput.vue b/src/ui/src/components/core/input/CoreSliderRangeInput.vue index 332fe0588..c96d78c57 100644 --- a/src/ui/src/components/core/input/CoreSliderRangeInput.vue +++ b/src/ui/src/components/core/input/CoreSliderRangeInput.vue @@ -20,6 +20,7 @@ import { ComponentPublicInstance } from "vue"; import { accentColor, cssClasses } from "@/renderer/sharedStyleFields"; import { FieldCategory, FieldType } from "@/writerTypes"; import BaseInputWrapper from "../base/BaseInputWrapper.vue"; +import { validatorPositiveNumber } from "@/constants/validators"; const description = "A user input component that allows users to select numeric values range using a range slider with optional constraints like min, max, and step."; @@ -59,6 +60,7 @@ export default { type: FieldType.Number, default: "1", init: "1", + validator: validatorPositiveNumber, }, accentColor, popoverColor: { diff --git a/src/ui/src/components/core/input/CoreTextareaInput.vue b/src/ui/src/components/core/input/CoreTextareaInput.vue index 99b2924f7..998a7ab74 100644 --- a/src/ui/src/components/core/input/CoreTextareaInput.vue +++ b/src/ui/src/components/core/input/CoreTextareaInput.vue @@ -31,6 +31,7 @@ import { FieldControl, FieldType } from "@/writerTypes"; import { cssClasses } from "@/renderer/sharedStyleFields"; import BaseInputWrapper from "../base/BaseInputWrapper.vue"; import { ComponentPublicInstance } from "vue"; +import { validatorPositiveNumber } from "@/constants/validators"; const description = "A user input component that allows users to enter multi-line text values."; @@ -63,6 +64,7 @@ export default { type: FieldType.Number, init: "5", default: "5", + validator: validatorPositiveNumber, }, cssClasses, }, diff --git a/src/ui/src/components/core/layout/CoreColumn.vue b/src/ui/src/components/core/layout/CoreColumn.vue index 2a55154c2..af3b64056 100644 --- a/src/ui/src/components/core/layout/CoreColumn.vue +++ b/src/ui/src/components/core/layout/CoreColumn.vue @@ -48,6 +48,7 @@ import { startCollapsed, isCollapsible as isCollapsibleField, } from "@/renderer/sharedStyleFields"; +import { validatorPositiveNumber } from "@/constants/validators"; const description = "A layout component that organizes its child components in columns. Must be inside a Column Container component."; @@ -71,6 +72,7 @@ export default { type: FieldType.Number, desc: "Relative size when compared to other columns in the same container. A column of width 2 will be double the width of one with width 1.", category: FieldCategory.Style, + validator: validatorPositiveNumber, }, isSticky: { name: "Sticky", diff --git a/src/ui/src/components/core/other/CoreHtml.vue b/src/ui/src/components/core/other/CoreHtml.vue index 08804aee1..538c7a64c 100644 --- a/src/ui/src/components/core/other/CoreHtml.vue +++ b/src/ui/src/components/core/other/CoreHtml.vue @@ -10,6 +10,7 @@ import { h, inject } from "vue"; import { FieldControl, FieldType } from "@/writerTypes"; import injectionKeys from "@/injectionKeys"; import { cssClasses } from "@/renderer/sharedStyleFields"; +import { validatorObjectRecordNotNested } from "@/constants/validators"; const defaultStyle = { padding: "16px", @@ -42,12 +43,14 @@ export default { init: JSON.stringify(defaultStyle, null, 2), type: FieldType.Object, desc: "Define the CSS styles to apply to the HTML element using a JSON object or a state reference to a dictionary.", + validator: validatorObjectRecordNotNested, }, attrs: { name: "Attributes", default: null, type: FieldType.Object, desc: "Set additional HTML attributes for the element using a JSON object or a dictionary via a state reference.", + validator: validatorObjectRecordNotNested, }, htmlInside: { name: "HTML inside", diff --git a/src/ui/src/components/core/other/CorePagination.vue b/src/ui/src/components/core/other/CorePagination.vue index f91817c97..b27b44245 100644 --- a/src/ui/src/components/core/other/CorePagination.vue +++ b/src/ui/src/components/core/other/CorePagination.vue @@ -73,6 +73,7 @@ <script lang="ts"> import { FieldType } from "@/writerTypes"; +import { validatorPositiveNumber } from "@/constants/validators"; const pageChangeStub = ` def handle_page_change(state, payload): @@ -106,18 +107,21 @@ export default { default: "1", type: FieldType.Number, desc: "The current page number.", + validator: validatorPositiveNumber, }, pageSize: { name: "Page Size", default: "10", type: FieldType.Number, desc: "The number of items per page.", + validator: validatorPositiveNumber, }, totalItems: { name: "Total Items", default: "10", type: FieldType.Number, desc: "The total number of items", + validator: validatorPositiveNumber, }, pageSizeOptions: { name: "Page Size Options", diff --git a/src/ui/src/components/core/other/CoreRepeater.vue b/src/ui/src/components/core/other/CoreRepeater.vue index b8c6ff9ca..a5107efe9 100644 --- a/src/ui/src/components/core/other/CoreRepeater.vue +++ b/src/ui/src/components/core/other/CoreRepeater.vue @@ -2,6 +2,7 @@ import { computed, h, inject } from "vue"; import { FieldType } from "@/writerTypes"; import injectionKeys from "@/injectionKeys"; +import { validatorRepeaterObject } from "@/constants/validators"; const description = "A container component that repeats its child components based on a dictionary."; @@ -23,6 +24,7 @@ export default { default: JSON.stringify(defaultRepeaterObject, null, 2), type: FieldType.Object, desc: "Include a state reference to the dictionary used for repeating the child components. Alternatively, specify a JSON object.", + validator: validatorRepeaterObject, }, keyVariable: { name: "Key variable name", diff --git a/src/ui/src/components/core/other/CoreTimer.vue b/src/ui/src/components/core/other/CoreTimer.vue index 316f24bd3..255150c97 100644 --- a/src/ui/src/components/core/other/CoreTimer.vue +++ b/src/ui/src/components/core/other/CoreTimer.vue @@ -10,6 +10,7 @@ <script lang="ts"> import { FieldType } from "@/writerTypes"; import { accentColor, cssClasses } from "@/renderer/sharedStyleFields"; +import { validatorPositiveNumber } from "@/constants/validators"; const description = "A component that emits an event repeatedly at specified time intervals, enabling time-based refresh."; @@ -31,6 +32,7 @@ export default { desc: "How much time to wait between ticks. A tick is considered finished when its event is handled.", default: "200", type: FieldType.Number, + validator: validatorPositiveNumber, }, isActive: { name: "Active", diff --git a/src/ui/src/components/core/other/CoreWebcamCapture.vue b/src/ui/src/components/core/other/CoreWebcamCapture.vue index 31485288a..4f8ebe396 100644 --- a/src/ui/src/components/core/other/CoreWebcamCapture.vue +++ b/src/ui/src/components/core/other/CoreWebcamCapture.vue @@ -39,6 +39,7 @@ import { import WdsButton from "@/wds/WdsButton.vue"; import WdsDropdownInput from "@/wds/WdsDropdownInput.vue"; import { useLogger } from "@/composables/useLogger"; +import { validatorPositiveNumber } from "@/constants/validators"; const description = "A user input component that allows users to capture images using their webcam."; @@ -70,6 +71,7 @@ export default { default: "200", desc: "Set to 0 for manual capture.", type: FieldType.Number, + validator: validatorPositiveNumber, }, buttonColor, buttonTextColor, diff --git a/src/ui/src/constants/validator.spec.ts b/src/ui/src/constants/validator.spec.ts new file mode 100644 index 000000000..f9a800d67 --- /dev/null +++ b/src/ui/src/constants/validator.spec.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { + getJsonSchemaValidator, + validatorChatBotMessage, + validatorCssClassname, + validatorCssSize, + validatorRepeaterObject, +} from "./validators"; + +describe("validators", () => { + describe("CSS Classname", () => { + const validator = getJsonSchemaValidator(validatorCssClassname); + + it.each([ + "", + "foo bar", + "foo", + "foo bar baz", + " foo bar ", + "foo123 bar_baz", + "class1 class2", + "foo-bar", + "foo bar-baz", + ])("should be valid class names: %s", (value) => { + expect(validator(value)).toBe(true); + }); + + it.each(["123", "-abc"])( + "should be invalid class names: %s", + (value) => { + expect(validator(value)).toBe(false); + }, + ); + }); + + describe("CSS size", () => { + const validator = getJsonSchemaValidator(validatorCssSize); + + it.each(["", "12px", "1rem", "2vh"])( + "should be valid size: %s", + (value) => { + expect(validator(value)).toBe(true); + }, + ); + + it.each(["px", "vh"])("should be invalid class names: %s", (value) => { + expect(validator(value)).toBe(false); + }); + }); + + describe("repeater", () => { + const validator = getJsonSchemaValidator(validatorRepeaterObject); + + it("should validate object", () => { + expect(validator({ a: { foo: "bar" }, b: { foo: "bar" } })).toBe( + true, + ); + }); + + it("should validate array", () => { + expect(validator([{ foo: "bar" }, { foo: "bar" }])).toBe(true); + }); + }); + + describe("chatbot message", () => { + const validator = getJsonSchemaValidator(validatorChatBotMessage); + + it("should not validate message without role", () => { + expect(validator({ content: "hello" })).toBe(false); + }); + + it("should validate simple message", () => { + expect( + validator({ + role: "assistant", + content: "hello", + }), + ).toBe(true); + }); + + it("should validate simple message with more fields", () => { + expect( + validator({ + role: "assistant", + content: "hello", + foo: "bar", + }), + ).toBe(true); + }); + + it("should validate message with tools", () => { + const result = validator({ + role: "assistant", + content: "hello", + tools: [ + { + type: "function", + function: { + name: "calculate_mean", + description: + "Calculate the mean (average) of a list of numbers.", + parameters: { + type: "object", + properties: { + numbers: { + type: "array", + items: { type: "number" }, + description: "List of numbers", + }, + }, + required: ["numbers"], + }, + }, + }, + ], + }); + expect(result).toBe(true); + }); + }); +}); diff --git a/src/ui/src/constants/validators.ts b/src/ui/src/constants/validators.ts new file mode 100644 index 000000000..2f315d428 --- /dev/null +++ b/src/ui/src/constants/validators.ts @@ -0,0 +1,232 @@ +import injectionKeys from "@/injectionKeys"; +import Ajv, { Format, type SchemaObject } from "ajv"; +import { inject } from "vue"; + +export enum ValidatorCustomFormat { + /** + * Check that the workflow key is existing + */ + WriterWorkflowKey = "writer#workflowKey", + /** + * Check it's a white spaced list of CSS classes + */ + CssClassnames = "cssClassnames", + CssSize = "cssSize", + Uri = "uri", + Uuid = "uuid", +} + +/** + * We use an URL to define the `$id` of the schema. The URL doesn't have to exist, it's only used for caching. + * @see <https://ajv.js.org/guide/managing-schemas.html#cache-key-schema-vs-key-vs-id> + */ +function generateSchemaId(path: string) { + return `https://dev.writer.com/framework/${encodeURIComponent(path)}.json`; +} + +export function buildJsonSchemaForEnum(options: string[]): SchemaObject { + return { + $id: generateSchemaId(options.join(",")), + type: "string", + enum: options, + }; +} + +export function buildJsonSchemaForNumberBetween( + minimum: number, + maximum: number, +): SchemaObject { + return { + $id: generateSchemaId(`between-${minimum}-${maximum}`), + type: "number", + minimum, + maximum, + }; +} + +export const validatorCssClassname: SchemaObject = { + $id: generateSchemaId("cssClassname"), + type: "string", + format: ValidatorCustomFormat.CssClassnames, +}; + +export const validatorCssSize: SchemaObject = { + $id: generateSchemaId("cssSize"), + type: "string", + format: ValidatorCustomFormat.CssSize, +}; + +export const validatorArrayOfString: SchemaObject = { + $id: generateSchemaId("arrayOfString"), + type: "array", + items: { type: "string" }, +}; + +export const validatorGpsLat = buildJsonSchemaForNumberBetween(-90, 90); + +export const validatorGpsLng = buildJsonSchemaForNumberBetween(-180, 180); + +export const validatorGpsMarker: SchemaObject = { + $id: generateSchemaId("gpsMarker"), + type: "object", + properties: { + name: { + type: "string", + }, + lat: validatorGpsLat, + lng: validatorGpsLng, + }, + required: ["lat", "lng", "name"], + additionalProperties: false, +}; + +export const validatorGpsMarkers: SchemaObject = { + $id: generateSchemaId("gpsMarkers"), + type: "array", + items: validatorGpsMarker, +}; + +export const validatorObjectRecordNotNested: SchemaObject = { + $id: generateSchemaId("objectRecordNotNested"), + type: "object", + patternProperties: { + "^.*$": { + type: ["string", "number", "boolean"], + }, + }, + additionalProperties: true, +}; + +export const validatorAnotatedText: SchemaObject = { + $id: generateSchemaId("anotatedText"), + type: "array", + items: { + oneOf: [ + { type: "string" }, + { + type: "array", + items: { type: "string" }, + }, + ], + }, +}; + +export const validatorRepeaterObject: SchemaObject = { + $id: generateSchemaId("repeaterObject"), + oneOf: [ + { + type: "object", + patternProperties: { + "^.*$": { + type: "object", + }, + }, + additionalProperties: true, + }, + { + type: "array", + items: { + type: "object", + }, + }, + ], +}; + +export const validatorChatBotMessage: SchemaObject = { + $id: generateSchemaId("chatBotMessage"), + type: "object", + properties: { + role: { type: "string" }, + content: { type: "string" }, + tools: { + type: "array", + items: { + type: "object", + properties: { + type: { + type: "string", + }, + }, + required: ["type"], + additionalProperties: true, + }, + }, + }, + required: ["role"], + additionalProperties: true, +}; + +export const validatorChatBotMessages: SchemaObject = { + $id: generateSchemaId("chatBotMessages"), + type: "array", + items: validatorChatBotMessage, +}; + +export const validatorPositiveNumber: SchemaObject = { + $id: generateSchemaId("positiveNumber"), + type: "number", + minimum: 0, +}; + +export const validatorUri: SchemaObject = { + $id: generateSchemaId("uri"), + type: "string", + format: "uri", +}; + +export const ajv = new Ajv({ + strict: true, + allowUnionTypes: true, +}); + +/** + * Compile and cache schema on demand + */ +export function getJsonSchemaValidator(schema: SchemaObject) { + if (schema?.$id === undefined) return ajv.compile(schema); + return ajv.getSchema(schema.$id) || ajv.compile(schema); +} + +// custom formats + +export const validatorCustomSchemas: Record< + ValidatorCustomFormat, + { format: Format; errorMessage: string } +> = { + [ValidatorCustomFormat.Uri]: { + format: /^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/)?[^\s]*$/i, + errorMessage: "must be a valid URL", + }, + [ValidatorCustomFormat.Uuid]: { + format: /^(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i, + errorMessage: "must be a valid UUID", + }, + [ValidatorCustomFormat.CssClassnames]: { + format: /(^\s*[a-zA-Z_][-\w]*(\s+[a-zA-Z_][-\w]*)*\s*$)|(^$)/, + errorMessage: "must be a valid list of CSS classes separated by spaces", + }, + [ValidatorCustomFormat.CssSize]: { + format: /(^([+-]?\d*\.?\d+)(px|em|%|vh|vw|rem|pt|pc|in|cm|mm|ex|ch|vmin|vmax|fr)$)|(^$)/, + errorMessage: "must be a valid CSS size", + }, + [ValidatorCustomFormat.WriterWorkflowKey]: { + format: { + type: "string", + validate: (workflowKey) => { + const core = inject(injectionKeys.core); + if (!core) return true; + const workflowKeys = core + .getComponents() + .filter((c) => c.type === "workflows_workflow") + .map((c) => c.content.key); + + return workflowKeys.includes(workflowKey); + }, + }, + errorMessage: "must correspond to an existing workflow key", + }, +}; + +Object.entries(validatorCustomSchemas).map(([name, definition]) => + ajv.addFormat(name, definition.format), +); diff --git a/src/ui/src/renderer/sharedStyleFields.ts b/src/ui/src/renderer/sharedStyleFields.ts index e6f990810..173d087c4 100644 --- a/src/ui/src/renderer/sharedStyleFields.ts +++ b/src/ui/src/renderer/sharedStyleFields.ts @@ -1,3 +1,7 @@ +import { + validatorCssClassname, + validatorCssSize, +} from "@/constants/validators"; import { FieldCategory, FieldType, @@ -80,6 +84,7 @@ export const cssClasses: WriterComponentDefinitionField = { type: FieldType.Text, category: FieldCategory.Style, desc: "CSS classes, separated by spaces. You can define classes in custom stylesheets.", + validator: validatorCssClassname, }; export const contentWidth: WriterComponentDefinitionField = { @@ -88,6 +93,7 @@ export const contentWidth: WriterComponentDefinitionField = { default: "100%", category: FieldCategory.Style, desc: "Configure content width using CSS units, e.g. 100px, 50%, 10vw, etc.", + validator: validatorCssSize, }; export const contentHAlign: WriterComponentDefinitionField = { diff --git a/src/ui/src/renderer/useFieldsErrors.spec.ts b/src/ui/src/renderer/useFieldsErrors.spec.ts new file mode 100644 index 000000000..09154505a --- /dev/null +++ b/src/ui/src/renderer/useFieldsErrors.spec.ts @@ -0,0 +1,121 @@ +import { describe, vi, it, expect, beforeAll } from "vitest"; +import { useFieldsErrors } from "./useFieldsErrors"; +import { computed, ref } from "vue"; +import { generateCore } from "@/core"; +import { + Core, + FieldType, + InstancePath, + WriterComponentDefinition, +} from "@/writerTypes"; +import { validatorCustomSchemas } from "@/constants/validators"; + +const getEvaluatedFields = vi.fn(); + +vi.mock("./useEvaluator", () => ({ + useEvaluator: () => ({ getEvaluatedFields }), +})); + +describe(useFieldsErrors.name, () => { + const instancePath = computed<InstancePath>(() => [ + { + componentId: "1", + instanceNumber: 0, + }, + ]); + let core: Core; + + const dummyComponent: WriterComponentDefinition = { + name: "dummmy component", + description: "", + }; + + beforeAll(() => { + core = generateCore(); + // @ts-expect-error return a dummy mock + vi.spyOn(core, "getComponentById").mockReturnValue({}); + }); + + it("should validate a field as number", () => { + vi.spyOn(core, "getComponentDefinition").mockReturnValue({ + ...dummyComponent, + fields: { + value: { + name: "value", + type: FieldType.Number, + validator: { + type: "number", + minimum: 10, + }, + }, + }, + }); + + const value = ref(1); + getEvaluatedFields.mockReturnValue({ value }); + + const errors = useFieldsErrors(core, instancePath); + expect(errors.value).toStrictEqual({ value: "must be >= 10" }); + + value.value = 10; + + expect(errors.value).toStrictEqual({ value: undefined }); + }); + + it("should validate a field as string", () => { + vi.spyOn(core, "getComponentDefinition").mockReturnValue({ + ...dummyComponent, + fields: { + value: { + name: "value", + type: FieldType.Text, + validator: { + type: "string", + format: "uri", + }, + }, + }, + }); + + const value = ref("test"); + getEvaluatedFields.mockReturnValue({ value }); + + const errors = useFieldsErrors(core, instancePath); + expect(errors.value).toStrictEqual({ + value: validatorCustomSchemas.uri.errorMessage, + }); + + value.value = "https://writer.com"; + + expect(errors.value).toStrictEqual({ value: undefined }); + }); + + it("should validate a field as options", () => { + vi.spyOn(core, "getComponentDefinition").mockReturnValue({ + ...dummyComponent, + fields: { + value: { + name: "value", + type: FieldType.Text, + options: { + a: "A", + b: "B", + c: "C", + }, + }, + }, + }); + + const value = ref("test"); + getEvaluatedFields.mockReturnValue({ value }); + + const errors = useFieldsErrors(core, instancePath); + expect(errors.value).toStrictEqual({ + value: "must be equal to one of the allowed values: a, b, c", + }); + + value.value = "a"; + + expect(errors.value).toStrictEqual({ value: undefined }); + }); +}); diff --git a/src/ui/src/renderer/useFieldsErrors.ts b/src/ui/src/renderer/useFieldsErrors.ts new file mode 100644 index 000000000..f6217bf8f --- /dev/null +++ b/src/ui/src/renderer/useFieldsErrors.ts @@ -0,0 +1,98 @@ +import type { + Core, + InstancePath, + WriterComponentDefinitionField, +} from "@/writerTypes"; +import { computed, ComputedRef } from "vue"; +import { useEvaluator } from "./useEvaluator"; +import { + buildJsonSchemaForEnum, + getJsonSchemaValidator, + ValidatorCustomFormat, + validatorCustomSchemas, +} from "@/constants/validators"; +import type { ErrorObject } from "ajv"; + +export function useFieldsErrors( + wf: Core, + instancePath: ComputedRef<InstancePath>, +) { + const { getEvaluatedFields } = useEvaluator(wf); + + const componentFields = computed(() => { + const { componentId } = instancePath.value.at(-1); + const component = wf.getComponentById(componentId); + if (!component) return {}; + + return wf.getComponentDefinition(component.type).fields ?? {}; + }); + + const evaluatedFields = computed(() => + getEvaluatedFields(instancePath.value), + ); + + return computed(() => { + return Object.entries(componentFields.value).reduce( + (acc, [key, definition]) => { + const value = evaluatedFields.value[key].value; + acc[key] = computeFieldErrors(definition, value); + return acc; + }, + {}, + ); + }); +} + +function computeFieldErrors( + field: WriterComponentDefinitionField, + value: unknown, +) { + let schema = field.validator; + + if ( + schema === undefined && + typeof field.options === "object" && + field.options !== null && + Object.keys(field.options).length > 0 + ) { + // set an automatic enum schema for options fields + schema = buildJsonSchemaForEnum(Object.keys(field.options)); + } + + if (schema === undefined) return undefined; + + const validate = getJsonSchemaValidator(schema); + + const valid = validate(value); + + if (valid || validate.errors === undefined) return undefined; + + return formatAjvErrors(validate.errors); +} + +function formatAjvError(error: ErrorObject): string { + if ( + error.keyword === "format" && + Object.values(ValidatorCustomFormat).includes(error.params.format) + ) { + return validatorCustomSchemas[error.params.format].errorMessage; + } + + let message = ""; + + if (error.instancePath) { + message += `${error.instancePath} `; + } + + message += error.message; + + if (Array.isArray(error.params?.allowedValues)) { + message += `: ${error.params.allowedValues.join(", ")}`; + } + + return message; +} + +function formatAjvErrors(errors: ErrorObject[]): string { + return errors.map(formatAjvError).join("\n"); +} diff --git a/src/ui/src/wds/WdsFieldWrapper.vue b/src/ui/src/wds/WdsFieldWrapper.vue index e369038da..1c24a937d 100644 --- a/src/ui/src/wds/WdsFieldWrapper.vue +++ b/src/ui/src/wds/WdsFieldWrapper.vue @@ -21,7 +21,8 @@ </WdsButton> </div> <div class="WdsFieldWrapper__slot"><slot></slot></div> - <div v-if="hint" class="WdsFieldWrapper__hint">{{ hint }}</div> + <p v-if="error" class="WdsFieldWrapper__error">{{ error }}</p> + <p v-if="hint" class="WdsFieldWrapper__hint">{{ hint }}</p> </div> </template> @@ -32,6 +33,7 @@ defineProps({ label: { type: String, required: false, default: undefined }, unit: { type: String, required: false, default: undefined }, hint: { type: String, required: false, default: undefined }, + error: { type: String, required: false, default: undefined }, helpButton: { type: [String, Boolean], required: false, @@ -71,12 +73,23 @@ defineEmits({ color: var(--secondaryTextColor); } -.WdsFieldWrapper__hint { - color: var(--secondaryTextColor); +.WdsFieldWrapper__hint, +.WdsFieldWrapper__error { margin-top: 4px; font-family: Poppins; font-size: 12px; font-weight: 400; line-height: 160%; } +.WdsFieldWrapper__error:first-letter { + text-transform: capitalize; +} + +.WdsFieldWrapper__hint { + color: var(--secondaryTextColor); +} + +.WdsFieldWrapper__error { + color: var(--builderErrorColor); +} </style> diff --git a/src/ui/src/wds/WdsTextInput.vue b/src/ui/src/wds/WdsTextInput.vue index 858df72f1..763bcb0a0 100644 --- a/src/ui/src/wds/WdsTextInput.vue +++ b/src/ui/src/wds/WdsTextInput.vue @@ -1,8 +1,9 @@ <template> <div v-if="leftIcon" - class="WdsTextInput WdsTextInput--leftIcon colorTransformer" v-bind="$attrs" + class="WdsTextInput WdsTextInput--leftIcon colorTransformer" + :aria-invalid="invalid" @click="input.focus()" > <i class="material-symbols-outlined">{{ leftIcon }}</i> @@ -13,6 +14,7 @@ v-bind="$attrs" ref="input" v-model="model" + :aria-invalid="invalid" class="WdsTextInput colorTransformer" /> </template> @@ -27,6 +29,7 @@ defineOptions({ inheritAttrs: false }); defineProps({ leftIcon: { type: String, required: false, default: undefined }, + invalid: { type: Boolean, required: false }, }); defineExpose({ @@ -100,4 +103,8 @@ function focus() { box-shadow: none; outline: none; } + +.WdsTextInput[aria-invalid="true"] { + border-color: var(--wdsColorOrange5); +} </style> diff --git a/src/ui/src/wds/WdsTextareaInput.vue b/src/ui/src/wds/WdsTextareaInput.vue index 362254b4a..d53e0c99f 100644 --- a/src/ui/src/wds/WdsTextareaInput.vue +++ b/src/ui/src/wds/WdsTextareaInput.vue @@ -1,5 +1,5 @@ <template> - <textarea ref="input" v-model="model"></textarea> + <textarea ref="input" v-model="model" :aria-invalid="invalid"></textarea> </template> <script setup lang="ts"> @@ -7,6 +7,10 @@ import { ref } from "vue"; const model = defineModel<string>(); +defineProps({ + invalid: { type: Boolean, required: false }, +}); + defineExpose({ focus, getSelection, @@ -52,4 +56,7 @@ textarea:focus { border: 1px solid var(--softenedAccentColor); box-shadow: 0px 0px 0px 3px rgba(81, 31, 255, 0.05); } +textarea[aria-invalid="true"] { + border-color: var(--wdsColorOrange5); +} </style> diff --git a/src/ui/src/writerTypes.ts b/src/ui/src/writerTypes.ts index 4eb9d554a..fc6d1f0a5 100644 --- a/src/ui/src/writerTypes.ts +++ b/src/ui/src/writerTypes.ts @@ -1,6 +1,7 @@ import type { Component as VueComponent } from "vue"; import { generateCore } from "./core"; import { generateBuilderManager } from "./builder/builderManager"; +import type { SchemaObject } from "ajv"; export type Core = ReturnType<typeof generateCore>; @@ -50,13 +51,11 @@ export type InstancePathItem = { /** * Details the full path, including all ancestors, of a unique instance of a Component. */ - export type InstancePath = InstancePathItem[]; /** * Defines component structure and behaviour. Included in Component templates. */ - export type WriterComponentDefinitionField = { /** Display name */ name: string; @@ -77,6 +76,7 @@ export type WriterComponentDefinitionField = { category?: FieldCategory; /** Use the value of this field as a CSS variable */ applyStyleVariable?: boolean; + validator?: SchemaObject; }; export type WriterComponentDefinition = { diff --git a/src/ui/tsconfig.json b/src/ui/tsconfig.json index 42bc7360c..b5313fc35 100644 --- a/src/ui/tsconfig.json +++ b/src/ui/tsconfig.json @@ -3,6 +3,8 @@ "target": "esnext", "module": "esnext", "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, "importHelpers": true, "isolatedModules": true, "noEmit": true, diff --git a/src/writer/blocks/httprequest.py b/src/writer/blocks/httprequest.py index de2642236..e5d9c7a6b 100644 --- a/src/writer/blocks/httprequest.py +++ b/src/writer/blocks/httprequest.py @@ -31,16 +31,33 @@ def register(cls, type: str): "PATCH": "PATCH", "DELETE": "DELETE" }, - "default": "GET" + "default": "GET", + "validator": { + "type": "string", + "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"], + } }, "url": { "name": "URL", "type": "Text", + "validator": { + "type": "string", + "format": "uri", + } }, "headers": { "name": "Headers", "type": "Key-Value", "default": "{}", + "validator": { + "type": "object", + "patternProperties": { + "^.*$": { + "type": ["string", "number", "boolean"], + }, + }, + "additionalProperties": True, + } }, "body": { "name": "Body", diff --git a/src/writer/blocks/runworkflow.py b/src/writer/blocks/runworkflow.py index 00e1189ec..c7c372fae 100644 --- a/src/writer/blocks/runworkflow.py +++ b/src/writer/blocks/runworkflow.py @@ -18,6 +18,10 @@ def register(cls, type: str): "workflowKey": { "name": "Workflow key", "type": "Text", + "validator": { + "type": "string", + "format": "writer#workflowKey", + } }, "payload": { "name": "Payload", diff --git a/src/writer/blocks/writeraddchatmessage.py b/src/writer/blocks/writeraddchatmessage.py index e4d8ebe24..12574a131 100644 --- a/src/writer/blocks/writeraddchatmessage.py +++ b/src/writer/blocks/writeraddchatmessage.py @@ -23,7 +23,15 @@ def register(cls, type: str): "message": { "name": "Message", "type": "Object", - "init": '{ "role": "assistant", "content": "Hello" }' + "init": '{ "role": "assistant", "content": "Hello" }', + "validator": { + "type": "object", + "properties": { + "role": { "type": "string" }, + "content": { "type": "string" }, + }, + "additionalProperties": False, + } } }, "outs": { diff --git a/src/writer/blocks/writeraddtokg.py b/src/writer/blocks/writeraddtokg.py index dcaa415f0..1b84c905e 100644 --- a/src/writer/blocks/writeraddtokg.py +++ b/src/writer/blocks/writeraddtokg.py @@ -20,13 +20,20 @@ def register(cls, type: str): "graphId": { "name": "Graph id", "type": "Text", - "desc": "The id for an existing knowledge graph. It has a UUID format." + "desc": "The id for an existing knowledge graph. It has a UUID format.", + "validator": { + "type": "string", + "format": "uuid", + } }, "files": { "name": "Files", "type": "Object", "default": "[]", - "desc": "A list of files to be uploaded and added to the knowledge graph. You can use files uploaded via the File Input component or specify dictionaries with data, type and name." + "desc": "A list of files to be uploaded and added to the knowledge graph. You can use files uploaded via the File Input component or specify dictionaries with data, type and name.", + "validator": { + "type": "array", + } }, }, "outs": { diff --git a/src/writer/blocks/writercompletion.py b/src/writer/blocks/writercompletion.py index d1980397d..4332efa48 100644 --- a/src/writer/blocks/writercompletion.py +++ b/src/writer/blocks/writercompletion.py @@ -29,7 +29,12 @@ def register(cls, type: str): "temperature": { "name": "Temperature", "type": "Number", - "default": "0.7" + "default": "0.7", + "validator": { + "type": "number", + "minimum": 0, + "maximum": 1, + } } }, "outs": { diff --git a/src/writer/blocks/writerinitchat.py b/src/writer/blocks/writerinitchat.py index de560420d..189002a99 100644 --- a/src/writer/blocks/writerinitchat.py +++ b/src/writer/blocks/writerinitchat.py @@ -30,7 +30,12 @@ def register(cls, type: str): "temperature": { "name": "Temperature", "type": "Number", - "default": "0.7" + "default": "0.7", + "validator": { + "type": "number", + "minimum": 0, + "maximum": 1, + } } }, "outs": { diff --git a/src/writer/blocks/writernocodeapp.py b/src/writer/blocks/writernocodeapp.py index 1f505739d..249658bb3 100644 --- a/src/writer/blocks/writernocodeapp.py +++ b/src/writer/blocks/writernocodeapp.py @@ -18,7 +18,11 @@ def register(cls, type: str): "appId": { "name": "App Id", "type": "Text", - "desc": "The app id can be found in the app's URL. It has a UUID format." + "desc": "The app id can be found in the app's URL. It has a UUID format.", + "validator": { + "type": "string", + "format": "uuid", + } }, "appInputs": { "name": "App inputs", diff --git a/tests/e2e/tests/builderFieldValidation.spec.ts b/tests/e2e/tests/builderFieldValidation.spec.ts new file mode 100644 index 000000000..dbbec0cae --- /dev/null +++ b/tests/e2e/tests/builderFieldValidation.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Builder field validation", () => { + let url: string; + + test.beforeAll(async ({ request }) => { + const response = await request.post(`/preset/section`); + expect(response.ok()).toBeTruthy(); + ({ url } = await response.json()); + }); + + test.afterAll(async ({ request }) => { + await request.delete(url); + }); + + test.beforeEach(async ({ page }) => { + await page.goto(url, { waitUntil: "domcontentloaded" }); + test.setTimeout(5000); + }); + + test("should display error for invalid button fields", async ({ page }) => { + await page + .locator(`.BuilderSidebarToolkit [data-component-type="button"]`) + .dragTo(page.locator(".CoreSection")); + await page.locator(`button.CoreButton.component`).click(); + + // is disabled + + const isDisabledInput = page.locator( + '.BuilderFieldsText[data-automation-key="isDisabled"] input', + ); + + await isDisabledInput.fill("maybe"); + expect(await isDisabledInput.getAttribute("aria-invalid")).toBe("true"); + + await isDisabledInput.fill("yes"); + expect(await isDisabledInput.getAttribute("aria-invalid")).toBe("false"); + + // css classes + + const cssClasses = page.locator( + '.BuilderFieldsText[data-automation-key="cssClasses"] input', + ); + await cssClasses.fill("1234"); + expect(await cssClasses.getAttribute("aria-invalid")).toBe("true"); + + await cssClasses.fill("class1 class2"); + expect(await cssClasses.getAttribute("aria-invalid")).toBe("false"); + }); + + test("should display error for invalid multiselectinput fields", async ({ + page, + }) => { + await page + .locator( + `.BuilderSidebarToolkit [data-component-type="multiselectinput"]`, + ) + .dragTo(page.locator(".CoreSection")); + await page.locator(`.CoreMultiselectInput.component`).click(); + + // maximum count + + const maximunCountInput = page.locator( + '.BuilderFieldsText[data-automation-key="maximumCount"] input', + ); + + await maximunCountInput.fill("-1"); + expect(await maximunCountInput.getAttribute("aria-invalid")).toBe("true"); + + await maximunCountInput.fill("2"); + expect(await maximunCountInput.getAttribute("aria-invalid")).toBe("false"); + + // options + + await page.locator(".BuilderFieldsOptions button").nth(1).click(); + + const optionsTextarea = page.locator( + '.BuilderFieldsObject[data-automation-key="options"] textarea', + ); + await optionsTextarea.fill(JSON.stringify(true)); + expect(await optionsTextarea.getAttribute("aria-invalid")).toBe("true"); + + await optionsTextarea.fill(JSON.stringify({ a: "A", b: "B" })); + expect(await optionsTextarea.getAttribute("aria-invalid")).toBe("false"); + }); +});