mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 08:01:35 +00:00
Runnable refactor (#932)
* fix(frontend): Support runnable everywhere * fix(frontend): Runnable component working * fix(frontend): Simplify implementation * fix(frontend): User inputs working * fix(frontend): Fix connections * fix(frontend): auto refresh * fix(frontend): revert changes
This commit is contained in:
@@ -17,16 +17,10 @@
|
||||
|
||||
const { worldStore } = getContext<AppEditorContext>('AppEditorContext')
|
||||
|
||||
// ComponentInput: Static/dynamic
|
||||
// ScriptInput: Run form: Static/Dynamic/User
|
||||
// paramInput: Search : configurable only at component level (toggle)
|
||||
|
||||
export const staticOutputs: string[] = ['selectedRow', 'loading', 'result']
|
||||
|
||||
$: outputs = $worldStore?.outputsById[id] as {
|
||||
selectedRow: Output<any>
|
||||
result: Output<Array<string>>
|
||||
loading: Output<boolean>
|
||||
}
|
||||
|
||||
let selectedRowIndex = -1
|
||||
@@ -49,6 +43,10 @@
|
||||
|
||||
let result: Array<Record<string, any>> = []
|
||||
$: headers = Object.keys(result[0] || {}) || []
|
||||
|
||||
const extraQueryParams = { search, page }
|
||||
|
||||
export const reservedKeys: string[] = Object.keys(extraQueryParams)
|
||||
</script>
|
||||
|
||||
<ComponentInputValue input={componentInputs.searchEnabled} bind:value={searchEnabledValue} />
|
||||
|
||||
@@ -42,10 +42,8 @@
|
||||
let result: ChartData<'pie', number[], unknown> | undefined = undefined
|
||||
</script>
|
||||
|
||||
<RunnableComponent {id} {path} {runType} {inlineScriptName} {inputs} bind:result>
|
||||
<RunnableComponent {id} {path} {runType} {inlineScriptName} bind:inputs bind:result>
|
||||
{#if result}
|
||||
<Pie data={result} {options} />
|
||||
{:else}
|
||||
<span>No dataset</span>
|
||||
{/if}
|
||||
</RunnableComponent>
|
||||
|
||||
@@ -18,17 +18,26 @@
|
||||
|
||||
let labelValue: string = 'Default label'
|
||||
|
||||
let tick = 0
|
||||
let runnableComponent: RunnableComponent
|
||||
</script>
|
||||
|
||||
<ComponentInputValue input={componentInputs.label} bind:value={labelValue} />
|
||||
|
||||
<RunnableComponent bind:inputs {path} {runType} {inlineScriptName} {id} shouldTick={tick}>
|
||||
<RunnableComponent
|
||||
bind:this={runnableComponent}
|
||||
bind:inputs
|
||||
{path}
|
||||
{runType}
|
||||
{inlineScriptName}
|
||||
{id}
|
||||
autoRefresh={false}
|
||||
>
|
||||
<AlignWrapper {horizontalAlignement} {verticalAlignement}>
|
||||
<Button
|
||||
on:click={() => {
|
||||
tick = tick + 1
|
||||
runnableComponent?.runComponent()
|
||||
}}
|
||||
btnClasses="h-full"
|
||||
>
|
||||
{labelValue}
|
||||
</Button>
|
||||
|
||||
@@ -2,26 +2,17 @@
|
||||
import { getContext } from 'svelte'
|
||||
import type { StaticInput, DynamicInput, AppEditorContext } from '../../types'
|
||||
|
||||
type T = $$Generic
|
||||
|
||||
export let input: DynamicInput | StaticInput
|
||||
export let value: any
|
||||
export let value: T
|
||||
|
||||
const { worldStore } = getContext<AppEditorContext>('AppEditorContext')
|
||||
|
||||
$: hasConnection = input.type === 'output' && input.id && input.name
|
||||
$: input.type === 'static' && (value = input.value)
|
||||
$: input.type === 'output' && $worldStore?.connect<any>(input, onValueChange)
|
||||
|
||||
$: inputResult = hasConnection
|
||||
? $worldStore?.connect<any>(input, () => updateValue())
|
||||
: {
|
||||
peak: () => {
|
||||
if (input.type === 'static') {
|
||||
return input.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateValue() {
|
||||
value = inputResult?.peak()
|
||||
function onValueChange(newValue: T): void {
|
||||
value = newValue
|
||||
}
|
||||
|
||||
$: !hasConnection && input && updateValue()
|
||||
</script>
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
|
||||
let timer: NodeJS.Timeout
|
||||
|
||||
function debounce(event: KeyboardEvent) {
|
||||
function debounce(event: KeyboardEvent): void {
|
||||
clearTimeout(timer)
|
||||
|
||||
timer = setTimeout(() => {
|
||||
const target = event.target as HTMLInputElement
|
||||
value = target.value
|
||||
|
||||
@@ -22,13 +22,12 @@
|
||||
export let runType: 'script' | 'flow' | undefined = undefined
|
||||
export let inlineScriptName: string | undefined = undefined
|
||||
export let extraQueryParams: Record<string, any> = {}
|
||||
export let shouldTick: number | undefined = undefined
|
||||
export let autoRefresh: boolean = true
|
||||
export let result: any = undefined
|
||||
|
||||
const { app, worldStore } = getContext<AppEditorContext>('AppEditorContext')
|
||||
let pagePath = $page.params.path
|
||||
|
||||
// Local state
|
||||
let pagePath = $page.params.path
|
||||
let args: Record<string, any> = {}
|
||||
let schema: Schema | undefined = undefined
|
||||
let testIsLoading = false
|
||||
@@ -36,8 +35,24 @@
|
||||
|
||||
$: mergedArgs = { ...args, ...extraQueryParams, ...runnableInputValues }
|
||||
|
||||
function isMergedArgsValid(mergedArgs: Record<string, any>) {
|
||||
if (Object.keys(inputs).length !== Object.keys(runnableInputValues).length) {
|
||||
// TODO: Review
|
||||
function setStaticInputsToArgs() {
|
||||
Object.entries(inputs).forEach(([key, value]) => {
|
||||
if (value.type === 'static') {
|
||||
args[key] = value.value
|
||||
}
|
||||
})
|
||||
|
||||
args = args
|
||||
}
|
||||
|
||||
$: inputs && setStaticInputsToArgs()
|
||||
|
||||
function argMergedArgsValid(mergedArgs: Record<string, any>) {
|
||||
if (
|
||||
Object.keys(inputs).filter((k) => inputs[k].type !== 'user').length !==
|
||||
Object.keys(runnableInputValues).length
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -45,16 +60,14 @@
|
||||
(arg) => arg !== undefined && arg !== null
|
||||
)
|
||||
|
||||
debugger
|
||||
|
||||
if (areAllArgsValid) {
|
||||
if (areAllArgsValid && autoRefresh) {
|
||||
executeComponent()
|
||||
}
|
||||
|
||||
return areAllArgsValid
|
||||
}
|
||||
|
||||
$: isValid = isMergedArgsValid(mergedArgs)
|
||||
$: isValid = argMergedArgsValid(mergedArgs)
|
||||
|
||||
// Test job internal state
|
||||
let testJob: CompletedJob | undefined = undefined
|
||||
@@ -65,13 +78,6 @@
|
||||
loading: Output<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Args are built from 3 sources:
|
||||
* 1. The inputs spec ( )
|
||||
* 2. The schema input transform with user submitted values§
|
||||
* 3. The extra query params
|
||||
*/
|
||||
|
||||
async function loadSchemaFromTriggerable(
|
||||
workspace: string,
|
||||
path: string,
|
||||
@@ -91,8 +97,15 @@
|
||||
|
||||
// When the schema is loaded, we need to update the inputs spec
|
||||
// in order to render the inputs the component panel
|
||||
$: if (schema && Object.keys(schema.properties ?? {}).length !== Object.keys(inputs).length) {
|
||||
inputs = schemaToInputsSpec(schema)
|
||||
$: if (schema && Object.keys(schema.properties).length !== Object.keys(inputs).length) {
|
||||
let schemaWithoutExtraQueries: Schema = JSON.parse(JSON.stringify(schema))
|
||||
|
||||
// Remove extra query params from the schema, which are not directly configurable by the user
|
||||
Object.keys(extraQueryParams).forEach((key) => {
|
||||
delete schemaWithoutExtraQueries.properties[key]
|
||||
})
|
||||
|
||||
inputs = schemaToInputsSpec(schemaWithoutExtraQueries)
|
||||
}
|
||||
|
||||
let schemaStripped: Schema | undefined = undefined
|
||||
@@ -123,14 +136,19 @@
|
||||
|
||||
$: schema && stripSchema(schema)
|
||||
|
||||
$: disabledArgs = Object.keys(inputs).reduce((a: string[], c: string) => {
|
||||
if (inputs[c].type === 'static') {
|
||||
a = [...a, c]
|
||||
}
|
||||
return a
|
||||
}, [])
|
||||
$: disabledArgs = Object.keys(inputs).reduce(
|
||||
(disabledArgsAccumulator: string[], inputName: string) => {
|
||||
if (inputs[inputName].type === 'static') {
|
||||
disabledArgsAccumulator = [...disabledArgsAccumulator, inputName]
|
||||
}
|
||||
return disabledArgsAccumulator
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
async function executeComponent() {
|
||||
outputs?.loading.set(true)
|
||||
|
||||
await testJobLoader?.abstractRun(() => {
|
||||
const requestBody = {
|
||||
args: mergedArgs,
|
||||
@@ -154,6 +172,10 @@
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function runComponent() {
|
||||
executeComponent()
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each Object.keys(inputs) as key}
|
||||
@@ -164,6 +186,8 @@
|
||||
on:done={() => {
|
||||
if (testJob) {
|
||||
outputs.result.set(testJob?.result)
|
||||
outputs?.loading.set(false)
|
||||
|
||||
result = testJob?.result
|
||||
}
|
||||
}}
|
||||
@@ -176,7 +200,7 @@
|
||||
<SchemaForm schema={schemaStripped} bind:args {isValid} {disabledArgs} shouldHideNoInputs />
|
||||
{/if}
|
||||
|
||||
{#if shouldTick === undefined}
|
||||
{#if autoRefresh === true}
|
||||
{#if isValid}
|
||||
<Button size="xs" color="dark" on:click={() => executeComponent()} disabled={!isValid}>
|
||||
<div>
|
||||
@@ -192,4 +216,6 @@
|
||||
Please fill in all the inputs
|
||||
</Alert>
|
||||
{/if}
|
||||
{:else}
|
||||
<slot />
|
||||
{/if}
|
||||
|
||||
@@ -2,26 +2,33 @@
|
||||
import { getContext } from 'svelte'
|
||||
import type { StaticInput, DynamicInput, AppEditorContext, UserInput } from '../../types'
|
||||
|
||||
type T = $$Generic
|
||||
export let input: DynamicInput | StaticInput | UserInput
|
||||
export let value: any
|
||||
export let value: T
|
||||
|
||||
const { worldStore } = getContext<AppEditorContext>('AppEditorContext')
|
||||
|
||||
$: hasConnection = input.type === 'output' && input.id !== undefined && input.name !== undefined
|
||||
$: input && handleConnection()
|
||||
|
||||
$: inputResult = hasConnection
|
||||
? $worldStore?.connect<any>(input, () => updateValue())
|
||||
: {
|
||||
peak: () => {
|
||||
if (input.type === 'static') {
|
||||
return input.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateValue() {
|
||||
value = inputResult?.peak()
|
||||
function handleConnection() {
|
||||
if (input.type === 'output') {
|
||||
$worldStore?.connect<any>(input, onValueChange)
|
||||
} else if (input.type === 'static') {
|
||||
setValue()
|
||||
}
|
||||
}
|
||||
|
||||
$: !hasConnection && input && input.type === 'static' && updateValue()
|
||||
function setValue() {
|
||||
if (input.type === 'static') {
|
||||
value = input.value
|
||||
}
|
||||
}
|
||||
|
||||
function onValueChange(newValue: T): void {
|
||||
if (input.type === 'output') {
|
||||
value = newValue
|
||||
} else {
|
||||
// TODO: handle disconnect
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
class={classNames(
|
||||
'p-2 border overflow-auto cursor-pointer h-full bg-white',
|
||||
selected ? 'border-blue-500' : 'border-white',
|
||||
$mode === 'preview' ? 'border-white' : 'hover:border-blue-500 '
|
||||
$mode === 'preview' ? 'border-white' : 'hover:border-blue-500'
|
||||
)}
|
||||
>
|
||||
{#if component.type === 'runformcomponent'}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { AppEditorContext } from '../types'
|
||||
import Grid from 'svelte-grid'
|
||||
import ComponentEditor from './ComponentEditor.svelte'
|
||||
import { classNames } from '$lib/utils'
|
||||
|
||||
const { selectedComponent, app, mode } = getContext<AppEditorContext>('AppEditorContext')
|
||||
|
||||
@@ -29,7 +30,7 @@
|
||||
{@const index = $app.grid.findIndex((c) => c.data.id === dataItem.data.id)}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="h-full w-full flex justify-center align-center border border-gray-100"
|
||||
class={classNames('h-full w-full flex justify-center align-center border border-gray-100')}
|
||||
on:click={() => {
|
||||
$selectedComponent = dataItem.data.id
|
||||
}}
|
||||
@@ -45,6 +46,6 @@
|
||||
<style>
|
||||
:global(.svlt-grid-shadow) {
|
||||
/* Back shadow */
|
||||
background: lightblue !important;
|
||||
background: rgb(147 197 253) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -91,12 +91,15 @@ const chartComponents = {
|
||||
...defaultProps,
|
||||
id: 'piechartcomponent',
|
||||
type: 'piechartcomponent',
|
||||
runnable: true
|
||||
runnable: true,
|
||||
card: true
|
||||
},
|
||||
{
|
||||
...defaultProps,
|
||||
id: 'barchartcomponent',
|
||||
type: 'barchartcomponent'
|
||||
type: 'barchartcomponent',
|
||||
runnable: true,
|
||||
card: true
|
||||
}
|
||||
] as AppComponent[]
|
||||
}
|
||||
@@ -120,7 +123,8 @@ const tableComponents = {
|
||||
fieldType: 'boolean'
|
||||
}
|
||||
},
|
||||
runnable: true
|
||||
runnable: true,
|
||||
card: true
|
||||
}
|
||||
] as AppComponent[]
|
||||
}
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
|
||||
export let appPath: string
|
||||
|
||||
const { connectingInput, staticOutputs, app, worldStore } =
|
||||
getContext<AppEditorContext>('AppEditorContext')
|
||||
const { connectingInput, staticOutputs, app } = getContext<AppEditorContext>('AppEditorContext')
|
||||
|
||||
function connectInput(id: string, name: string) {
|
||||
if ($connectingInput) {
|
||||
@@ -55,8 +54,6 @@
|
||||
scriptCreationDrawer.closeDrawer()
|
||||
}
|
||||
|
||||
// Inline DENO, Inline Python, Inline GO, Inline SQL
|
||||
|
||||
let selectedScript:
|
||||
| { content: string; language: Preview.language; path: string; schema: Schema }
|
||||
| undefined = undefined
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
faAlignCenter,
|
||||
faAlignLeft,
|
||||
faAlignRight,
|
||||
faClose,
|
||||
faPen,
|
||||
faTrashAlt
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { getContext } from 'svelte'
|
||||
@@ -31,52 +33,85 @@
|
||||
|
||||
{#if component}
|
||||
<div class="flex flex-col w-full divide-y">
|
||||
<PanelSection title="Context">
|
||||
{#if component.inputs}
|
||||
<InputsSpecsEditor bind:inputSpecs={component.inputs} />
|
||||
{/if}
|
||||
{#if component.runnable}
|
||||
<PanelSection title="Runnable">
|
||||
{#if component['inlineScriptName']}
|
||||
<span class="text-xs">{component['inlineScriptName']}</span>
|
||||
<div class="w-full flex flex-row gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
color="dark"
|
||||
startIcon={{ icon: faPen }}
|
||||
on:click={() => {
|
||||
alert('TODO')
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
|
||||
{#if component.runnable && component['path'] === undefined && component['inlineScriptName'] === undefined}
|
||||
<span class="text-sm">Select a script or a flow to continue</span>
|
||||
<PickScript
|
||||
kind="script"
|
||||
on:pick={({ detail }) => {
|
||||
if (component && component.type === 'runformcomponent') {
|
||||
component.path = detail.path
|
||||
component.runType = 'script'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<PickFlow
|
||||
on:pick={({ detail }) => {
|
||||
if (component && component.type === 'runformcomponent') {
|
||||
component.path = detail.path
|
||||
component.runType = 'flow'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
startIcon={{ icon: faClose }}
|
||||
on:click={() => {
|
||||
alert('TODO')
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if component.runnable && component['path'] === undefined && component['inlineScriptName'] === undefined}
|
||||
{#each Object.keys($app.inlineScripts ?? {}) as inlineScriptName}
|
||||
<Button
|
||||
on:click={() => {
|
||||
if (component?.runnable) {
|
||||
// @ts-ignore
|
||||
component.inlineScriptName = inlineScriptName
|
||||
{#if component.runnable && component['path'] === undefined && component['inlineScriptName'] === undefined}
|
||||
<span class="text-sm">Select a script or a flow to continue</span>
|
||||
<PickScript
|
||||
kind="script"
|
||||
on:pick={({ detail }) => {
|
||||
if (component && component.type === 'runformcomponent') {
|
||||
component.path = detail.path
|
||||
component.runType = 'script'
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
>
|
||||
Link {inlineScriptName}
|
||||
</Button>
|
||||
{/each}
|
||||
{/if}
|
||||
/>
|
||||
<PickFlow
|
||||
on:pick={({ detail }) => {
|
||||
if (component && component.type === 'runformcomponent') {
|
||||
component.path = detail.path
|
||||
component.runType = 'flow'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if component.componentInputs}
|
||||
{#if component.runnable && component['path'] === undefined && component['inlineScriptName'] === undefined}
|
||||
{#each Object.keys($app.inlineScripts ?? {}) as inlineScriptName}
|
||||
<Button
|
||||
on:click={() => {
|
||||
if (component?.runnable) {
|
||||
// @ts-ignore
|
||||
component.inlineScriptName = inlineScriptName
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
>
|
||||
Link {inlineScriptName}
|
||||
</Button>
|
||||
{/each}
|
||||
{/if}
|
||||
</PanelSection>
|
||||
{/if}
|
||||
{#if Object.values(component.inputs).length > 0}
|
||||
<PanelSection title="Runnable inputs">
|
||||
<InputsSpecsEditor bind:inputSpecs={component.inputs} />
|
||||
</PanelSection>
|
||||
{/if}
|
||||
|
||||
{#if Object.values(component.componentInputs).length > 0}
|
||||
<PanelSection title="Component parameters">
|
||||
<ComponentInputsSpecsEditor bind:componentInputSpecs={component.componentInputs} />
|
||||
{/if}
|
||||
</PanelSection>
|
||||
</PanelSection>
|
||||
{/if}
|
||||
|
||||
{#if component.verticalAlignement !== undefined}
|
||||
<PanelSection title="Alignement">
|
||||
|
||||
@@ -7,6 +7,38 @@
|
||||
|
||||
export let inputSpecs: InputsSpec
|
||||
|
||||
const userTypeKeys = ['schemaProperty', 'defaultValue', 'value']
|
||||
const staticTypeKeys = ['visible', 'value', 'fieldType']
|
||||
const dynamicTypeKeys = ['id', 'name', 'defaultValue']
|
||||
|
||||
function sanitizeInputSpec(type: 'user' | 'static' | 'output', inputSpecKey: string) {
|
||||
const inputSpec = inputSpecs[inputSpecKey]
|
||||
if (type === 'user') {
|
||||
for (const key of staticTypeKeys) {
|
||||
delete inputSpec[key]
|
||||
}
|
||||
for (const key of dynamicTypeKeys) {
|
||||
delete inputSpec[key]
|
||||
}
|
||||
} else if (type === 'static') {
|
||||
for (const key of userTypeKeys) {
|
||||
delete inputSpec[key]
|
||||
}
|
||||
for (const key of dynamicTypeKeys) {
|
||||
delete inputSpec[key]
|
||||
}
|
||||
} else if (type === 'output') {
|
||||
for (const key of userTypeKeys) {
|
||||
delete inputSpec[key]
|
||||
}
|
||||
for (const key of staticTypeKeys) {
|
||||
delete inputSpec[key]
|
||||
}
|
||||
}
|
||||
|
||||
inputSpecs[inputSpecKey] = inputSpec
|
||||
}
|
||||
|
||||
let openedProp = Object.keys(inputSpecs)[0]
|
||||
</script>
|
||||
|
||||
@@ -26,7 +58,10 @@
|
||||
</div>
|
||||
{#if inputSpecKey === openedProp}
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<ToggleButtonGroup bind:selected={inputSpecs[inputSpecKey].type}>
|
||||
<ToggleButtonGroup
|
||||
bind:selected={inputSpecs[inputSpecKey].type}
|
||||
on:select={(x) => sanitizeInputSpec(x.detail, inputSpecKey)}
|
||||
>
|
||||
<ToggleButton position="left" value="static" startIcon={{ icon: faBolt }} size="xs">
|
||||
Static
|
||||
</ToggleButton>
|
||||
|
||||
@@ -24,7 +24,16 @@ export type StaticInput = {
|
||||
type: 'static'
|
||||
value: any
|
||||
visible?: boolean
|
||||
fieldType: 'text' | 'textarea' | 'number' | 'boolean' | 'select' | 'date' | 'time' | 'datetime'
|
||||
fieldType:
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'select'
|
||||
| 'date'
|
||||
| 'time'
|
||||
| 'datetime'
|
||||
| 'object'
|
||||
}
|
||||
|
||||
export type AppInputTransform = DynamicInput | StaticInput | UserInput
|
||||
@@ -92,6 +101,7 @@ export type AppComponent =
|
||||
// Only dynamic inputs (Result of display)
|
||||
componentInputs: ComponentInputsSpec
|
||||
runnable?: boolean | undefined
|
||||
card?: boolean | undefined
|
||||
|
||||
// TODO: add min/max width/height
|
||||
}
|
||||
|
||||
@@ -77,11 +77,13 @@ export async function loadSchema(
|
||||
export function schemaToInputsSpec(schema: Schema): InputsSpec {
|
||||
return Object.keys(schema.properties).reduce((accu, key) => {
|
||||
const property = schema.properties[key]
|
||||
|
||||
accu[key] = {
|
||||
type: 'static',
|
||||
defaultValue: property.default,
|
||||
value: undefined,
|
||||
visible: true
|
||||
visible: true,
|
||||
fieldType: property.type
|
||||
}
|
||||
return accu
|
||||
}, {})
|
||||
|
||||
Reference in New Issue
Block a user