feat(frontend): event handlers (#3902)

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): done

* feat(frontend): dark mode

* feat(frontend): fix dark mode + width issue

* feat(frontend): fix dark mode + width issue

* feat(frontend): fix layout

* feat(frontend): done

* feat(frontend): fix wording
This commit is contained in:
Faton Ramadani
2024-06-13 15:57:14 +02:00
committed by GitHub
parent 8357987fdd
commit e4e16c5344
15 changed files with 315 additions and 117 deletions
@@ -13,8 +13,9 @@
export let configuration: RichConfigurations
export let customCss: ComponentCustomCSS<'fileinputcomponent'> | undefined = undefined
export let render: boolean
export let onFileChange: string[] | undefined = undefined
const { app, worldStore, componentControl, mode } =
const { app, worldStore, componentControl, mode, runnableComponents } =
getContext<AppViewerContext>('AppViewerContext')
let acceptedFileTypes: string[] | undefined = undefined
@@ -36,6 +37,7 @@
})
}
outputs?.result.set(files)
onFileChange?.forEach((id) => $runnableComponents?.[id]?.cb?.forEach((cb) => cb?.()))
}
let css = initCss($app.css?.fileinputcomponent, customCss)
@@ -16,6 +16,7 @@
export let customCss: ComponentCustomCSS<'s3fileinputcomponent'> | undefined = undefined
export let render: boolean
export let extraKey: string | undefined = undefined
export let onFileChange: string[] | undefined = undefined
let resolvedConfig = initConfig(
components['s3fileinputcomponent'].initialData.configuration,
@@ -33,7 +34,8 @@
}
let fileUploads: Writable<FileUploadData[]> = writable([])
const { app, worldStore, componentControl } = getContext<AppViewerContext>('AppViewerContext')
const { app, worldStore, componentControl, runnableComponents } =
getContext<AppViewerContext>('AppViewerContext')
$componentControl[id] = {
clearFiles: () => {
@@ -125,6 +127,7 @@
on:addition={(evt) => {
const curr = outputs.result.peak()
outputs.result.set(curr.concat(evt.detail))
onFileChange?.forEach((id) => $runnableComponents?.[id]?.cb?.forEach((cb) => cb?.()))
}}
on:deletion={(evt) => {
const curr = outputs.result.peak()
@@ -14,9 +14,17 @@
export let customCss: ComponentCustomCSS<'conditionalwrapper'> | undefined = undefined
export let render: boolean
export let conditions: RichConfiguration[]
export let onTabChange: string[] | undefined = undefined
const { app, focusedGrid, selectedComponent, worldStore, connectingInput, componentControl } =
getContext<AppViewerContext>('AppViewerContext')
const {
app,
focusedGrid,
selectedComponent,
worldStore,
connectingInput,
componentControl,
runnableComponents
} = getContext<AppViewerContext>('AppViewerContext')
const outputs = initOutput($worldStore, id, {
conditions: [] as boolean[],
@@ -52,6 +60,7 @@
selectedConditionIndex = index
outputs.selectedTabIndex.set(index)
onTabChange?.forEach((id) => $runnableComponents?.[id]?.cb?.forEach((cb) => cb?.()))
}
$: resolvedConditions && handleResolvedConditions()
@@ -22,6 +22,8 @@
export let recomputeIds: string[] | undefined = undefined
export let extraQueryParams: Record<string, any> = {}
export let componentInput: AppInput | undefined
export let onNext: string[] | undefined = undefined
export let onPrevious: string[] | undefined = undefined
const {
app,
@@ -30,7 +32,8 @@
selectedComponent,
componentControl,
connectingInput,
mode
mode,
runnableComponents
} = getContext<AppViewerContext>('AppViewerContext')
let selected = tabs[0]
@@ -206,6 +209,7 @@
on:click={(e) => {
e.preventDefault()
directionClicked = 'left'
onPrevious?.forEach((id) => $runnableComponents?.[id]?.cb?.forEach((cb) => cb?.()))
runStep(selectedIndex - 1)
}}
>
@@ -227,6 +231,7 @@
on:click={(e) => {
e.preventDefault()
directionClicked = 'right'
onNext?.forEach((id) => $runnableComponents?.[id]?.cb?.forEach((cb) => cb?.()))
runStep(selectedIndex + 1)
}}
>
@@ -23,6 +23,7 @@
export let customCss: ComponentCustomCSS<'tabscomponent'> | undefined = undefined
export let render: boolean
export let disabledTabs: RichConfiguration[]
export let onTabChange: string[] | undefined = undefined
let resolvedConfig = initConfig(
components['tabscomponent'].initialData.configuration,
@@ -36,7 +37,8 @@
selectedComponent,
mode,
componentControl,
connectingInput
connectingInput,
runnableComponents
} = getContext<AppViewerContext>('AppViewerContext')
let selected: string = tabs[0]
@@ -50,6 +52,8 @@
selectedIndex = tabs?.indexOf(selected)
outputs?.selectedTabIndex.set(selectedIndex)
onTabChange?.forEach((id) => $runnableComponents?.[id]?.cb?.forEach((cb) => cb?.()))
if ($focusedGrid?.parentComponentId != id || $focusedGrid?.subGridIndex != selectedIndex) {
$focusedGrid = {
parentComponentId: id,
@@ -7,9 +7,10 @@
import ComponentPanel from './settingsPanel/ComponentPanel.svelte'
import InputsSpecsEditor from './settingsPanel/InputsSpecsEditor.svelte'
import BackgroundScriptSettings from './settingsPanel/script/BackgroundScriptSettings.svelte'
import Recompute from './settingsPanel/Recompute.svelte'
import EventHandlerItem from './settingsPanel/EventHandlerItem.svelte'
const { selectedComponent, app, stateId } = getContext<AppViewerContext>('AppViewerContext')
const { selectedComponent, app, stateId, runnableComponents } =
getContext<AppViewerContext>('AppViewerContext')
let firstComponent = $selectedComponent?.[0]
@@ -153,7 +154,17 @@
</PanelSection>
</div>
{/if}
<Recompute bind:recomputeIds={hiddenInlineScript.script.recomputeIds} ownId={id} />
<PanelSection
title={`Event handlers`}
tooltip="Event handlers are used to trigger actions on other components when a specific event occurs. For example, you can trigger a recompute on a component when a script has successfully run."
>
<EventHandlerItem
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}
/>
</PanelSection>
<div class="grow shrink" />
{/key}
{/if}
@@ -606,6 +606,7 @@
id={component.id}
tabs={component.tabs}
disabledTabs={component.disabledTabs}
onTabChange={component.onTabChange}
customCss={component.customCss}
{componentContainerHeight}
{render}
@@ -617,6 +618,8 @@
customCss={component.customCss}
{componentContainerHeight}
componentInput={component.componentInput}
onNext={component.onNext}
onPrevious={component.onPrevious}
{render}
/>
{:else if component.type === 'conditionalwrapper' && component.conditions}
@@ -624,6 +627,7 @@
id={component.id}
conditions={component.conditions}
customCss={component.customCss}
onTabChange={component.onTabChange}
{componentContainerHeight}
{render}
/>
@@ -675,6 +679,7 @@
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
onFileChange={component.onFileChange}
{render}
/>
{:else if component.type === 's3fileinputcomponent'}
@@ -682,6 +687,7 @@
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
onFileChange={component.onFileChange}
{render}
/>
{:else if component.type === 'imagecomponent'}
@@ -186,11 +186,15 @@ export type RadioComponent = BaseComponent<'radiocomponent'>
export type IconComponent = BaseComponent<'iconcomponent'>
export type HorizontalDividerComponent = BaseComponent<'horizontaldividercomponent'>
export type VerticalDividerComponent = BaseComponent<'verticaldividercomponent'>
export type FileInputComponent = BaseComponent<'fileinputcomponent'>
export type FileInputComponent = BaseComponent<'fileinputcomponent'> & {
onFileChange?: string[]
}
export type TabsComponent = BaseComponent<'tabscomponent'> & {
tabs: string[]
disabledTabs: RichConfiguration[]
onTabChange?: string[]
}
export type ListComponent = BaseComponent<'listcomponent'>
export type ContainerComponent = BaseComponent<'containercomponent'> & {
groupFields: RichConfigurations
@@ -214,9 +218,12 @@ export type ModalComponent = BaseComponent<'modalcomponent'> & {
}
export type StepperComponent = BaseComponent<'steppercomponent'> & {
tabs: string[]
onNext?: string[]
onPrevious?: string[]
}
export type ConditionalWrapperComponent = BaseComponent<'conditionalwrapper'> & {
conditions: RichConfiguration[]
onTabChange?: string[]
}
export type Schemaformcomponent = BaseComponent<'schemaformcomponent'>
@@ -234,7 +241,9 @@ export type DBExplorerComponent = BaseComponent<'dbexplorercomponent'> & {
actions: TableAction[]
}
export type S3FileInputComponent = BaseComponent<'s3fileinputcomponent'>
export type S3FileInputComponent = BaseComponent<'s3fileinputcomponent'> & {
onFileChange?: string[]
}
export type DecisionTreeNode = {
id: string
@@ -9,7 +9,6 @@
import ConnectedInputEditor from './inputEditor/ConnectedInputEditor.svelte'
import { classNames, getModifierKey, isMac } from '$lib/utils'
import { buildExtraLib } from '../../utils'
import Recompute from './Recompute.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import ComponentInputTypeEditor from './ComponentInputTypeEditor.svelte'
import AlignmentEditor from './AlignmentEditor.svelte'
@@ -40,6 +39,7 @@
import GridAgChartsLicenseKe from './GridAgChartsLicenseKe.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import ContextVariables from './ContextVariables.svelte'
import EventHandlers from './EventHandlers.svelte'
export let componentSettings: { item: GridItem; parent: string | undefined } | undefined =
undefined
@@ -402,46 +402,8 @@
{ccomponents[component.type].name} has no configuration
</div>
{/if}
{#if (`recomputeIds` in componentSettings.item.data && Array.isArray(componentSettings.item.data.recomputeIds)) || componentSettings.item.data.type === 'buttoncomponent' || componentSettings.item.data.type === 'formcomponent' || componentSettings.item.data.type === 'formbuttoncomponent' || componentSettings.item.data.type === 'checkboxcomponent'}
<Recompute
bind:recomputeIds={componentSettings.item.data.recomputeIds}
ownId={component.id}
/>
{/if}
{#if (`onOpenRecomputeIds` in componentSettings.item.data && Array.isArray(componentSettings.item.data.onOpenRecomputeIds)) || componentSettings.item.data.type === 'modalcomponent' || componentSettings.item.data.type === 'drawercomponent'}
<Recompute
bind:recomputeIds={componentSettings.item.data.onOpenRecomputeIds}
ownId={component.id}
title="Trigger runnables on open"
tooltip="Select components to recompute after this component was opened"
/>
{/if}
{#if (`onCloseRecomputeIds` in componentSettings.item.data && Array.isArray(componentSettings.item.data.onCloseRecomputeIds)) || componentSettings.item.data.type === 'modalcomponent' || componentSettings.item.data.type === 'drawercomponent'}
<Recompute
bind:recomputeIds={componentSettings.item.data.onCloseRecomputeIds}
ownId={component.id}
title="Trigger runnables on close"
tooltip="Select components to recompute after this component was closed"
/>
{/if}
{#if componentSettings.item.data.type === 'checkboxcomponent'}
<Recompute
title="Recompute on toggle"
tooltip={'Contrary to onSuccess, this will only trigger recompute when a human toggle the change, not if it set by a default value or by setValue'}
documentationLink={undefined}
bind:recomputeIds={componentSettings.item.data.onToggle}
ownId={component.id}
/>
{/if}
{#if componentSettings.item.data.type === 'resourceselectcomponent' || componentSettings.item.data.type === 'selectcomponent'}
<Recompute
title="Recompute on select"
tooltip={'Contrary to onSuccess, this will only trigger recompute when a human select an item, not if it set by a default value or by setValue'}
documentationLink={undefined}
bind:recomputeIds={componentSettings.item.data.onSelect}
ownId={component.id}
/>
{/if}
<EventHandlers bind:item={componentSettings.item} ownId={component.id} />
<div class="grow shrink" />
@@ -0,0 +1,37 @@
<script lang="ts">
import Tooltip from '$lib/components/Tooltip.svelte'
import MultiSelect from '$lib/components/multiselect/MultiSelectWrapper.svelte'
import { twMerge } from 'tailwind-merge'
export let items: string[]
export let value: string[] | undefined = undefined
export let title: string
export let tooltip: string
$: width = 0
const inputWidth = 280
</script>
<div
class={twMerge(
'flex flex-col justify-between w-full',
width < inputWidth ? 'flex-col gap-2' : 'flex-row gap-16 '
)}
bind:clientWidth={width}
>
<div class="flex items-center text-xs h-8 whitespace-nowrap">
{title}
<Tooltip light small>{tooltip}</Tooltip>
</div>
<div class="w-full">
{#if items.length === 0}
<div class="text-xs text-secondary w-full flex items-center justify-end h-8">
No components to recompute.
</div>
{:else}
<MultiSelect {items} bind:value />
{/if}
</div>
</div>
@@ -0,0 +1,108 @@
<script lang="ts">
import type { AppViewerContext, GridItem } from '../../types'
import { getContext } from 'svelte'
import EventHandlerItem from './EventHandlerItem.svelte'
import PanelSection from './common/PanelSection.svelte'
const { runnableComponents } = getContext<AppViewerContext>('AppViewerContext')
export let item: GridItem
export let ownId: string
const componentsWithEventHandler = [
'modalcomponent',
'drawercomponent',
'buttoncomponent',
'formcomponent',
'formbuttoncomponent',
'checkboxcomponent',
'resourceselectcomponent',
'selectcomponent',
'tabscomponent',
'conditionalwrapper',
'fileinputcomponent',
's3fileinputcomponent',
'steppercomponent'
]
</script>
{#if componentsWithEventHandler.includes(item.data.type)}
<PanelSection
title="Event handlers"
tooltip="Event handlers are used to trigger actions on other components when a specific event occurs. For example, you can trigger a recompute on a component when a button is clicked."
>
{#if (`onOpenRecomputeIds` in item.data && Array.isArray(item.data.onOpenRecomputeIds)) || item.data.type === 'modalcomponent' || item.data.type === 'drawercomponent'}
<EventHandlerItem
title="on open"
tooltip="Select components to recompute after this component was opened"
items={Object.keys($runnableComponents).filter((id) => id !== ownId)}
bind:value={item.data.onOpenRecomputeIds}
/>
{/if}
{#if (`onCloseRecomputeIds` in item.data && Array.isArray(item.data.onCloseRecomputeIds)) || item.data.type === 'modalcomponent' || item.data.type === 'drawercomponent'}
<EventHandlerItem
title="on close"
tooltip="Select components to recompute after this component was closed"
items={Object.keys($runnableComponents).filter((id) => id !== ownId)}
bind:value={item.data.onCloseRecomputeIds}
/>
{/if}
{#if (`recomputeIds` in item.data && Array.isArray(item.data.recomputeIds)) || item.data.type === 'buttoncomponent' || item.data.type === 'formcomponent' || item.data.type === 'formbuttoncomponent' || item.data.type === 'checkboxcomponent'}
<EventHandlerItem
title="on success"
tooltip="Select components to recompute after this runnable has successfully run"
items={Object.keys($runnableComponents).filter((id) => id !== ownId)}
bind:value={item.data.recomputeIds}
/>
{/if}
{#if item.data.type === 'checkboxcomponent'}
<EventHandlerItem
title="on toggle"
tooltip="Contrary to onSuccess, this will only trigger recompute when a human toggle the change, not if it set by a default value or by setValue"
items={Object.keys($runnableComponents).filter((id) => id !== ownId)}
bind:value={item.data.onToggle}
/>
{/if}
{#if item.data.type === 'resourceselectcomponent' || item.data.type === 'selectcomponent'}
<EventHandlerItem
title="on select"
tooltip="Contrary to onSuccess, this will only trigger recompute when a human select an item, not if it set by a default value or by setValue"
items={Object.keys($runnableComponents).filter((id) => id !== ownId)}
bind:value={item.data.onSelect}
/>
{/if}
{#if item.data.type === 'tabscomponent' || item.data.type === 'conditionalwrapper'}
<EventHandlerItem
title="on tab change"
tooltip="Select components to recompute after the selected tab was changed"
items={Object.keys($runnableComponents).filter((id) => id !== ownId)}
bind:value={item.data.onTabChange}
/>
{/if}
{#if item.data.type === 'fileinputcomponent' || item.data.type === 's3fileinputcomponent'}
<EventHandlerItem
title="on file change"
tooltip="Select components to recompute after a file was selected"
items={Object.keys($runnableComponents).filter((id) => id !== ownId)}
bind:value={item.data.onFileChange}
/>
{/if}
{#if item.data.type === 'steppercomponent'}
<EventHandlerItem
title="on next"
tooltip="Select components to recompute after the next button was clicked"
items={Object.keys($runnableComponents).filter((id) => id !== ownId)}
bind:value={item.data.onNext}
/>
{/if}
{#if item.data.type === 'steppercomponent'}
<EventHandlerItem
title="on previous"
tooltip="Select components to recompute after the previous button was clicked"
items={Object.keys($runnableComponents).filter((id) => id !== ownId)}
bind:value={item.data.onPrevious}
/>
{/if}
</PanelSection>
{/if}
@@ -23,6 +23,11 @@
$: finalInputSpecsConfiguration = inputSpecsConfiguration ?? inputSpecs
const dispatch = createEventDispatcher()
const mapping = {
onSuccess: 'On success wizard',
onError: 'On error wizard'
}
</script>
{#if inputSpecs}
@@ -38,7 +43,7 @@
{:else if finalInputSpecsConfiguration[k]?.type == 'oneOf'}
<OneOfInputSpecsEditor
{acceptSelf}
key={k}
key={mapping[k] ?? k}
bind:oneOf={inputSpecs[k]}
{id}
{shouldCapitalize}
@@ -1,61 +0,0 @@
<script lang="ts">
import Badge from '$lib/components/common/badge/Badge.svelte'
import { getContext } from 'svelte'
import type { AppViewerContext } from '../../types'
import PanelSection from './common/PanelSection.svelte'
import Toggle from '$lib/components/Toggle.svelte'
export let recomputeIds: string[] | undefined = undefined
export let ownId: string
export let title: string = 'Trigger runnables on success'
export let tooltip: string =
'Select components to recompute after this runnable has successfully run'
export let documentationLink: string =
'https://www.windmill.dev/docs/apps/app-runnable-panel#trigger-runnables-on-success'
const { runnableComponents } = getContext<AppViewerContext>('AppViewerContext')
function onChange(checked: boolean, id: string) {
if (checked) {
recomputeIds = [...(recomputeIds ?? []), id]
} else {
recomputeIds = recomputeIds?.filter((x) => x !== id)
}
}
</script>
<PanelSection {title} {tooltip} {documentationLink}>
{#if Object.keys($runnableComponents ?? {}).filter((id) => id !== ownId).length > 0}
<table class="divide-y border w-full">
<thead class="bg-surface-secondary">
<tr>
<th scope="col" class="px-2 py-2 text-left text-xs font-medium text-tertiary">
Component
</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-tertiary">
Recompute
</th>
</tr>
</thead>
<tbody>
{#each Object.keys($runnableComponents ?? {}).filter((id) => id !== ownId) as id}
<tr>
<td class="whitespace-nowrap px-2 text-xs">
<Badge color="indigo">{id}</Badge>
</td>
<td class="relative whitespace-nowrap px-4">
<Toggle
class="windmillapp my-1"
size="xs"
on:change={(e) => onChange(e.detail, id)}
checked={recomputeIds?.includes(id)}
/>
</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<div class="text-xs">No components to recompute. Create one and select it here.</div>
{/if}
</PanelSection>
@@ -78,6 +78,7 @@
export let ulSelectedClass: string = ``
export let ulSelectedStyle: string | null = null
export let value: Option | Option[] | null = null
export let disableRemoveAll: boolean = false
const selected_to_value = (selected: Option[]) => {
value = maxSelect === 1 ? selected[0] ?? null : selected
@@ -507,7 +508,7 @@
class="remove"
>
<slot name="remove-icon">
<X size={20} class="text-primary p-0.5" />
<X size={20} class="text-primary dark:text-primary-inverse p-0.5" />
</slot>
</button>
{/if}
@@ -552,7 +553,7 @@
{#if disabled}
<slot name="disabled-icon">disable</slot>
{:else if selected.length > 0}
{#if maxSelect !== 1 && selected.length > 1}
{#if maxSelect !== 1 && selected.length > 1 && !disableRemoveAll}
<button
type="button"
class="remove remove-all"
@@ -648,7 +649,7 @@
on:blur={() => (option_msg_is_active = false)}
role="option"
aria-selected="false"
class="user-msg"
class="user-msg p-1"
style:cursor={{
dupe: `not-allowed`,
create: `pointer`,
@@ -0,0 +1,97 @@
<script lang="ts">
// @ts-ignore
import Portal from 'svelte-portal'
import { createFloatingActions } from 'svelte-floating-ui'
import { tick } from 'svelte'
import { offset, flip, shift } from 'svelte-floating-ui/dom'
import MultiSelect from '$lib/components/multiselect/MultiSelect.svelte'
import DarkModeObserver from '../DarkModeObserver.svelte'
const [floatingRef, floatingContent] = createFloatingActions({
strategy: 'absolute',
middleware: [offset(5), flip(), shift()]
})
export let items: any[]
export let value: string[] | undefined = [] as string[]
let outerDiv: HTMLDivElement | undefined = undefined
let portalRef: HTMLDivElement | undefined = undefined
function moveOptionsToPortal() {
// Find ul element with class 'options' within the outerDiv
const ul = outerDiv?.querySelector('.options')
if (ul) {
// Move the ul element to the portal
portalRef?.appendChild(ul)
}
}
$: if (portalRef && outerDiv && items?.length > 0) {
tick().then(() => {
moveOptionsToPortal()
})
}
// bg-indigo-100 text-indigo-800 dark:bg-indigo-200 dark:text-indigo-900
let darkMode: boolean = false
let w = 0
let open: boolean = false
</script>
<DarkModeObserver bind:darkMode />
<div use:floatingRef bind:clientWidth={w}>
{#if !value || Array.isArray(value)}
<div class="border rounded-md border-gray-300 shadow-sm dark:border-gray-600 !w-full">
<MultiSelect
outerDivClass={`!text-xs`}
ulSelectedClass="overflow-auto"
bind:outerDiv
--sms-border={'none'}
--sms-min-height={'30px'}
--sms-focus-border={'none'}
--sms-selected-bg={darkMode ? '#c7d2fe' : '#e0e7ff'}
--sms-selected-text-color={darkMode ? '#312e81' : '#3730a3'}
bind:selected={value}
options={items}
on:close={() => {
open = false
}}
on:open={() => {
open = true
}}
let:option
disableRemoveAll
>
<!-- needed because portal doesn't work for mouseup event en mobile -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="w-full text-sm"
on:mouseup|stopPropagation
on:pointerdown|stopPropagation={(e) => {
let newe = new MouseEvent('mouseup')
e.target?.['parentElement']?.dispatchEvent(newe)
}}
>
{option}
</div>
</MultiSelect>
</div>
<Portal>
<div use:floatingContent class="z5000" hidden={!open}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
bind:this={portalRef}
class="multiselect"
style={`min-width: ${w}px;`}
on:click|stopPropagation
/>
</div>
</Portal>
{:else}
Value {value} is not an array
{/if}
</div>