feat(frontend): conditional rendering (#1555)

* feat(frontend): conditional rendering

* feat(frontend): done

* feat(frontend): small fix

* feat(frontend): done

* feat(frontend): renaming

* feat(frontend): restructure components

* feat(frontend): revert setTab

* feat(frontend): fix setTab

* feat(frontend): fix setTab
This commit is contained in:
Faton Ramadani
2023-05-10 14:23:33 +02:00
committed by GitHub
parent ae332c9e2d
commit e97ddee8e8
12 changed files with 458 additions and 26 deletions
@@ -0,0 +1,92 @@
<script lang="ts">
import { getContext } from 'svelte'
import { initOutput } from '../../editor/appUtils'
import SubGridEditor from '../../editor/SubGridEditor.svelte'
import type { AppViewerContext, ComponentCustomCSS, RichConfiguration } from '../../types'
import { concatCustomCss } from '../../utils'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import { InputValue } from '../helpers'
export let id: string
export let componentContainerHeight: number
export let customCss: ComponentCustomCSS<'containercomponent'> | undefined = undefined
export let render: boolean
export let conditions: RichConfiguration[]
const { app, focusedGrid, selectedComponent, worldStore, connectingInput, componentControl } =
getContext<AppViewerContext>('AppViewerContext')
const outputs = initOutput($worldStore, id, {
conditions: [] as boolean[],
selectedConditionIndex: 0
})
function onFocus() {
$focusedGrid = {
parentComponentId: id,
subGridIndex: selectedConditionIndex
}
}
$: css = concatCustomCss($app.css?.containercomponent, customCss)
let resolvedConditions: boolean[] = []
let selectedConditionIndex = 0
function handleResolvedConditions() {
const slicedArray = resolvedConditions.slice(0, conditions.length)
const firstTrueIndex = slicedArray.findIndex((c) => c)
outputs.conditions.set(slicedArray, true)
setSelectedIndex(firstTrueIndex)
}
function setSelectedIndex(index: number) {
console.trace('setSelectedIndex', index)
$focusedGrid = {
parentComponentId: id,
subGridIndex: index
}
selectedConditionIndex = index
outputs.selectedConditionIndex.set(index)
}
$: resolvedConditions && handleResolvedConditions()
$componentControl[id] = {
setTab: (conditionIndex: number) => {
if (conditionIndex === -1) {
handleResolvedConditions()
} else {
setSelectedIndex(conditionIndex)
}
}
}
</script>
{#each conditions ?? [] as condition, index}
<InputValue {id} input={condition} bind:value={resolvedConditions[index]} />
{/each}
<InitializeComponent {id} />
<div class="w-full h-full">
{#if $app.subgrids?.[`${id}-${selectedConditionIndex}`]}
<SubGridEditor
visible={render}
{id}
class={css?.container?.class}
style={css?.container?.style}
subGridId={`${id}-${selectedConditionIndex}`}
containerHeight={componentContainerHeight}
on:focus={() => {
if (!$connectingInput.opened) {
$selectedComponent = [id]
}
onFocus()
}}
/>
{/if}
</div>
@@ -8,6 +8,8 @@
import type { AppComponent } from './component'
import { twMerge } from 'tailwind-merge'
import { getErrorFromLatestResult } from './appUtils'
import ButtonDropdown from '$lib/components/common/button/ButtonDropdown.svelte'
import { MenuItem } from '@rgossiaux/svelte-headlessui'
export let component: AppComponent
export let selected: boolean
@@ -17,9 +19,11 @@
export let hasInlineEditor: boolean = false
export let inlineEditorOpened: boolean = false
let isConditionalWrapperManuallySelected: boolean = false
const dispatch = createEventDispatcher()
const { errorByComponent, openDebugRun, jobs, connectingInput } =
const { errorByComponent, openDebugRun, jobs, connectingInput, componentControl } =
getContext<AppViewerContext>('AppViewerContext')
$: error = getErrorFromLatestResult(component.id, $errorByComponent, $jobs)
@@ -68,6 +72,58 @@
{/if}
</button>
{/if}
{#if component.type === 'conditionalwrapper'}
<button
title="Conditions"
class={classNames(
'text-2xs py-0.5 font-bold w-fit border cursor-pointer rounded-sm',
isConditionalWrapperManuallySelected
? 'bg-red-100 text-red-600 border-red-500 hover:bg-red-200 hover:text-red-800'
: 'bg-indigo-100 text-indigo-600 border-indigo-500 hover:bg-indigo-200 hover:text-indigo-800'
)}
on:click={() => dispatch('triggerInlineEditor')}
on:pointerdown|stopPropagation
>
<ButtonDropdown hasPadding={false}>
<svelte:fragment slot="items">
{#each component.conditions ?? [] as { }, index}
<MenuItem
on:click={() => {
$componentControl?.[component.id]?.setTab?.(index)
isConditionalWrapperManuallySelected = true
}}
>
<div
class={classNames(
'!text-gray-600 text-left px-4 py-2 gap-2 cursor-pointer hover:bg-gray-100 !text-xs font-semibold'
)}
>
{#if index === component.conditions.length - 1}
{`Debug default condition`}
{:else}
{`Debug condition ${index + 1}`}
{/if}
</div>
</MenuItem>
{/each}
<MenuItem
on:click={() => {
$componentControl?.[component.id]?.setTab?.(-1)
isConditionalWrapperManuallySelected = false
}}
>
<div
class={classNames(
'!text-red-600 text-left px-4 py-2 gap-2 cursor-pointer hover:bg-gray-100 !text-xs font-semibold'
)}
>
{`Reset debug mode`}
</div>
</MenuItem>
</svelte:fragment>
</ButtonDropdown>
</button>
{/if}
<button
title="Expand"
class={classNames(
@@ -94,17 +150,18 @@
<Anchor aria-label="Lock position" size={14} />
{/if}
</button>
<span
<div
draggable="false"
title="Move"
on:mousedown|stopPropagation|capture
class={classNames(
'px-1 text-2xs py-0.5 font-bold w-fit border cursor-move rounded-sm',
'bg-indigo-100 text-indigo-600 border-indigo-500 hover:bg-indigo-200 hover:text-indigo-800'
'bg-indigo-100 text-indigo-600 border-indigo-500 hover:bg-indigo-200 hover:text-indigo-800',
'flex items-center justify-center'
)}
>
<Move size={14} />
</span>
</div>
</div>
{/if}
@@ -248,6 +248,7 @@ export function appComponentFromType<T extends keyof typeof components>(
componentInput: init.componentInput,
panes: init.panes,
tabs: init.tabs,
conditions: init.conditions,
customCss: {},
recomputeIds: init.recomputeIds ? [] : undefined,
actionButtons: init.actionButtons ? [] : undefined,
@@ -42,6 +42,7 @@
import AppModal from '../../components/layout/AppModal.svelte'
import AppSchemaForm from '../../components/buttons/AppSchemaForm.svelte'
import AppStepper from '../../components/layout/AppStepper.svelte'
import AppConditionalWrapper from '../../components/layout/AppConditionalWrapper.svelte'
export let component: AppComponent
export let selected: boolean
@@ -402,6 +403,14 @@
componentInput={component.componentInput}
{render}
/>
{:else if component.type === 'conditionalwrapper' && component.conditions}
<AppConditionalWrapper
id={component.id}
conditions={component.conditions}
customCss={component.customCss}
{componentContainerHeight}
{render}
/>
{:else if component.type === 'containercomponent'}
<AppContainer
id={component.id}
@@ -34,17 +34,20 @@ import {
FlipHorizontal,
FlipVertical,
FileText,
AtSignIcon
AtSignIcon,
Split
} from 'lucide-svelte'
import type {
Aligned,
BaseAppComponent,
ComponentCustomCSS,
GridItem,
RichConfiguration,
StaticRichConfigurations
} from '../../types'
import type { Size } from '../../svelte-grid/types'
import type { ResultAppInput, StaticAppInput } from '../../inputType'
import type { AppInputSpec, ResultAppInput, StaticAppInput } from '../../inputType'
export type BaseComponent<T extends string> = {
type: T
@@ -109,6 +112,10 @@ export type ModalComponent = BaseComponent<'modalcomponent'>
export type StepperComponent = BaseComponent<'steppercomponent'> & {
tabs: string[]
}
export type ConditionalWrapperComponent = BaseComponent<'conditionalwrapper'> & {
conditions: RichConfiguration[]
}
export type Schemaformcomponent = BaseComponent<'schemaformcomponent'>
export type TypedComponent =
@@ -154,6 +161,7 @@ export type TypedComponent =
| ModalComponent
| StepperComponent
| Schemaformcomponent
| ConditionalWrapperComponent
export type AppComponent = BaseAppComponent & TypedComponent
@@ -196,6 +204,7 @@ export interface InitialAppComponent extends Partial<Aligned> {
actionButtons?: boolean
tabs?: string[]
panes?: number[]
conditions?: AppInputSpec<'boolean', boolean>[]
}
const buttonColorOptions = [...BUTTON_COLORS]
@@ -445,7 +454,7 @@ export const components = {
}
},
formcomponent: {
name: 'Form',
name: 'Submit form',
icon: FormInput,
dims: '3:5-6:5' as AppComponentDimensions,
customCss: {
@@ -1744,7 +1753,7 @@ Hello \${ctx.username}
}
},
schemaformcomponent: {
name: 'Schema Form',
name: 'Form',
icon: FileText,
dims: '3:8-8:12' as AppComponentDimensions,
customCss: {
@@ -1782,6 +1791,32 @@ Hello \${ctx.username}
}
}
}
},
conditionalwrapper: {
name: 'Conditional tabs',
icon: Split,
dims: '2:8-6:8' as AppComponentDimensions,
customCss: {
container: { class: '', style: '' }
},
initialData: {
configuration: {},
componentInput: undefined,
numberOfSubgrids: 2,
conditions: [
{
type: 'eval',
expr: 'false',
fieldType: 'boolean'
},
{
type: 'eval',
expr: 'true',
fieldType: 'boolean'
}
] as AppInputSpec<'boolean', boolean>[]
}
}
} as const
@@ -1,9 +1,13 @@
import type { ComponentSet } from '../../types'
const tabs: ComponentSet = {
title: 'Tabs',
components: ['tabscomponent', 'conditionalwrapper']
} as const
const layout: ComponentSet = {
title: 'Layout',
components: [
'tabscomponent',
'containercomponent',
'horizontaldividercomponent',
'verticaldividercomponent',
@@ -23,6 +27,7 @@ const buttons: ComponentSet = {
const inputs: ComponentSet = {
title: 'Inputs',
components: [
'schemaformcomponent',
'textinputcomponent',
'textareainputcomponent',
'passwordinputcomponent',
@@ -36,8 +41,7 @@ const inputs: ComponentSet = {
'checkboxcomponent',
'selectcomponent',
'resourceselectcomponent',
'multiselectcomponent',
'schemaformcomponent'
'multiselectcomponent'
]
} as const
@@ -49,17 +53,26 @@ const display: ComponentSet = {
'imagecomponent',
'mapcomponent',
'htmlcomponent',
'tablecomponent',
'aggridcomponent',
'barchartcomponent',
'piechartcomponent',
'vegalitecomponent',
'plotlycomponent',
'scatterchartcomponent',
'timeseriescomponent',
'pdfcomponent',
'displaycomponent'
]
} as const
export const COMPONENT_SETS = [layout, buttons, inputs, display] as const
const tables: ComponentSet = {
title: 'Tables',
components: ['tablecomponent', 'aggridcomponent']
} as const
const charts: ComponentSet = {
title: 'Charts',
components: [
'barchartcomponent',
'piechartcomponent',
'vegalitecomponent',
'plotlycomponent',
'scatterchartcomponent',
'timeseriescomponent'
]
} as const
export const COMPONENT_SETS = [tabs, layout, buttons, inputs, tables, display, charts] as const
@@ -553,6 +553,9 @@ export const quickStyleProperties: Record<
selectedStep: [typographyGrouping, sizeGrouping],
container: containerDefaultProps
},
conditionalwrapper: {
container: containerDefaultProps
},
pdfcomponent: {
container: containerDefaultProps
},
@@ -16,7 +16,7 @@
import AlignmentEditor from './AlignmentEditor.svelte'
import RunnableInputEditor from './inputEditor/RunnableInputEditor.svelte'
import TemplateEditor from '$lib/components/TemplateEditor.svelte'
import { COMPONENT_SETS, ccomponents } from '../component'
import { ccomponents } from '../component'
import CssProperty from '../componentsPanel/CssProperty.svelte'
import GridTab from './GridTab.svelte'
import { clearErrorByComponentId, clearJobsByComponentId, deleteGridItem } from '../appUtils'
@@ -27,6 +27,8 @@
import { secondaryMenu } from './secondaryMenu'
import StylePanel from './StylePanel.svelte'
import { Delete } from 'lucide-svelte'
import GridCondition from './GridCondition.svelte'
import { isTriggerable } from './script/utils'
export let componentSettings: { item: GridItem; parent: string | undefined } | undefined =
undefined
@@ -127,7 +129,7 @@
$: componentSettings?.item?.data && ($app = $app)
const hasInteraction = componentSettings?.item.data.type
? COMPONENT_SETS[1].components.includes(componentSettings?.item.data.type)
? isTriggerable(componentSettings?.item.data.type)
: false
</script>
@@ -244,6 +246,8 @@
<GridTab bind:tabs={componentSettings.item.data.tabs} {component} />
{:else if componentSettings.item.data.type === 'steppercomponent'}
<GridTab bind:tabs={componentSettings.item.data.tabs} {component} word="Step" />
{:else if componentSettings.item.data.type === 'conditionalwrapper'}
<GridCondition bind:conditions={componentSettings.item.data.conditions} {component} />
{:else if componentSettings.item.data.type === 'verticalsplitpanescomponent' || componentSettings.item.data.type === 'horizontalsplitpanescomponent'}
<GridPane bind:panes={componentSettings.item.data.panes} {component} />
{:else if componentSettings.item.data.type === 'tablecomponent' && Array.isArray(componentSettings.item.data.actionButtons)}
@@ -0,0 +1,214 @@
<script lang="ts">
import type { AppInputSpec } from '../../inputType'
import Button from '$lib/components/common/button/Button.svelte'
import { faPlus } from '@fortawesome/free-solid-svg-icons'
import PanelSection from './common/PanelSection.svelte'
import { dndzone } from 'svelte-dnd-action'
import { GripVertical, X } from 'lucide-svelte'
import InputsSpecEditor from './InputsSpecEditor.svelte'
import { generateRandomString } from '$lib/utils'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { twMerge } from 'tailwind-merge'
import type { AppViewerContext, RichConfiguration } from '../../types'
import { getContext, tick } from 'svelte'
import { deleteGridItem } from '../appUtils'
import type { AppComponent } from '../component'
import Toggle from '$lib/components/Toggle.svelte'
export let conditions: RichConfiguration[] = []
export let component: AppComponent
let dragDisabled = true
let items = conditions.map((condition, index) => {
return { value: condition, id: generateRandomString(), originalIndex: index }
})
$: conditions = items.map((item) => item.value)
const { app, runnableComponents, componentControl } =
getContext<AppViewerContext>('AppViewerContext')
function handleConsider(e: CustomEvent): void {
const { items: newItems } = e.detail
items = newItems
}
function handleFinalize(e: CustomEvent) {
const { items: newItems } = e.detail
items = newItems
// if the originalIndex are not in order, we should swap the subgrids
if (items.some((item, index) => item.originalIndex !== index)) {
const newSubgrids = {}
for (let i = 0; i < items.length; i++) {
newSubgrids[`${component.id}-${i}`] =
$app!.subgrids![`${component.id}-${items[i].originalIndex}`] ?? []
}
// update originalIndex
items.forEach((item, i) => {
item.originalIndex = i
})
$app!.subgrids = {
...$app!.subgrids,
...newSubgrids
}
$app = $app
tick().then(() => {
const targetIndex = items.findIndex((i) => i.id === e.detail.info.id)
$componentControl[component.id]?.setTab?.(targetIndex)
})
}
dragDisabled = true
}
function startDrag(event) {
event.preventDefault()
dragDisabled = false
}
function handleKeyDown(event: KeyboardEvent): void {
if ((event.key === 'Enter' || event.key === ' ') && dragDisabled) {
dragDisabled = false
}
}
function deleteSubgrid(index: number) {
let subgrid = `${component.id}-${index}`
for (const item of $app!.subgrids![subgrid]) {
const components = deleteGridItem($app, item.data, subgrid, false)
for (const key in components) {
delete $runnableComponents[key]
}
}
$runnableComponents = $runnableComponents
for (let i = index; i < items.length - 1; i++) {
$app!.subgrids![`${component.id}-${i}`] = $app!.subgrids![`${component.id}-${i + 1}`]
}
items.splice(index, 1)
items = [...items]
component.numberOfSubgrids = items.length
}
function addCondition(): void {
const numberOfConditions = items.length
if (!$app.subgrids) {
$app.subgrids = {}
}
const lastSubgrid = JSON.parse(
JSON.stringify($app.subgrids[`${component.id}-${numberOfConditions - 1}`])
)
$app.subgrids[`${component.id}-${numberOfConditions - 1}`] = []
$app.subgrids[`${component.id}-${numberOfConditions}`] = lastSubgrid
component.numberOfSubgrids = items.length
const newCondition: AppInputSpec<'boolean', boolean> = {
type: 'eval',
expr: 'false',
fieldType: 'boolean'
}
items.splice(conditions.length - 1, 0, {
value: newCondition,
id: generateRandomString(),
originalIndex: items.length - 1
})
component.numberOfSubgrids = items.length
}
</script>
<PanelSection title={'Conditions'}>
<Alert title="Evaluation" size="xs">
Conditions are evaluated in order. The first condition that evaluates to true will render its
subgrid.
</Alert>
{#if items.length == 0}
<span class="text-xs text-gray-500">No Tabs</span>
{/if}
<div class="w-full flex flex-col mt-2">
<section
use:dndzone={{
items: items,
flipDurationMs: 200,
dropTargetStyle: {}
}}
on:consider={handleConsider}
on:finalize={handleFinalize}
>
{#each items as item, index (item.id)}
{#if index < items.length - 1}
{@const condition = item.value}
<div class="w-full flex flex-row gap-2 items-center relative">
<div class={twMerge('grow border p-3 my-2 rounded-md border-gray-200 bg-white')}>
<InputsSpecEditor
key={`Condition ${index + 1}`}
bind:componentInput={item.value}
id={component.id}
userInputEnabled={false}
shouldCapitalize={true}
resourceOnly={false}
hasRows={false}
fieldType={condition?.['fieldType']}
subFieldType={condition?.['subFieldType']}
format={condition?.['format']}
selectOptions={condition?.['selectOptions']}
tooltip={condition?.['tooltip']}
onlyStatic={condition?.['onlyStatic']}
fileUpload={condition?.['fileUpload']}
placeholder={condition?.['placeholder']}
customTitle={condition?.['customTitle']}
displayType={false}
/>
</div>
<div class="flex flex-col justify-center gap-2">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div on:click={() => deleteSubgrid(index)}>
<X size={16} />
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div
tabindex={dragDisabled ? 0 : -1}
class="w-4 h-4"
on:mousedown={startDrag}
on:touchstart={startDrag}
on:keydown={handleKeyDown}
>
<GripVertical size={16} />
</div>
</div>
</div>
{/if}
{/each}
</section>
<div class="border rounded-md p-3 mb-2">
<div class="flex justify-between">
<span class="font-bold text-xs">Default</span>
<Toggle checked={true} disabled size="xs" />
</div>
<Alert title="Default" size="xs">
The default subgrid is rendered when no conditions evaluate to true.
</Alert>
</div>
<Button
size="xs"
color="light"
variant="border"
startIcon={{ icon: faPlus }}
on:click={addCondition}
iconOnly
/>
</div>
</PanelSection>
@@ -9,7 +9,8 @@
const { app } = getContext<AppViewerContext>('AppViewerContext')
const tabComponents = allItems($app.grid, $app.subgrids).filter(
(component) => component.data.type === 'tabscomponent'
(component) =>
component.data.type === 'tabscomponent' || component.data.type === 'conditionalwrapper'
)
</script>
@@ -97,7 +97,7 @@ export type ResultInput = {
hideRefreshButton?: boolean
}
type AppInputSpec<T extends InputType, U, V extends InputType = never> = (
export type AppInputSpec<T extends InputType, U, V extends InputType = never> = (
| StaticInput<U>
| ConnectedInput
| UserInput<U>
@@ -1,10 +1,13 @@
<script>
<script lang="ts">
import { Menu, Transition, MenuButton, MenuItems } from '@rgossiaux/svelte-headlessui'
import { ChevronDown } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
export let hasPadding: boolean = true
</script>
<Menu let:open as="div" class="relative hover:z-50 flex w-full h-full">
<MenuButton class="h-full w-full px-2">
<MenuButton class={twMerge('h-full w-full', hasPadding ? 'px-2' : '')}>
<ChevronDown class="w-5 h-5" />
</MenuButton>
<Transition