fix(frontend): proper each block binding + better app settings reactivity (#5568)

* fix: properly bind to array elements in Svelte each loops

This commit fixes an issue where binding directly to loop variables in Svelte's #each loops doesn't properly update the original array. Instead of binding directly to the loop variable, we now bind to the array elements using index variables.

The pattern used is: - Change: {#each arr as el} -> {#each arr as _, index} - Change: bind:value={el} -> bind:value={arr[index]}

Modified files: - frontend/src/lib/components/ArrayTypeNarrowing.svelte - frontend/src/lib/components/apps/editor/AppInputs.svelte - frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte

* better app settings panel reactivity

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: HugoCasa <hugo@casademont.ch>
This commit is contained in:
Guilhem
2025-04-04 17:11:14 +00:00
committed by GitHub
co-authored by Ruben Fiszel HugoCasa
parent c8e1f65ac4
commit a2f2076231
9 changed files with 323 additions and 115 deletions
@@ -25,8 +25,8 @@
itemsType?.type != 'string'
? itemsType?.type
: Array.isArray(itemsType?.enum)
? 'enum'
: 'string'
? 'enum'
: 'string'
let schema = {
properties: itemsType?.properties || {},
@@ -105,16 +105,17 @@
<label for="input" class="text-secondary text-xs">
Enums
<div class="flex flex-col gap-1">
{#each itemsType?.enum || [] as e}
{#each itemsType?.enum || [] as _, index}
<div class="flex flex-row max-w-md gap-1 items-center">
<input id="input" type="text" bind:value={e} />
<input id="input" type="text" bind:value={itemsType.enum[index]} />
<div>
<button
transition:fade|local={{ duration: 100 }}
class="rounded-full p-1 bg-surface-secondary duration-200 hover:bg-surface-hover ml-2"
on:click={() => {
if (itemsType?.enum) {
itemsType.enum = (itemsType.enum || []).filter((el) => el !== e)
if (itemsType && itemsType.enum) {
const enumValue = itemsType.enum[index]
itemsType.enum = itemsType.enum.filter((el) => el !== enumValue)
}
}}
>
@@ -106,12 +106,12 @@
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Allowed domains</span>
<div class="flex flex-col gap-1">
{#each value?.['allowed_domains'] ?? [] as domain}
{#each value?.['allowed_domains'] ?? [] as domain, idx}
<div class="flex gap-2">
<input
class="max-w-96 w-full"
type="text"
bind:value={domain}
bind:value={value['allowed_domains'][idx]}
on:keyup={(e) => {
if (domain == '') {
value['allowed_domains'] = value['allowed_domains']?.filter(
@@ -1,15 +0,0 @@
// Created this utility to avoid circular dependencies between
// svelte effects
import { deepEqual } from 'fast-equals'
export function createOnObjChange<T extends object>() {
let oldValue: T | undefined
return (value: T | undefined, callback: () => void) => {
if (!deepEqual(oldValue, value)) {
oldValue = structuredClone(value)
callback()
}
}
}
@@ -24,8 +24,11 @@
<div>
<AppComponentInput bind:component={gridItem.data} {resourceOnly} />
<div class="ml-4 mt-4">
{#each gridItem.data.actionButtons as actionButton (actionButton.id)}
<AppComponentInput bind:component={actionButton.data} {resourceOnly} />
{#each gridItem.data.actionButtons as actionButton, actionIndex (actionButton.id)}
<AppComponentInput
bind:component={gridItem.data.actionButtons[actionIndex].data}
{resourceOnly}
/>
{/each}
</div>
</div>
@@ -34,8 +37,11 @@
<AppComponentInput bind:component={gridItem.data} {resourceOnly} />
<div class="ml-4 mt-4">
{#if Array.isArray(gridItem.data.actions)}
{#each gridItem.data.actions as actionButton (actionButton.id)}
<AppComponentInput bind:component={actionButton.data} {resourceOnly} />
{#each gridItem.data.actions as actionButton, actionIndex (actionButton.id)}
<AppComponentInput
bind:component={gridItem.data.actions[actionIndex].data}
{resourceOnly}
/>
{/each}
{/if}
</div>
@@ -1,13 +1,14 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte'
import type { App, AppViewerContext } from '../types'
import { BG_PREFIX, allItems } from '../utils'
import { findComponentSettings, findGridItem } from './appUtils'
import type { App, AppViewerContext, GridItem } from '../types'
import { BG_PREFIX } from '../utils'
import { findGridItemWithLocation, allItemsWithLocation } from './appUtils'
import PanelSection from './settingsPanel/common/PanelSection.svelte'
import ComponentPanel from './settingsPanel/ComponentPanel.svelte'
import InputsSpecsEditor from './settingsPanel/InputsSpecsEditor.svelte'
import BackgroundScriptSettings from './settingsPanel/script/BackgroundScriptSettings.svelte'
import EventHandlerItem from './settingsPanel/EventHandlerItem.svelte'
import type { TableAction } from './component'
const { selectedComponent, app, stateId, runnableComponents } =
getContext<AppViewerContext>('AppViewerContext')
@@ -20,18 +21,28 @@
?.map((x, i) => ({ script: x, index: i }))
.find(({ script, index }) => $selectedComponent?.includes(BG_PREFIX + index))
$: componentSettings = findComponentSettings($app, firstComponent)
$: gridItemWithLocation = findGridItemWithLocation($app, firstComponent)
$: tableActionSettings = findTableActionSettings($app, firstComponent)
$: menuItemsSettings = findMenuItemsSettings($app, firstComponent)
function findTableActionSettings(app: App, id: string | undefined) {
return allItems(app.grid, app.subgrids)
.map((x) => {
return allItemsWithLocation(app.grid, app.subgrids)
.map((itemWithLocation) => {
const x = itemWithLocation.item
if (x?.data?.type === 'tablecomponent') {
if (x?.data?.actionButtons) {
const tableAction = x.data.actionButtons.find((x) => x.id === id)
if (tableAction) {
return { item: { data: tableAction, id: tableAction.id }, parent: x.data.id }
const tableActionIdx = x.data.actionButtons.findIndex((x) => x.id === id)
if (tableActionIdx > -1) {
const tableAction = x.data.actionButtons[tableActionIdx]
return {
item: { data: tableAction, id: tableAction.id },
parent: x.data.id,
gridItemLocation: itemWithLocation.location,
location: {
key: 'actionButtons',
index: tableActionIdx
}
}
}
}
} else if (
@@ -42,9 +53,18 @@
x?.data?.type === 'aggridinfinitecomponentee'
) {
if (x?.data?.actions) {
const tableAction = x.data.actions.find((x) => x.id === id)
if (tableAction) {
return { item: { data: tableAction, id: tableAction.id }, parent: x.data.id }
const tableActionIdx = x.data.actions.findIndex((x) => x.id === id)
if (tableActionIdx > -1) {
const tableAction = x.data.actions[tableActionIdx]
return {
item: { data: tableAction, id: tableAction.id },
parent: x.data.id,
gridItemLocation: itemWithLocation.location,
location: {
key: 'actions',
index: tableActionIdx
}
}
}
}
}
@@ -53,13 +73,20 @@
}
function findMenuItemsSettings(app: App, id: string | undefined) {
return allItems(app.grid, app.subgrids)
.map((x) => {
return allItemsWithLocation(app.grid, app.subgrids)
.map((itemWithLocation) => {
const x = itemWithLocation.item
if (x?.data?.type === 'menucomponent') {
if (x?.data?.menuItems) {
const menuItem = x.data.menuItems.find((x) => x.id === id)
if (menuItem) {
return { item: { data: menuItem, id: menuItem.id }, parent: x.data.id }
const menuItemIdx = x.data.menuItems.findIndex((x) => x.id === id)
if (menuItemIdx > -1) {
const menuItem = x.data.menuItems[menuItemIdx]
return {
item: { data: menuItem, id: menuItem.id },
parent: x.data.id,
index: menuItemIdx,
gridItemLocation: itemWithLocation.location
}
}
}
}
@@ -67,13 +94,39 @@
.find((x) => x)
}
function itemHasActions(
item: GridItem | undefined
): item is GridItem & { data: { actions: TableAction[] } } {
return (
item?.data?.type === 'aggridcomponent' ||
item?.data?.type === 'aggridcomponentee' ||
item?.data?.type === 'dbexplorercomponent' ||
item?.data?.type === 'aggridinfinitecomponent' ||
item?.data?.type === 'aggridinfinitecomponentee'
)
}
const dispatch = createEventDispatcher()
</script>
{#if componentSettings}
{#key componentSettings?.item?.id}
{#if gridItemWithLocation}
{#key gridItemWithLocation.item.id}
<ComponentPanel
bind:componentSettings
bind:componentSettings={
() => gridItemWithLocation,
(cs) => {
if (gridItemWithLocation?.location.type === 'grid') {
$app.grid[gridItemWithLocation.location.gridItemIndex] = cs.item
} else if (
gridItemWithLocation?.location.type === 'subgrid' &&
Array.isArray($app.subgrids?.[gridItemWithLocation.location.subgridKey])
) {
$app.subgrids[gridItemWithLocation.location.subgridKey][
gridItemWithLocation.location.subgridItemIndex
] = cs.item
}
}
}
onDelete={() => {
dispatch('delete')
}}
@@ -83,30 +136,65 @@
{#key tableActionSettings?.item?.data?.id}
<ComponentPanel
noGrid
bind:componentSettings={tableActionSettings}
bind:componentSettings={
() => tableActionSettings,
(cs) => {
if (tableActionSettings) {
if (tableActionSettings.gridItemLocation.type === 'grid') {
const { gridItemIndex } = tableActionSettings.gridItemLocation
const { key, index } = tableActionSettings.location
if ($app.grid[gridItemIndex]?.data?.[key]) {
$app.grid[gridItemIndex].data[key][index] = cs.item.data
}
} else if (tableActionSettings.gridItemLocation.type === 'subgrid') {
const { subgridKey, subgridItemIndex } = tableActionSettings.gridItemLocation
const { key, index } = tableActionSettings.location
if ($app.subgrids?.[subgridKey]?.[subgridItemIndex]?.data?.[key]) {
$app.subgrids[subgridKey][subgridItemIndex].data[key][index] = cs.item.data
}
}
}
}
}
duplicateMoveAllowed={false}
onDelete={() => {
if (tableActionSettings) {
const parent = findGridItem($app, tableActionSettings.parent)
if (!parent) return
const item = findGridItemWithLocation($app, tableActionSettings.parent)
if (!item) return
const { item: parent, location } = item
if (parent.data.type === 'tablecomponent') {
parent.data.actionButtons = parent.data.actionButtons.filter(
const newActionButtons = parent.data.actionButtons.filter(
(x) => x.id !== tableActionSettings?.item.id
)
if (location.type === 'grid') {
const { gridItemIndex } = location
if ($app.grid[gridItemIndex]?.data?.type === 'tablecomponent') {
$app.grid[gridItemIndex].data.actionButtons = newActionButtons
}
} else if (location.type === 'subgrid') {
const { subgridKey, subgridItemIndex } = location
if (
$app.subgrids?.[subgridKey]?.[subgridItemIndex]?.data?.type === 'tablecomponent'
) {
$app.subgrids[subgridKey][subgridItemIndex].data.actionButtons = newActionButtons
}
}
}
if (
(parent.data.type === 'aggridcomponent' ||
parent.data.type === 'aggridcomponentee' ||
parent.data.type === 'dbexplorercomponent' ||
parent.data.type === 'aggridinfinitecomponent' ||
parent.data.type === 'aggridinfinitecomponentee') &&
Array.isArray(parent.data.actions)
) {
parent.data.actions = parent.data.actions.filter(
if (itemHasActions(parent) && Array.isArray(parent.data.actions)) {
const newActions = parent.data.actions.filter(
(x) => x.id !== tableActionSettings?.item.id
)
if (location.type === 'grid') {
const { gridItemIndex } = location
if (itemHasActions($app.grid[gridItemIndex])) {
$app.grid[gridItemIndex].data.actions = newActions
}
} else {
const { subgridKey, subgridItemIndex } = location
if (itemHasActions($app.subgrids?.[subgridKey]?.[subgridItemIndex])) {
$app.subgrids[subgridKey][subgridItemIndex].data.actions = newActions
}
}
}
}
}}
@@ -116,17 +204,44 @@
{#key menuItemsSettings?.item?.id}
<ComponentPanel
noGrid
bind:componentSettings={menuItemsSettings}
duplicateMoveAllowed={false}
bind:componentSettings={
() => menuItemsSettings,
(cs) => {
if (menuItemsSettings) {
if (menuItemsSettings.gridItemLocation.type === 'grid') {
const { gridItemIndex } = menuItemsSettings.gridItemLocation
if ($app.grid[gridItemIndex]?.data?.type === 'menucomponent') {
$app.grid[gridItemIndex].data.menuItems[cs.index] = cs.item.data
}
} else if (menuItemsSettings.gridItemLocation.type === 'subgrid') {
const { subgridKey, subgridItemIndex } = menuItemsSettings.gridItemLocation
if ($app.subgrids?.[subgridKey]?.[subgridItemIndex]?.data?.type === 'menucomponent') {
$app.subgrids[subgridKey][subgridItemIndex].data.menuItems[cs.index] = cs.item.data
}
}
}
}
}
onDelete={() => {
if (menuItemsSettings) {
const parent = findGridItem($app, menuItemsSettings.parent)
if (!parent) return
const item = findGridItemWithLocation($app, menuItemsSettings.parent)
if (!item) return
const { item: parent, location } = item
if (parent.data.type === 'menucomponent') {
parent.data.menuItems = parent.data.menuItems.filter(
const newItems = parent.data.menuItems.filter(
(x) => x.id !== menuItemsSettings?.item.id
)
if (location.type === 'grid') {
const { gridItemIndex } = location
if ($app.grid[gridItemIndex]?.data?.type === 'menucomponent') {
$app.grid[gridItemIndex].data.menuItems = newItems
}
} else if (location.type === 'subgrid') {
const { subgridKey, subgridItemIndex } = location
if ($app.subgrids?.[subgridKey]?.[subgridItemIndex]?.data?.type === 'menucomponent') {
$app.subgrids[subgridKey][subgridItemIndex].data.menuItems = newItems
}
}
}
}
}}
@@ -135,8 +250,15 @@
{:else if hiddenInlineScript}
{@const id = BG_PREFIX + hiddenInlineScript.index}
{#key id}
<BackgroundScriptSettings bind:runnable={hiddenInlineScript.script} {id} />
<BackgroundScriptSettings
bind:runnable={
() => hiddenInlineScript.script,
(r) => {
$app.hiddenInlineScripts[hiddenInlineScript.index] = r
}
}
{id}
/>
{#if Object.keys(hiddenInlineScript.script.fields ?? {}).length > 0}
<div class="mb-8">
<PanelSection title={`Inputs`}>
@@ -145,7 +267,14 @@
displayType
{id}
shouldCapitalize={false}
bind:inputSpecs={hiddenInlineScript.script.fields}
bind:inputSpecs={
() => hiddenInlineScript.script.fields,
(is) => {
if ($app.hiddenInlineScripts[hiddenInlineScript.index]) {
$app.hiddenInlineScripts[hiddenInlineScript.index].fields = is
}
}
}
userInputEnabled={false}
recomputeOnInputChanged={hiddenInlineScript.script.recomputeOnInputChanged}
showOnDemandOnlyToggle
@@ -163,7 +292,14 @@
title="on success"
tooltip="This event is triggered when the script runs successfully."
items={Object.keys($runnableComponents).filter((_id) => _id !== id)}
bind:value={hiddenInlineScript.script.recomputeIds}
bind:value={
() => hiddenInlineScript.script.recomputeIds,
(v) => {
if ($app.hiddenInlineScripts[hiddenInlineScript.index]) {
$app.hiddenInlineScripts[hiddenInlineScript.index].recomputeIds = v
}
}
}
/>
</PanelSection>
<div class="grow shrink"></div>
@@ -36,6 +36,91 @@ import { getNextId } from '$lib/components/flows/idUtils'
import { enterpriseLicense } from '$lib/stores'
import gridHelp from '../svelte-grid/utils/helper'
type GridItemLocation =
| {
type: 'grid'
gridItemIndex: number
}
| {
type: 'subgrid'
subgridItemIndex: number
subgridKey: string
}
interface GridItemWithLocation {
location: GridItemLocation
item: GridItem
parent: string | undefined
}
export function allItemsWithLocation(
grid: GridItem[],
subgrids: Record<string, GridItem[]> | undefined
): GridItemWithLocation[] {
const gridItems: GridItemWithLocation[] = grid.map((x, i) => ({
location: {
type: 'grid',
gridItemIndex: i
},
item: x,
parent: undefined
}))
if (subgrids) {
for (const key of Object.keys(subgrids)) {
gridItems.push(
...subgrids[key].map((x, i) => ({
location: {
type: 'subgrid' as const,
subgridItemIndex: i,
subgridKey: key
},
item: x,
parent: key
}))
)
}
}
return gridItems
}
export function findGridItemWithLocation(
app: App,
id: string | undefined
): GridItemWithLocation | undefined {
if (!id) return undefined
if (app?.grid) {
const gridItemIndex = app.grid.findIndex((x) => x.data?.id === id)
if (gridItemIndex > -1) {
return {
location: {
type: 'grid',
gridItemIndex: gridItemIndex
},
item: app.grid[gridItemIndex],
parent: undefined
}
}
}
if (app?.subgrids) {
for (const key of Object.keys(app.subgrids ?? {})) {
const subGridItemIndex = app.subgrids[key].findIndex((x) => x.data?.id === id)
if (subGridItemIndex > -1) {
return {
location: {
subgridItemIndex: subGridItemIndex,
subgridKey: key,
type: 'subgrid'
},
item: app.subgrids[key][subGridItemIndex],
parent: key
}
}
}
}
return undefined
}
export function findComponentSettings(app: App, id: string | undefined) {
if (!id) return undefined
if (app?.grid) {
@@ -772,20 +857,20 @@ export type InitConfig<
[Property in keyof T]: T[Property] extends StaticAppInput
? T[Property]['value'] | undefined
: T[Property] extends { type: 'oneOf' }
? {
type: 'oneOf'
selected: keyof T[Property]['configuration']
configuration: {
[Choice in keyof T[Property]['configuration']]: {
[IT in keyof T[Property]['configuration'][Choice]]: T[Property]['configuration'][Choice][IT] extends StaticAppInput
? T[Property]['configuration'][Choice][IT] extends StaticAppInputOnDemand
? () => Promise<T[Property]['configuration'][Choice][IT]['value'] | undefined>
: T[Property]['configuration'][Choice][IT]['value'] | undefined
: undefined
? {
type: 'oneOf'
selected: keyof T[Property]['configuration']
configuration: {
[Choice in keyof T[Property]['configuration']]: {
[IT in keyof T[Property]['configuration'][Choice]]: T[Property]['configuration'][Choice][IT] extends StaticAppInput
? T[Property]['configuration'][Choice][IT] extends StaticAppInputOnDemand
? () => Promise<T[Property]['configuration'][Choice][IT]['value'] | undefined>
: T[Property]['configuration'][Choice][IT]['value'] | undefined
: undefined
}
}
}
}
: undefined
: undefined
}
export function initConfig<
@@ -827,27 +912,30 @@ export function initConfig<
? [
key,
configuration?.[key]?.type == 'static' ? configuration?.[key]?.['value'] : undefined
]
]
: value.type == 'oneOf'
? [
key,
{
selected: value.selected,
type: 'oneOf',
configuration: Object.fromEntries(
Object.entries(value.configuration).map(([choice, config]) => {
const conf = initConfig(config, configuration?.[key]?.configuration?.[choice])
Object.entries(config).forEach(([innerKey, innerValue]) => {
if (innerValue.type === 'static' && !(innerKey in conf)) {
conf[innerKey] = innerValue.value
}
? [
key,
{
selected: value.selected,
type: 'oneOf',
configuration: Object.fromEntries(
Object.entries(value.configuration).map(([choice, config]) => {
const conf = initConfig(
config,
configuration?.[key]?.configuration?.[choice]
)
Object.entries(config).forEach(([innerKey, innerValue]) => {
if (innerValue.type === 'static' && !(innerKey in conf)) {
conf[innerKey] = innerValue.value
}
})
return [choice, conf]
})
return [choice, conf]
})
)
}
]
: [key, undefined]
)
}
]
: [key, undefined]
)
) as any
)
@@ -14,7 +14,7 @@
import AlignmentEditor from './AlignmentEditor.svelte'
import RunnableInputEditor from './inputEditor/RunnableInputEditor.svelte'
import TemplateEditor from '$lib/components/TemplateEditor.svelte'
import { ccomponents, components, type AppComponent } from '../component'
import { ccomponents, components } from '../component'
import CssProperty from '../componentsPanel/CssProperty.svelte'
import GridTab from './GridTab.svelte'
import { deleteGridItem, isTableAction } from '../appUtils'
@@ -45,7 +45,6 @@
import Badge from '$lib/components/common/badge/Badge.svelte'
import { twMerge } from 'tailwind-merge'
import Popover from '$lib/components/Popover.svelte'
import { createOnObjChange } from '../../components/helpers/onObjChange'
export let componentSettings: { item: GridItem; parent: string | undefined } | undefined =
undefined
@@ -141,9 +140,6 @@
? ccomponents[componentSettings?.item?.data?.type]?.initialData?.componentInput
: undefined
const onDataChange = createOnObjChange<AppComponent>()
$: onDataChange(componentSettings?.item?.data, () => ($app = $app))
const hasInteraction = componentSettings?.item.data.type
? isTriggerable(componentSettings?.item.data.type)
: false
@@ -6,12 +6,11 @@
import { getContext } from 'svelte'
import ScriptSettingsSection from './shared/ScriptSettingsSection.svelte'
import ScriptTransformer from './shared/ScriptTransformer.svelte'
import { createOnObjChange } from '$lib/components/apps/components/helpers/onObjChange'
export let runnable: HiddenRunnable
export let id: string
const { runnableComponents, app } = getContext<AppViewerContext>('AppViewerContext')
const { runnableComponents } = getContext<AppViewerContext>('AppViewerContext')
function updateAutoRefresh() {
const autoRefresh = runnable.autoRefresh
@@ -22,9 +21,6 @@
}
}
}
const onDataChange = createOnObjChange<HiddenRunnable>()
$: onDataChange(runnable, () => ($app = $app))
</script>
<div class={'border-y divide-y '}>
@@ -233,7 +233,7 @@
{#if $selectedId === `${flowModule?.id}-branch-${branchIndex}`}
<FlowBranchOneWrapper
{noEditor}
bind:branch
bind:branch={flowModule.value.branches[branchIndex]}
parentModule={flowModule}
{previousModule}
{enableAi}
@@ -254,7 +254,7 @@
{:else if flowModule.value.type === 'branchall'}
{#each flowModule.value.branches as branch, branchIndex (branchIndex)}
{#if $selectedId === `${flowModule?.id}-branch-${branchIndex}`}
<FlowBranchAllWrapper {noEditor} bind:branch />
<FlowBranchAllWrapper {noEditor} bind:branch={flowModule.value.branches[branchIndex]} />
{:else}
{#each branch.modules as _, index}
<svelte:self