fix(nocode): KeyValue Pairs have a bug (#10998)

This commit is contained in:
Barthélémy Ledoux
2025-09-02 14:19:55 +02:00
committed by GitHub
parent d92cc099c7
commit 8acbc8ba03
39 changed files with 427 additions and 418 deletions

View File

@@ -89,6 +89,7 @@ export default [
{
// Enforce the use of the <script setup> block in components within these paths
files: [components("filter"), components("code")],
ignores: [components("code/components/tasks")],
rules: {"vue/component-api-style": ["error", ["script-setup"]]},
},
{

View File

@@ -6,25 +6,25 @@
/>
<el-form v-else label-position="top">
<TaskWrapper :key="v.fieldKey" v-for="(v) in fieldsFromSchemaTop" :merge="shouldMerge(v.schema)" :transparent="v.fieldKey === 'inputs'">
<Wrapper :key="v.fieldKey" v-for="(v) in fieldsFromSchemaTop" :merge="shouldMerge(v.schema)" :transparent="v.fieldKey === 'inputs'">
<template #tasks>
<TaskObjectField
v-bind="v"
@update:model-value="(val) => onTaskUpdateField(v.fieldKey, val)"
/>
</template>
</TaskWrapper>
</Wrapper>
<hr class="my-4">
<TaskWrapper :key="v.fieldKey" v-for="(v) in fieldsFromSchemaRest" :merge="shouldMerge(v.schema)" :transparent="SECTIONS_IDS.includes(v.fieldKey)">
<Wrapper :key="v.fieldKey" v-for="(v) in fieldsFromSchemaRest" :merge="shouldMerge(v.schema)" :transparent="SECTIONS_IDS.includes(v.fieldKey)">
<template #tasks>
<TaskObjectField
v-bind="v"
@update:model-value="(val) => onTaskUpdateField(v.fieldKey, val)"
/>
</template>
</TaskWrapper>
</Wrapper>
</el-form>
</div>
</div>
@@ -37,8 +37,8 @@
import {removeNullAndUndefined} from "./utils/cleanUp";
import Task from "./segments/Task.vue";
import TaskWrapper from "../flows/tasks/TaskWrapper.vue";
import TaskObjectField from "../flows/tasks/TaskObjectField.vue";
import Wrapper from "./components/tasks/Wrapper.vue";
import TaskObjectField from "./components/tasks/TaskObjectField.vue";
import {
BLOCK_SCHEMA_PATH_INJECTION_KEY,
CLOSE_TASK_FUNCTION_INJECTION_KEY,

View File

@@ -25,7 +25,7 @@
/>
</el-col>
<el-col :span="16" class="d-flex">
<slot name="value-field" :value="pair[1]" :key="pair[0]">
<slot name="value-field" :value="pair[1]" :key="pair[0]" :index="index" :update-value="updateValue">
<InputText
:model-value="pair[1]"
:placeholder="t('value')"
@@ -109,15 +109,19 @@
function updateModel() {
localEdit.value = true;
emit("update:modelValue", Object.fromEntries(internalPairs.value.filter(pair => pair[0] !== "" && pair[1] !== undefined)));
const newVal = Object.fromEntries(internalPairs.value.filter(pair => pair[0] !== "" && pair[1] !== undefined))
emit("update:modelValue", newVal);
}
function handleKeyInput(index: number, newValue: string) {
internalPairs.value[index][0] = newValue.toString();
updateModel()
};
function addPair() {
internalPairs.value.push(["", undefined])
updateModel()
};
@@ -128,6 +132,7 @@
};
function updateValue (pairId: number, newValue: string){
internalPairs.value[pairId][1] = newValue;
updateModel()
};

View File

@@ -40,9 +40,6 @@ export default defineComponent({
isRequired(key: string) {
return this.schema?.required?.includes(key);
},
onShow() {
},
onInput(value:any) {
this.$emit("update:modelValue", collapseEmptyValues(value));
}
@@ -63,7 +60,7 @@ export default defineComponent({
return YAML_UTILS.stringify(this.values);
},
info() {
return `${this.schema?.title || this.schema?.type}`
return this.schema?.title ?? this.schema?.type
},
isValid() {
return true;

View File

@@ -37,7 +37,7 @@
</template>
<script>
import Task from "./Task";
import Task from "./MixinTask";
import {TaskIcon} from "@kestra-io/ui-libs";
import getTaskComponent from "./getTaskComponent";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";

View File

@@ -16,7 +16,7 @@
/>
</el-col>
<el-col :span="items.length > 1 ? 20 : 22" class="pe-2">
<TaskWrapper :merge="!needWrapper">
<Wrapper :merge="!needWrapper">
<template #tasks>
<component
:key="'array-' + index"
@@ -30,7 +30,7 @@
@update:model-value="handleInput($event, index)"
/>
</template>
</TaskWrapper>
</Wrapper>
</el-col>
<el-col :span="2" class="d-flex align-items-center justify-content-center delete">
<DeleteOutline @click="removeItem(index)" />
@@ -42,12 +42,12 @@
<script setup lang="ts">
import {computed, inject, provide, ref} from "vue";
import {DeleteOutline, ChevronUp, ChevronDown} from "../../code/utils/icons";
import {DeleteOutline, ChevronUp, ChevronDown} from "../../utils/icons";
import Add from "../../code/components/Add.vue";
import Add from "../Add.vue";
import getTaskComponent from "./getTaskComponent";
import TaskWrapper from "./TaskWrapper.vue";
import {BLOCK_SCHEMA_PATH_INJECTION_KEY} from "../../code/injectionKeys";
import Wrapper from "./Wrapper.vue";
import {BLOCK_SCHEMA_PATH_INJECTION_KEY} from "../../injectionKeys";
defineOptions({inheritAttrs: false});
@@ -137,7 +137,7 @@
</script>
<style scoped lang="scss">
@import "../../code/styles/code.scss";
@import "../../styles/code.scss";
.disabled {
opacity: 0.5;

View File

@@ -53,10 +53,10 @@
<script setup>
import getTaskComponent from "./getTaskComponent";
import Help from "vue-material-design-icons/HelpBox.vue";
import Markdown from "../../layout/Markdown.vue";
import Markdown from "../../../layout/Markdown.vue";
</script>
<script>
import Task from "./Task";
import Task from "./MixinTask";
export default {
name: "TaskBasic",
@@ -147,7 +147,7 @@
</script>
<style lang="scss" scoped>
@import "../../code/styles/code.scss";
@import "../../styles/code.scss";
.type-tag {
background-color: var(--ks-tag-background);

View File

@@ -17,7 +17,7 @@
</script>
<script>
import Task from "./Task";
import Task from "./MixinTask";
export default {
inheritAttrs: false,

View File

@@ -39,11 +39,11 @@
<script lang="ts" setup>
import {computed, ref, watch} from "vue";
import {useI18n} from "vue-i18n";
import {DeleteOutline} from "../../code/utils/icons";
import {DeleteOutline} from "../../utils/icons";
import InputText from "../../code/components/inputs/InputText.vue";
import InputText from "../inputs/InputText.vue";
import TaskExpression from "./TaskExpression.vue";
import Add from "../../code/components/Add.vue";
import Add from "../Add.vue";
import getTaskComponent from "./getTaskComponent";
import debounce from "lodash/debounce";
@@ -153,5 +153,5 @@
</script>
<style scoped lang="scss">
@import "../../code/styles/code.scss";
@import "../../styles/code.scss";
</style>

View File

@@ -16,7 +16,7 @@
</el-select>
</template>
<script>
import Task from "./Task";
import Task from "./MixinTask";
export default {
mixins: [Task],
};

View File

@@ -11,8 +11,8 @@
/>
</template>
<script lang="ts" setup>
import {collapseEmptyValues} from "./Task";
import Editor from "../../../components/inputs/Editor.vue";
import {collapseEmptyValues} from "./MixinTask";
import Editor from "../../../../components/inputs/Editor.vue";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import {computed, ref} from "vue";

View File

@@ -1,10 +1,10 @@
<template>
<InputPair v-model="protectedModel">
<template #value-field="{value, key}">
<template #value-field="{value, updateValue, index}">
<TaskString
v-bind="$attrs"
:model-value="value"
@update:model-value="(changed: any) => updateValue(key, changed)"
@update:model-value="(changed: any) => updateValue(index, changed)"
/>
</template>
</InputPair>
@@ -12,14 +12,11 @@
<script lang="ts" setup>
import {computed} from "vue";
import {PairField} from "../../code/utils/types";
import InputPair from "../../code/components/inputs/InputPair.vue";
import {PairField} from "../../utils/types";
import InputPair from "../inputs/InputPair.vue";
// @ts-expect-error no typings for taskString yet
import TaskString from "./TaskString.vue";
const emit = defineEmits<{
(e: "update:modelValue", value: PairField["value"] | string): void;
}>();
const model = defineModel<PairField["value"] | string>();
const protectedModel = computed({
@@ -30,13 +27,4 @@
model.value = value
}
})
function updateValue(key: string, changed: string){
if(!model.value || typeof model.value === "string"){
return
}
model.value[key] = changed
emit("update:modelValue", model.value);
}
</script>

View File

@@ -13,9 +13,9 @@
<script setup lang="ts">
import {computed, inject, ref} from "vue";
import Collapse from "../../code/components/collapse/Collapse.vue";
import {BLOCK_SCHEMA_PATH_INJECTION_KEY} from "../../code/injectionKeys";
import {useFlowStore} from "../../../stores/flow";
import Collapse from "../collapse/Collapse.vue";
import {BLOCK_SCHEMA_PATH_INJECTION_KEY} from "../../injectionKeys";
import {useFlowStore} from "../../../../stores/flow";
const blockSchemaPath = inject(BLOCK_SCHEMA_PATH_INJECTION_KEY, ref(""))
@@ -58,7 +58,7 @@
</script>
<style scoped lang="scss">
@import "../../code/styles/code.scss";
@import "../../styles/code.scss";
.tasks-wrapper {
width: 100%;

View File

@@ -9,10 +9,10 @@
</template>
<script>
import {mapStores} from "pinia";
import Task from "./Task";
import NamespaceSelect from "../../namespaces/components/NamespaceSelect.vue";
import Task from "./MixinTask";
import NamespaceSelect from "../../../namespaces/components/NamespaceSelect.vue";
import {useFlowStore} from "../../../stores/flow";
import {useFlowStore} from "../../../../stores/flow";
export default {
components: {NamespaceSelect},
mixins: [Task],

View File

@@ -12,7 +12,7 @@
</template>
<script>
import Task from "./Task"
import Task from "./MixinTask"
export default {
mixins: [Task],
computed: {

View File

@@ -0,0 +1,255 @@
<template>
<el-form label-position="top" class="w-100">
<template v-if="sortedProperties">
<template v-for="[fieldKey, fieldSchema] in protectedRequiredProperties" :key="fieldKey">
<Wrapper :merge>
<template #tasks>
<TaskObjectField v-bind="fieldProps(fieldKey, fieldSchema)" />
</template>
</Wrapper>
</template>
<el-collapse v-model="activeNames" v-if="requiredProperties.length && (optionalProperties?.length || deprecatedProperties?.length || connectionProperties?.length)" class="collapse">
<el-collapse-item name="connection" v-if="connectionProperties?.length" :title="t('no_code.sections.connection')">
<template v-for="[fieldKey, fieldSchema] in connectionProperties" :key="fieldKey">
<Wrapper>
<template #tasks>
<TaskObjectField v-bind="fieldProps(fieldKey, fieldSchema)" />
</template>
</Wrapper>
</template>
</el-collapse-item>
<el-collapse-item name="optional" v-if="optionalProperties?.length" :title="t('no_code.sections.optional')">
<template v-for="[fieldKey, fieldSchema] in optionalProperties" :key="fieldKey">
<Wrapper>
<template #tasks>
<TaskObjectField v-bind="fieldProps(fieldKey, fieldSchema)" />
</template>
</Wrapper>
</template>
</el-collapse-item>
<el-collapse-item name="deprecated" v-if="deprecatedProperties?.length" :title="t('no_code.sections.deprecated')">
<template v-for="[fieldKey, fieldSchema] in deprecatedProperties" :key="fieldKey">
<Wrapper>
<template #tasks>
<TaskObjectField v-bind="fieldProps(fieldKey, fieldSchema)" />
</template>
</Wrapper>
</template>
</el-collapse-item>
</el-collapse>
</template>
<template v-else-if="typeof modelValue === 'object' && modelValue !== null && !Array.isArray(modelValue)">
<task-dict
:model-value="modelValue"
:task="task"
@update:model-value="
(value) => $emit('update:modelValue', value)
"
:root="root"
:schema="schema ?? {}"
:required="required"
:definitions="definitions"
/>
</template>
</el-form>
</template>
<script setup lang="ts">
import {computed, ref} from "vue";
import {useI18n} from "vue-i18n";
import TaskDict from "./TaskDict.vue";
import Wrapper from "./Wrapper.vue";
import TaskObjectField from "./TaskObjectField.vue";
import {collapseEmptyValues} from "./MixinTask";
defineOptions({
name: "TaskObject",
inheritAttrs: false,
});
type Model = Record<string, any> | undefined;
type Schema = { required?: string[]; [k: string]: any } | undefined;
const props = defineProps<{
merge?: boolean;
properties?: any;
metadataInputs?: boolean;
modelValue?: Model;
required?: boolean;
schema?: Schema;
definitions?: any;
// passed-through by parent in some contexts
task?: any;
root?: string;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: Model): void;
}>();
const {t} = useI18n();
const activeNames = ref<string[]>([]);
const FIRST_FIELDS = ["id", "forced", "on", "type"] as const;
type Entry = [string, any];
function sortProperties(properties: Entry[], required?: string[]): Entry[] {
if (!properties?.length) return [];
return properties.slice().sort((a, b) => {
if (FIRST_FIELDS.includes(a[0] as any)) return -1;
if (FIRST_FIELDS.includes(b[0] as any)) return 1;
const aRequired = (required || []).includes(a[0]);
const bRequired = (required || []).includes(b[0]);
if (aRequired && !bRequired) return -1;
if (!aRequired && bRequired) return 1;
const aDefault = "default" in a[1];
const bDefault = "default" in b[1];
if (aDefault && !bDefault) return 1;
if (!aDefault && bDefault) return -1;
return a[0].localeCompare(b[0]);
});
}
function isDeprecated(value: any) {
if(value?.allOf){
return value.allOf.some(isDeprecated);
}
return value?.$deprecated;
}
const filteredProperties = computed<Entry[]>(() => {
const propertiesProc = (props.properties ?? props.schema?.properties);
return propertiesProc
? (Object.entries(propertiesProc) as Entry[]).filter(([key, value]) => key !== "type" && !Array.isArray(value))
: [];
});
const sortedProperties = computed<Entry[]>(() => sortProperties(filteredProperties.value, props.schema?.required));
const isRequired = (key: string) => Boolean(props.schema?.required?.includes(key));
const requiredProperties = computed<Entry[]>(() => {
return props.merge ? sortedProperties.value : sortedProperties.value.filter(([p, v]) => v && isRequired(p));
});
const protectedRequiredProperties = computed<Entry[]>(() => {
return requiredProperties.value.length ? requiredProperties.value : sortedProperties.value;
});
const optionalProperties = computed<Entry[]>(() => {
return props.merge ? [] : sortedProperties.value.filter(([p, v]) => v && !isRequired(p) && !isDeprecated(v) && v.$group !== "connection");
});
const deprecatedProperties = computed<Entry[]>(() => {
const obj = (typeof props.modelValue === "object" && props.modelValue !== null) ? (props.modelValue as Record<string, any>) : {};
return props.merge ? [] : sortedProperties.value.filter(([k, v]) => v && isDeprecated(v) && obj[k] !== undefined);
});
const connectionProperties = computed<Entry[]>(() => {
return props.merge ? [] : sortedProperties.value.filter(([p, v]) => v && v.$group === "connection" && !isRequired(p));
});
function onInput(value: any) {
emit("update:modelValue", collapseEmptyValues(value));
}
function onObjectInput(propertyName: string, value: any) {
const currentValue = (typeof props.modelValue === "object" && props.modelValue !== null ? {...(props.modelValue as Record<string, any>)} : {});
currentValue[propertyName] = value;
onInput(currentValue);
}
function fieldProps(key: string, schema: any) {
const mv = (typeof props.modelValue === "object" && props.modelValue !== null) ? (props.modelValue as Record<string, any>)[key] : undefined;
return {
modelValue: mv,
"onUpdate:modelValue": (value: any) => onObjectInput(key, value),
root: props.root,
fieldKey: key,
task: props.modelValue,
schema: schema,
definitions: props.definitions,
required: props.schema?.required,
} as const;
}
</script>
<style lang="scss">
.el-form-item__content {
.el-form-item {
width: 100%;
}
}
.el-popper.singleton-tooltip {
max-width: 300px !important;
background: var(--ks-tooltip-background);
}
</style>
<style lang="scss" scoped>
@import "../../styles/code.scss";
.el-form-item {
width: 100%;
margin-bottom: 0;
> :deep(.el-form-item__label) {
width: 100%;
display: flex;
align-items: center;
padding: 0;
}
}
.inline-wrapper {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
.inline-start {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
flex: 1 1 auto;
}
.label {
color: var(--ks-content-primary);
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
}
.type-tag {
background-color: var(--ks-tag-background-active);
color: var(--ks-tag-content);
font-size: 12px;
line-height: 20px;
padding: 0 8px;
padding-bottom: 2px;
border-radius: 8px;
text-transform: capitalize;
}
.information-icon {
color: var(--ks-content-secondary);
cursor: pointer;
}
}
</style>

View File

@@ -64,10 +64,9 @@
</template>
<script setup lang="ts">
import {computed, ref} from "vue";
import {templateRef} from "@vueuse/core";
import {computed, ref, useTemplateRef} from "vue";
import Help from "vue-material-design-icons/Information.vue";
import Markdown from "../../layout/Markdown.vue";
import Markdown from "../../../layout/Markdown.vue";
import TaskLabelWithBoolean from "./TaskLabelWithBoolean.vue";
import ClearButton from "./ClearButton.vue";
import getTaskComponent from "./getTaskComponent";
@@ -87,7 +86,7 @@
(e: "update:modelValue", value?: Record<string, any> | string | number | boolean | Array<any>): void;
}>();
const taskComponent = templateRef<{resetSelectType?: () => void}>("taskComponent");
const taskComponent = useTemplateRef<{resetSelectType?: () => void}>("taskComponent");
const isRequired = computed(() => {
return !props.disabled && props.required?.includes(props.fieldKey);// && props.schema.$required;

View File

@@ -45,12 +45,12 @@
</div>
</template>
<script setup>
import Editor from "../../../components/inputs/Editor.vue";
import InputText from "../../code/components/inputs/InputText.vue";
import Editor from "../../../../components/inputs/Editor.vue";
import InputText from "../inputs/InputText.vue";
import IconCodeBracesBox from "vue-material-design-icons/CodeBracesBox.vue";
</script>
<script>
import Task from "./Task";
import Task from "./MixinTask";
export default {
inheritAttrs: false,

View File

@@ -19,8 +19,8 @@
</template>
<script>
import {mapStores} from "pinia";
import {useFlowStore} from "../../../stores/flow";
import Task from "./Task";
import {useFlowStore} from "../../../../stores/flow";
import Task from "./MixinTask";
export default {
mixins: [Task],

View File

@@ -38,12 +38,12 @@
</div>
</template>
<script>
import Task from "./Task";
import Task from "./MixinTask";
import Plus from "vue-material-design-icons/Plus.vue";
import Minus from "vue-material-design-icons/Minus.vue";
import TaskExpression from "./TaskExpression.vue";
import {mapStores} from "pinia";
import {useCoreStore} from "../../../stores/core";
import {useCoreStore} from "../../../../stores/core";
import axios from "axios";
export default {

View File

@@ -21,8 +21,8 @@
REF_PATH_INJECTION_KEY,
CREATING_TASK_INJECTION_KEY,
BLOCK_SCHEMA_PATH_INJECTION_KEY
} from "../../code/injectionKeys";
import Element from "../../code/components/collapse/Element.vue";
} from "../../injectionKeys";
import Element from "../collapse/Element.vue";
const model = defineModel({
type: Object,

View File

@@ -13,9 +13,9 @@
<script setup lang="ts">
import {computed, inject, ref} from "vue";
import Collapse from "../../code/components/collapse/Collapse.vue";
import {BLOCK_SCHEMA_PATH_INJECTION_KEY} from "../../code/injectionKeys";
import {useFlowStore} from "../../../stores/flow";
import Collapse from "../collapse/Collapse.vue";
import {BLOCK_SCHEMA_PATH_INJECTION_KEY} from "../../injectionKeys";
import {useFlowStore} from "../../../../stores/flow";
const blockSchemaPath = inject(BLOCK_SCHEMA_PATH_INJECTION_KEY, ref())
@@ -48,7 +48,7 @@
</script>
<style scoped lang="scss">
@import "../../code/styles/code.scss";
@import "../../styles/code.scss";
.tasks-wrapper {
width: 100%;

View File

@@ -10,7 +10,7 @@
<script setup lang="ts">
import {computed, getCurrentInstance} from "vue";
import {useI18n} from "vue-i18n";
import InputText from "../../code/components/inputs/InputText.vue";
import InputText from "../inputs/InputText.vue";
const {t} = useI18n()

View File

@@ -6,7 +6,7 @@
</template>
<script lang="ts" setup>
defineOptions({name: "TaskWrapper"});
defineOptions({name: "Wrapper"});
defineProps<{merge?: boolean, transparent?: boolean}>();
</script>

View File

@@ -12,10 +12,6 @@ function getType(property: any, key?: string, schema?: any): string {
return "task"
}
if (property.$ref.includes(".conditions.")) {
return "condition"
}
if (property.$ref.includes("tasks.runners.TaskRunner")) {
return "task-runner"
}

View File

@@ -113,12 +113,6 @@
<template #label>
<code>{{ $t("concurrency") }}</code>
<br>
<task-basic
:schema="concurrencySchema"
v-model="newMetadata.concurrency"
root="concurrency"
v-if="showConcurrency"
/>
</template>
</el-form-item>
<el-form-item>
@@ -132,8 +126,6 @@
</el-form>
</template>
<script setup>
import TaskBasic from "./tasks/TaskBasic.vue";
import Pencil from "vue-material-design-icons/Pencil.vue";
import Eye from "vue-material-design-icons/Eye.vue";
import Plus from "vue-material-design-icons/Plus.vue";

View File

@@ -35,8 +35,7 @@
import {computed, inject, onActivated, provide, ref, toRaw, watch} from "vue";
import {useI18n} from "vue-i18n";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
// @ts-expect-error TaskObject can't be typed for now because of time constraints
import TaskObject from "./tasks/TaskObject.vue";
import TaskObject from "../code/components/tasks/TaskObject.vue";
import PluginSelect from "../../components/plugins/PluginSelect.vue";
import {NoCodeElement, Schemas} from "../code/utils/types";
import {

View File

@@ -1,66 +0,0 @@
<template>
<el-input :model-value="JSON.stringify(values)">
<template #append>
<el-button :icon="TextSearch" @click="isOpen = true" />
</template>
</el-input>
<drawer
v-if="isOpen"
v-model="isOpen"
:title="root"
>
<template #header>
<code>{{ root }}</code>
</template>
<el-form label-position="top">
<task-editor
ref="editor"
:section="SECTIONS.TRIGGERS"
:model-value="taskYaml"
@update:model-value="onInput"
/>
</el-form>
<template #footer>
<el-button :icon="ContentSave" @click="isOpen = false" type="primary">
{{ $t('save') }}
</el-button>
</template>
</drawer>
</template>
<script setup>
import {SECTIONS} from "@kestra-io/ui-libs";
import * as YAML_UTILS from "@kestra-io/ui-libs/flow-yaml-utils";
import TextSearch from "vue-material-design-icons/TextSearch.vue";
import ContentSave from "vue-material-design-icons/ContentSave.vue";
import TaskEditor from "../TaskEditor.vue"
import Drawer from "../../Drawer.vue"
</script>
<script>
import Task from "./Task"
export default {
mixins: [Task],
emits: ["update:modelValue"],
data() {
return {
isOpen: false,
};
},
computed: {
taskYaml() {
return YAML_UTILS.stringify(this.modelValue);
}
},
methods: {
onInput(value) {
this.$emit("update:modelValue", YAML_UTILS.parse(value));
},
},
};
</script>

View File

@@ -1,255 +0,0 @@
<template>
<el-form label-position="top" class="w-100">
<template v-if="sortedProperties">
<template v-for="[fieldKey, fieldSchema] in protectedRequiredProperties" :key="fieldKey">
<TaskWrapper :merge>
<template #tasks>
<TaskObjectField v-bind="fieldProps(fieldKey, fieldSchema)" />
</template>
</TaskWrapper>
</template>
<el-collapse v-model="activeNames" v-if="requiredProperties.length && (optionalProperties?.length || deprecatedProperties?.length || connectionProperties?.length)" class="collapse">
<el-collapse-item name="connection" v-if="connectionProperties?.length" :title="$t('no_code.sections.connection')">
<template v-for="[fieldKey, fieldSchema] in connectionProperties" :key="fieldKey">
<TaskWrapper>
<template #tasks>
<TaskObjectField v-bind="fieldProps(fieldKey, fieldSchema)" />
</template>
</TaskWrapper>
</template>
</el-collapse-item>
<el-collapse-item name="optional" v-if="optionalProperties?.length" :title="$t('no_code.sections.optional')">
<template v-for="[fieldKey, fieldSchema] in optionalProperties" :key="fieldKey">
<TaskWrapper>
<template #tasks>
<TaskObjectField v-bind="fieldProps(fieldKey, fieldSchema)" />
</template>
</TaskWrapper>
</template>
</el-collapse-item>
<el-collapse-item name="deprecated" v-if="deprecatedProperties?.length" :title="$t('no_code.sections.deprecated')">
<template v-for="[fieldKey, fieldSchema] in deprecatedProperties" :key="fieldKey">
<TaskWrapper>
<template #tasks>
<TaskObjectField v-bind="fieldProps(fieldKey, fieldSchema)" />
</template>
</TaskWrapper>
</template>
</el-collapse-item>
</el-collapse>
</template>
<template v-else>
<task-dict
:model-value="modelValue"
:task="task"
@update:model-value="
(value) => $emit('update:modelValue', value)
"
:root="root"
:schema="schema"
:required="required"
:definitions="definitions"
/>
</template>
</el-form>
</template>
<script setup>
import TaskDict from "./TaskDict.vue";
import TaskWrapper from "./TaskWrapper.vue";
import TaskObjectField from "./TaskObjectField.vue";
defineEmits(["update:modelValue"]);
</script>
<script>
import Task from "./Task";
const FIRST_FIELDS = ["id", "forced", "on", "type"];
function sortProperties(properties, required) {
if(!properties.length) {
return [];
}
return properties.sort((a, b) => {
if (FIRST_FIELDS.includes(a[0])) {
return -1;
} else if (FIRST_FIELDS.includes(b[0])) {
return 1;
}
const aRequired = (required || []).includes(
a[0],
);
const bRequired = (required || []).includes(
b[0],
);
if (aRequired && !bRequired) {
return -1;
} else if (!aRequired && bRequired) {
return 1;
}
const aDefault = "default" in a[1];
const bDefault = "default" in b[1];
if (aDefault && !bDefault) {
return 1;
} else if (!aDefault && bDefault) {
return -1;
}
return a[0].localeCompare(b[0]);
})
}
function isDeprecated(value) {
if(value?.allOf){
return value.allOf.some(isDeprecated);
}
return value?.$deprecated;
}
export default {
inheritAttrs: false,
name: "TaskObject",
mixins: [Task],
props: {
properties: {
type: Object,
default: () => ({}),
},
merge: {type: Boolean, default: false},
metadataInputs: {type: Boolean, default: false}
},
data() {
return {
activeNames: [],
};
},
computed: {
filteredProperties() {
return this.properties ? Object.entries(this.properties).filter(([key, value]) => {
return !(key === "type") && !Array.isArray(value);
}) : [];
},
sortedProperties() {
return sortProperties(this.filteredProperties, this.schema?.required);
},
requiredProperties() {
return this.merge ? this.sortedProperties : this.sortedProperties.filter(([p,v]) => v && this.isRequired(p));
},
protectedRequiredProperties(){
return this.requiredProperties.length ? this.requiredProperties : this.sortedProperties;
},
optionalProperties() {
return this.merge ? [] : this.sortedProperties.filter(([p,v]) => v && !this.isRequired(p) && !isDeprecated(v) && v.$group !== "connection");
},
deprecatedProperties() {
return this.merge ? [] : this.sortedProperties.filter(([k,v]) => v && isDeprecated(v) && this.modelValue[k] !== undefined);
},
connectionProperties() {
return this.merge ? [] : this.sortedProperties.filter(([p,v]) => v && v.$group === "connection" && !this.isRequired(p));
},
},
methods: {
onObjectInput(propertyName, value) {
const currentValue = this.modelValue || {};
currentValue[propertyName] = value;
this.onInput(currentValue);
},
isNestedProperty(key) {
return key.includes(".") ||
["interval", "maxInterval", "minInterval", "type"].includes(key);
},
fieldProps(key, schema) {
return {
modelValue: this.modelValue?.[key],
"onUpdate:modelValue": (value) => {
this.onObjectInput(key, value);
},
root: this.root,
fieldKey: key,
task: this.modelValue,
schema: schema,
definitions: this.definitions,
required: this.schema.required,
};
},
},
};
</script>
<style lang="scss">
.el-form-item__content {
.el-form-item {
width: 100%;
}
}
.el-popper.singleton-tooltip {
max-width: 300px !important;
background: var(--ks-tooltip-background);
}
</style>
<style lang="scss" scoped>
@import "../../code/styles/code.scss";
.el-form-item {
width: 100%;
margin-bottom: 0;
> :deep(.el-form-item__label) {
width: 100%;
display: flex;
align-items: center;
padding: 0;
}
}
.inline-wrapper {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
.inline-start {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
flex: 1 1 auto;
}
.label {
color: var(--ks-content-primary);
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
}
.type-tag {
background-color: var(--ks-tag-background-active);
color: var(--ks-tag-content);
font-size: 12px;
line-height: 20px;
padding: 0 8px;
padding-bottom: 2px;
border-radius: 8px;
text-transform: capitalize;
}
.information-icon {
color: var(--ks-content-secondary);
cursor: pointer;
}
}
</style>

View File

@@ -186,22 +186,22 @@
function updatePluginDocumentation(event: any) {
const source = event.model.getValue();
const cursorOffset = event.model.getOffsetAt(event.position);
const isPlugin = (type: string) => pluginsStore.allTypes.includes(type);
const isInRange = (range: [number, number, number]) =>
const isInRange = (range: [number, number, number]) =>
cursorOffset >= range[0] && cursorOffset <= range[2];
const getRangeSize = (range: [number, number, number]) => range[2] - range[0];
const getElementFromRange = (typeElement: any) => {
const wrapper = YAML_UTILS.localizeElementAtIndex(source, typeElement.range[0]);
return wrapper?.value?.type && isPlugin(wrapper.value.type)
? wrapper.value
? wrapper.value
: {type: typeElement.type};
};
const selectedElement = YAML_UTILS.extractFieldFromMaps(source, "type", () => true, isPlugin)
.filter(el => el.range && isInRange(el.range))
.reduce((closest, current) =>
.reduce((closest, current) =>
!closest || getRangeSize(current.range) < getRangeSize(closest.range)
? current
: closest
@@ -237,6 +237,7 @@
const saveFileContent = async () => {
clearTimeout(timeout.value);
if(!namespace.value || !props.path) return
await namespacesStore.createFile({
namespace: namespace.value,
path: props.path,

View File

@@ -8,7 +8,7 @@ import InitialFlowSchema from "./flow-schema.json"
import {toRaw} from "vue";
import {isEntryAPluginElementPredicate} from "@kestra-io/ui-libs";
interface PluginComponent {
export interface PluginComponent {
icon?: string;
cls?: string;
deprecated?: boolean;

View File

@@ -1,11 +1,11 @@
import {ref} from "vue";
import TaskDict from "../../../../../src/components/flows/tasks/TaskDict.vue";
import TaskDict from "../../../../../../src/components/code/components/tasks/TaskDict.vue";
import {userEvent, waitFor, within, expect} from "storybook/internal/test";
import {Meta, StoryObj} from "@storybook/vue3-vite";
import {vueRouter} from "storybook-vue3-router";
const meta: Meta<typeof TaskDict> = {
title: "components/flows/tasks/TaskDict",
title: "components/nocode/TaskDict",
component: TaskDict,
decorators: [
vueRouter([

View File

@@ -0,0 +1,97 @@
import TaskObject from "../../../../../../src/components/code/components/tasks/TaskObject.vue";
import {ref} from "vue"
import {StoryObj} from "@storybook/vue3-vite";
import {waitFor, within, expect, fireEvent} from "storybook/test";
import {vueRouter} from "storybook-vue3-router";
export default {
decorators: [vueRouter([
{
path: "/",
name: "home",
component: {template: "<div>home</div>"}
}])
],
title: "Components/NoCode/TaskObject",
component: TaskObject,
}
type Story = StoryObj<typeof TaskObject>;
const schema = {
type: "object",
properties: {
data: {
title: "The list of data rows for the table.",
type: "array",
items: {type: "object"},
},
type: {const: "io.kestra.plugin.ee.apps.core.blocks.Table"},
},
title: "A block for displaying a table.",
required: ["id", "id"],
};
const AppTableBlockRender = () => ({
setup() {
const model = ref<Record<string, any> | undefined>({})
return () => <div style={{display: "flex", gap: "16px"}}>
<div style={{width: "500px"}}>
<TaskObject
schema={schema}
modelValue={model.value}
onUpdate:modelValue={(value) => model.value = value}
/>
</div>
<div style={{width: "500px"}}>
<h2>Resulting object</h2>
<pre style={{
border: "1px solid #555",
borderRadius: "4px",
padding: "2px",
background: "#222"
}} data-testid="resulting-object">{JSON.stringify(model.value, null, 2)}</pre>
</div>
</div>
}
});
export const AppTableBlock: Story = {
render: AppTableBlockRender,
async play({canvasElement}) {
const canvas = within(canvasElement);
canvas.getByText("+ Add a new value").click();
await waitFor(() => {
expect(canvas.getByText(/null/)).toBeVisible();
});
canvas.getByText("+ Add a new value", {selector: ".schema-wrapper .schema-wrapper button"}).click();
await waitFor(() => {
expect(canvas.getByPlaceholderText("Key")).toBeVisible();
});
fireEvent.input(canvas.getByPlaceholderText("Key"), {target: {value: "key1"}})
fireEvent.input(canvas.getByTestId("monaco-editor-hidden-synced-textarea"), {target: {value: "value1"}})
canvas.getByText("+ Add a new value", {selector: ".schema-wrapper .schema-wrapper button"}).click();
await waitFor(() => {
expect(canvas.getAllByPlaceholderText("Key")[1]).toBeVisible();
});
fireEvent.input(canvas.getAllByPlaceholderText("Key")[1], {target: {value: "key2"}})
fireEvent.input(canvas.getAllByTestId("monaco-editor-hidden-synced-textarea")[1], {target: {value: "value2"}})
await waitFor(() => {
expect(canvas.getByTestId("resulting-object").innerHTML).toBe(JSON.stringify({
data: [
{
key1: "value1",
key2: "value2"
}
]
}, null, 2));
});
}
}

View File

@@ -4,7 +4,7 @@
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2023.Array", "ES2020", "DOM", "DOM.Iterable", "ES2021.String"],
"lib": ["ES2023.Array", "ES2020", "DOM", "DOM.Iterable", "ES2021.String", "ES2022.Array"],
"skipLibCheck": true,
"incremental": true,
"types": ["vitest/globals"],