fix(frontend): make resource a separate top-level type in schema editor for clarity

This commit is contained in:
Ruben Fiszel
2025-08-31 16:19:04 +00:00
parent 690b2470bf
commit e1df2f036f
8 changed files with 348 additions and 370 deletions

View File

@@ -220,15 +220,12 @@
function computeSelected(property: any) {
if (!opened) return ''
return property.type !== 'object'
? property.type
: property.format === 'resource-s3_object'
? 'S3'
: property.format?.startsWith('dynselect-')
? 'dynselect'
: property.oneOf && property.oneOf.length >= 2
? 'oneOf'
: 'object'
if (property.type !== 'object') return property.type
if (property.format === 'resource-s3_object') return 'S3'
if (property.format?.startsWith('dynselect-')) return 'dynselect'
if (property.oneOf && property.oneOf.length >= 2) return 'oneOf'
if (property.format?.startsWith('resource-')) return 'resource'
return 'object'
}
export function openField(key: string) {
@@ -359,10 +356,11 @@
['Number', 'number'],
['Integer', 'integer'],
['Object', 'object'],
['Resource', 'resource'],
['OneOf', 'oneOf'],
['Array', 'array'],
['Boolean', 'boolean'],
['S3 Object', 'S3']
['S3', 'S3']
]
if (showDynSelectOpt) {
typeOptions.push(['DynSelect', 'dynselect'])
@@ -680,6 +678,7 @@
bind:selected={
() => computeSelected(schema.properties[opened ?? '']),
(v) => {
const isResource = v == 'resource'
const isS3 = v == 'S3'
const isOneOf = v == 'oneOf'
const isDynSelect = v == 'dynselect'
@@ -700,13 +699,18 @@
nullable: undefined,
required: undefined
}
if (isS3) {
schema.properties[argName] = {
...emptyProperty,
type: 'object',
format: 'resource-s3_object'
}
} else if (isResource) {
schema.properties[argName] = {
...emptyProperty,
type: 'object',
format: 'resource-'
}
} else if (isDynSelect) {
const functionName = argName.replace(/\s+/g, '_')
schema.properties[argName] = {

View File

@@ -1,21 +0,0 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import ResourceTypePicker from './ResourceTypePicker.svelte'
export let format: string | undefined
let resource: string | undefined = format?.startsWith('resource-')
? format.substring('resource-'.length)
: undefined
const dispatch = createEventDispatcher()
</script>
<ResourceTypePicker
on:click={(e) => {
resource = e.detail
format = resource != undefined ? `resource-${resource}` : undefined
dispatch('change', format)
}}
value={resource}
nonePickable={true}
/>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import ResourceTypePicker from './ResourceTypePicker.svelte'
interface Props {
format: string | undefined
}
let { format = $bindable() }: Props = $props()
let resource: string | undefined = $state(
format?.startsWith('resource-') ? format.substring('resource-'.length) : undefined
)
</script>
<ResourceTypePicker
on:click={(e) => {
resource = e.detail
format = `resource-${resource ?? ''}`
}}
value={resource}
nonePickable={true}
/>

View File

@@ -5,8 +5,6 @@
import { workspaceStore } from '$lib/stores'
import IconedResourceType from './IconedResourceType.svelte'
import { Button, ClearableInput } from './common'
import Popover from './meltComponents/Popover.svelte'
import { offset, flip, shift } from 'svelte-floating-ui/dom'
import Label from './Label.svelte'
import Tooltip from './Tooltip.svelte'
import Badge from './common/badge/Badge.svelte'
@@ -46,84 +44,61 @@
)
</script>
<Label label="Resource type" class="w-full col-span-2">
<Label label="Resource type" class="w-full mb-4">
{#snippet header()}
<Tooltip light small>Select a resource type to narrow down the object type.</Tooltip>
{/snippet}
{#snippet action()}
<div class="flex flex-row gap-1">
<Button
size="xs"
color="light"
on:click={() => onClick(undefined)}
disabled={notPickable || value === undefined}
>
Clear
</Button>
<Popover
floatingConfig={{
strategy: 'fixed',
placement: 'left-end',
middleware: [offset(8), flip(), shift()]
}}
contentClasses="flex flex-col gap-2 h-full p-4 max-h-[40vh] w-[500px]"
>
{#snippet trigger()}
<Button nonCaptureEvent size="xs" color="dark">Select resource type</Button>
{/snippet}
{#snippet content({ close })}
<ClearableInput bind:value={search} placeholder="Search resource..." />
<div class="overflow-y-scroll h-full">
<div
class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center overflow-x-hidden"
>
{#if nonePickable && search === ''}
{@const isPicked = value === undefined}
<Button
size="sm"
variant="border"
color={isPicked ? 'blue' : 'dark'}
btnClasses={isPicked ? '!border-2' : 'm-[1px]'}
disabled={notPickable}
on:click={() => {
onClick(undefined)
close()
}}
>
None
</Button>
{/if}
{#each filteredResources as r}
{@const isPicked = value === r}
<Button
size="sm"
variant="border"
color={isPicked ? 'blue' : 'light'}
btnClasses={isPicked ? '!border-2' : 'm-[1px]'}
disabled={notPickable}
on:click={() => {
onClick(r)
close()
}}
>
<IconedResourceType name={r} after={true} width="20px" height="20px" />
</Button>
{/each}
{#if filteredResources.length === 0 && search !== ''}
<div class="text-tertiary text-sm">No resources found</div>
{/if}
</div>
</div>
{/snippet}
</Popover>
<div class="flex flex-row items-center w-full justify-between">
<Badge color={!value ? 'gray' : 'blue'}>
{value ?? 'None'}
</Badge>
</div>
{/snippet}
<div class="flex flex-row items-center w-full justify-between">
<Badge color={!value ? 'gray' : 'blue'}>
{value ?? 'None'}
</Badge>
<div class="mt-2">
<ClearableInput bind:value={search} placeholder="Search resource..." />
<div class="overflow-y-scroll max-h-[330px] h-full mt-1">
<div
class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center overflow-x-hidden"
>
{#if nonePickable && search === ''}
{@const isPicked = value === undefined}
<Button
size="sm"
variant="border"
color={isPicked ? 'blue' : 'light'}
btnClasses={isPicked ? '!border-2' : 'm-[1px]'}
disabled={notPickable}
on:click={() => {
onClick(undefined)
close()
}}
>
None
</Button>
{/if}
{#each filteredResources as r}
{@const isPicked = value === r}
<Button
size="sm"
variant="border"
color={isPicked ? 'blue' : 'light'}
btnClasses={isPicked ? '!border-2' : 'm-[1px]'}
disabled={notPickable}
on:click={() => {
onClick(r)
close()
}}
>
<IconedResourceType name={r} after={true} width="20px" height="20px" />
</Button>
{/each}
{#if filteredResources.length === 0 && search !== ''}
<div class="text-tertiary text-sm">No resources found</div>
{/if}
</div>
</div>
</div>
</Label>

View File

@@ -2,14 +2,24 @@
import type { TabsContext } from '$lib/components/apps/editor/settingsPanel/inputEditor/tabs.svelte'
import { getContext } from 'svelte'
export let value: string
export let alwaysMounted: boolean = false
export let style = ''
interface Props {
value: string
alwaysMounted?: boolean
style?: string
values?: string[] | undefined
class?: string
children?: import('svelte').Snippet
}
export let values: string[] | undefined = undefined
let {
value,
alwaysMounted = false,
style = '',
values = undefined,
class: clazz = '',
children
}: Props = $props()
let clazz: string = ''
export { clazz as class }
const { selected } = getContext<TabsContext>('Tabs')
</script>
@@ -18,6 +28,6 @@
class={`${clazz} ${value === $selected || values?.includes($selected) ? 'visible' : 'hidden'}`}
{style}
>
<slot />
{@render children?.()}
</div>
{/if}

View File

@@ -82,7 +82,18 @@
const rnd = generateRandomString()
</script>
<div class="flex flex-col items-end mb-2 w-full">
<div class="flex flex-wrap justify-between mb-2 w-full items-center gap-y-2">
<AddProperty
on:change={() => {
if (jsonView) {
schemaString = JSON.stringify(schema, null, '\t')
editor?.setCode(schemaString)
}
}}
bind:schema
bind:this={addPropertyComponent}
/>
<Toggle
bind:checked={jsonView}
label="JSON View"
@@ -100,17 +111,6 @@
/>
</div>
<AddProperty
on:change={() => {
if (jsonView) {
schemaString = JSON.stringify(schema, null, '\t')
editor?.setCode(schemaString)
}
}}
bind:schema
bind:this={addPropertyComponent}
/>
{#if !jsonView}
{#key rnd}
<div

View File

@@ -7,9 +7,7 @@
import type VariableEditor from '../VariableEditor.svelte'
import { createEventDispatcher } from 'svelte'
import ArgInput from '../ArgInput.svelte'
import ObjectTypeNarrowing from '../ObjectTypeNarrowing.svelte'
import Tabs from '../common/tabs/Tabs.svelte'
import { Tab, TabContent } from '../common'
import ResourceNarrowing from '../ResourceNarrowing.svelte'
import EditableSchemaDrawer from './EditableSchemaDrawer.svelte'
import type { SchemaProperty } from '$lib/common'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
@@ -18,7 +16,6 @@
import { Pen, Plus, Trash2 } from 'lucide-svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import ResourcePicker from '../ResourcePicker.svelte'
import Tooltip from '../Tooltip.svelte'
interface Props {
format?: string | undefined
@@ -141,222 +138,215 @@
)
</script>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2 mt-2">
{#if type === 'object' && oneOf && oneOf.length >= 2}
<div class="flex flex-row gap-1 items-center justify-start">
<ToggleButtonGroup
bind:selected={oneOfSelected}
class="h-auto w-auto"
tabListClass="flex-wrap"
>
{#snippet children({ item })}
{#each oneOf ?? [] as obj}
<ToggleButton value={obj.title ?? ''} label={obj.title} {item} />
{/each}
{/snippet}
</ToggleButtonGroup>
<Label label="OneOf properties">
<div class="flex flex-row gap-1 items-center justify-start pt-2">
<ToggleButtonGroup
bind:selected={oneOfSelected}
class="h-auto w-auto"
tabListClass="flex-wrap"
>
{#snippet children({ item })}
{#each oneOf ?? [] as obj}
<ToggleButton value={obj.title ?? ''} label={obj.title} {item} />
{/each}
{/snippet}
</ToggleButtonGroup>
<Popover placement="bottom-end" closeButton>
{#snippet trigger()}
<Button size="xs2" color="light" nonCaptureEvent startIcon={{ icon: Plus }} />
{/snippet}
{#snippet content({ close })}
<Label label="Label" class="p-2 flex flex-col gap-2">
<input
type="text"
class="w-full !bg-surface"
onkeydown={(event) => {
if (event.key === 'Enter') {
<Popover placement="bottom-end" closeButton>
{#snippet trigger()}
<Button size="xs2" color="light" nonCaptureEvent startIcon={{ icon: Plus }} />
{/snippet}
{#snippet content({ close })}
<Label label="Label" class="p-2 flex flex-col gap-2">
<input
type="text"
class="w-full !bg-surface"
onkeydown={(event) => {
if (event.key === 'Enter') {
createVariant(variantName)
close()
}
}}
bind:value={variantName}
/>
<Button
variant="border"
color="light"
size="xs"
on:click={() => {
createVariant(variantName)
close()
}}
disabled={variantName.length === 0}
>
Add
</Button>
</Label>
{/snippet}
</Popover>
</div>
<div class="flex flex-row gap-2 items-center ml-1 mt-4 mb-2">
<span class="font-semibold text-sm">{oneOfSelected}</span>
<Popover
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
containerClasses="border rounded-lg shadow-lg p-4 bg-surface"
closeButton
>
{#snippet trigger()}
<Button
size="xs2"
color="light"
startIcon={{ icon: Pen }}
propagateEvent
iconOnly={false}
on:click={() => {
if (oneOfSelected) {
variantName = oneOfSelected
}
}}
bind:value={variantName}
/>
<Button
variant="border"
color="light"
size="xs"
on:click={() => {
createVariant(variantName)
close()
}}
disabled={variantName.length === 0}
>
Add
</Button>
</Label>
{/snippet}
</Popover>
</div>
<div class="flex flex-row gap-2 items-center">
<span class="font-semibold text-sm">{oneOfSelected}</span>
<Popover
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
containerClasses="border rounded-lg shadow-lg p-4 bg-surface"
closeButton
>
{#snippet trigger()}
<Button
size="xs2"
color="light"
startIcon={{ icon: Pen }}
propagateEvent
iconOnly={false}
on:click={() => {
if (oneOfSelected) {
variantName = oneOfSelected
}
}}
/>
{/snippet}
{#snippet content({ close })}
<Label label="Label" class="p-2 flex flex-col gap-2">
<input
type="text"
class="w-full !bg-surface"
onkeydown={(event) => {
if (event.key === 'Enter') {
{/snippet}
{#snippet content({ close })}
<Label label="Label" class="p-2 flex flex-col gap-2">
<input
type="text"
class="w-full !bg-surface"
onkeydown={(event) => {
if (event.key === 'Enter') {
if (oneOfSelected) {
renameVariant(variantName, oneOfSelected)
close()
}
}
}}
bind:value={variantName}
/>
<Button
variant="border"
color="light"
size="xs"
on:click={() => {
if (oneOfSelected) {
renameVariant(variantName, oneOfSelected)
close()
}
}
}}
bind:value={variantName}
/>
<Button
variant="border"
color="light"
size="xs"
on:click={() => {
if (oneOfSelected) {
renameVariant(variantName, oneOfSelected)
close()
}
}}
disabled={variantName.length === 0}
>
Rename
</Button>
</Label>
{/snippet}
</Popover>
<Button
size="xs2"
color="red"
startIcon={{ icon: Trash2 }}
iconOnly
disabled={(oneOf?.length ?? 0) <= 2}
on:click={() => {
if (oneOf && oneOfSelected) {
const idx = oneOf.findIndex((obj) => obj.title === oneOfSelected)
oneOf = oneOf.filter((_, i) => i !== idx)
oneOfSelected = oneOf[0].title
}
}}
/>
</div>
{#if oneOfSelected && oneOf}
{@const idx = oneOf.findIndex((obj) => obj.title === oneOfSelected)}
<EditableSchemaDrawer
onClose={() => {
onDrawerClose?.()
}}
bind:schema={oneOf[idx]}
hiddenArgs={[
oneOf?.find((o) => Object.keys(o.properties ?? {}).includes('kind')) ? 'kind' : 'label'
]}
/>
{/if}
{:else if type === 'object' && format !== 'resource-s3_object' && !isDynSelect}
<Tabs
bind:selected={initialObjectSelected}
on:selected={(e) => {
if (e.detail === 'json-schema') {
format = 'json-schema'
} else {
format = ''
}
}}
>
<Tab value="resource">Resource</Tab>
<Tab value="custom-object">Custom Object</Tab>
<Tab value="json-schema">
JSON Schema
<Tooltip>
This displays a JSON schema editor, useful when a JSON schema input is expected.
</Tooltip>
</Tab>
{#snippet content()}
<div class="pt-2">
<TabContent value="custom-object">
<ToggleButtonGroup
bind:selected={customObjectSelected}
class="mb-2"
on:selected={(e) => {
if (e.detail === 'editor') {
format = undefined
} else {
properties = undefined
order = undefined
requiredProperty = undefined
}
}}
>
{#snippet children({ item })}
<ToggleButton value="editor" label="Editor" {item} />
<ToggleButton
value="json-schema-resource"
label="JSON Schema Resource"
{item}
tooltip="Select a JSON schema resource to specify the object's properties"
showTooltipIcon
/>
{/snippet}
</ToggleButtonGroup>
{#if customObjectSelected === 'editor'}
<EditableSchemaDrawer
bind:schema={
() => {
return {
properties: properties,
order: order,
required: requiredProperty
}
},
(v) => {
properties = v.properties
order = v.order
requiredProperty = v.required
}
}
/>
{:else if customObjectSelected === 'json-schema-resource'}
<ResourcePicker
resourceType="json_schema"
bind:value={
() => {
if (format?.startsWith('jsonschema-')) {
return format.substring('jsonschema-'.length)
}
return undefined
},
(v) => {
format = 'jsonschema-' + v
}
}
/>
{/if}
</TabContent>
<TabContent value="resource">
<ObjectTypeNarrowing bind:format />
</TabContent>
}}
disabled={variantName.length === 0}
>
Rename
</Button>
</Label>
{/snippet}
</Popover>
<Button
size="xs2"
color="red"
startIcon={{ icon: Trash2 }}
iconOnly
disabled={(oneOf?.length ?? 0) <= 2}
on:click={() => {
if (oneOf && oneOfSelected) {
const idx = oneOf.findIndex((obj) => obj.title === oneOfSelected)
oneOf = oneOf.filter((_, i) => i !== idx)
oneOfSelected = oneOf[0].title
}
}}
/>
</div>
{#if oneOfSelected && oneOf}
{@const idx = oneOf.findIndex((obj) => obj.title === oneOfSelected)}
<div class="ml-1">
<EditableSchemaDrawer
onClose={() => {
onDrawerClose?.()
}}
bind:schema={oneOf[idx]}
hiddenArgs={[
oneOf?.find((o) => Object.keys(o.properties ?? {}).includes('kind'))
? 'kind'
: 'label'
]}
/>
</div>
{/snippet}
</Tabs>
{/if}
</Label>
<div class="py-2"></div>
{:else if type === 'object' && format?.startsWith('resource-') && format !== 'resource-s3_object' && !isDynSelect}
<ResourceNarrowing bind:format />
{:else if type === 'object' && !format?.startsWith('resource-') && !isDynSelect}
<div class="py-2">
<Label label="Object properties">
<ToggleButtonGroup
bind:selected={customObjectSelected}
class="my-2"
on:selected={(e) => {
if (e.detail === 'editor') {
format = undefined
} else {
properties = undefined
order = undefined
requiredProperty = undefined
}
}}
>
{#snippet children({ item })}
<ToggleButton value="editor" label="Custom" {item} />
<ToggleButton
value="json-schema-resource"
label="Template"
{item}
tooltip="Select a JSON schema resource to specify the object's properties"
showTooltipIcon
/>
{/snippet}
</ToggleButtonGroup>
{#if customObjectSelected === 'editor'}
<EditableSchemaDrawer
bind:schema={
() => {
return {
properties: properties,
order: order,
required: requiredProperty
}
},
(v) => {
properties = v.properties
order = v.order
requiredProperty = v.required
}
}
/>
{:else if customObjectSelected === 'json-schema-resource'}
{#if format == undefined}
<div class="text-xs text-tertiary my-1">
Select a <code>json_schema</code> resource as a reusable JSON schema template
</div>
{/if}
<ResourcePicker
resourceType="json_schema"
bind:value={
() => {
if (format?.startsWith('jsonschema-')) {
return format.substring('jsonschema-'.length)
}
return undefined
},
(v) => {
if (v) {
format = 'jsonschema-' + v
} else {
format = undefined
}
}
}
/>
{/if}
</Label>
</div>
{/if}
{#if !(type === 'object' && oneOf && oneOf.length >= 2) && !(type == 'object' && initialObjectSelected == 'custom-object') && !isDynSelect}

View File

@@ -217,7 +217,7 @@
bind:itemsType
canEditResourceType={isFlowInput || isAppInput}
/>
{:else if type == 'string' || ['number', 'integer', 'object'].includes(type ?? '')}
{:else if type == 'string' || ['number', 'integer'].includes(type ?? '')}
<div>
<Label label="Field settings">
<div>
@@ -244,44 +244,42 @@
bind:currency={extra['currency']}
bind:currencyLocale={extra['currencyLocale']}
/>
{:else if type == 'object' && oneOf && oneOf.length >= 2 && !isFlowInput && !isAppInput}
<ToggleButtonGroup bind:selected={oneOfSelected} class="mb-2">
{#snippet children({ item })}
{#each oneOf as obj}
<ToggleButton value={obj.title ?? ''} label={obj.title} {item} />
{/each}
{/snippet}
</ToggleButtonGroup>
{#if oneOfSelected && oneOfSchemas}
{@const idx = oneOf.findIndex((obj) => obj.title === oneOfSelected)}
<div class="border">
<EditableSchemaForm
on:change
noPreview
bind:schema={oneOfSchemas[idx]}
uiOnly
jsonEnabled={false}
editTab="inputEditor"
/>
</div>
{/if}
{:else if type == 'object' && !format?.startsWith('resource-') && !isFlowInput && !isAppInput}
<div class="border">
<EditableSchemaForm
on:change
noPreview
bind:schema
uiOnly
jsonEnabled={false}
editTab="inputEditor"
/>
</div>
{:else}
<div class="text-tertiary text-xs">No settings available for this field type</div>
{/if}
</div>
</Label>
</div>
{:else if type == 'object' && oneOf && oneOf.length >= 2 && !isFlowInput && !isAppInput}
<ToggleButtonGroup bind:selected={oneOfSelected} class="mb-2">
{#snippet children({ item })}
{#each oneOf as obj}
<ToggleButton value={obj.title ?? ''} label={obj.title} {item} />
{/each}
{/snippet}
</ToggleButtonGroup>
{#if oneOfSelected && oneOfSchemas}
{@const idx = oneOf.findIndex((obj) => obj.title === oneOfSelected)}
<div class="border">
<EditableSchemaForm
on:change
noPreview
bind:schema={oneOfSchemas[idx]}
uiOnly
jsonEnabled={false}
editTab="inputEditor"
/>
</div>
{/if}
{:else if type == 'object' && !format?.startsWith('resource-') && !isFlowInput && !isAppInput}
<div class="border">
<EditableSchemaForm
on:change
noPreview
bind:schema
uiOnly
jsonEnabled={false}
editTab="inputEditor"
/>
</div>
{/if}
{@render children?.()}