Skip to content

Commit 0e41023

Browse files
feat: app builder accordion list and accordion tab component (#5132)
* feat: tabs component accordion * accordion tabs * accordionlistcomponent * types * Update AppTabs.svelte --------- Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
1 parent 2800903 commit 0e41023

File tree

7 files changed

+291
-27
lines changed

7 files changed

+291
-27
lines changed
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
<script lang="ts">
2+
import { getContext } from 'svelte'
3+
import { initOutput } from '../../editor/appUtils'
4+
import SubGridEditor from '../../editor/SubGridEditor.svelte'
5+
import type { AppViewerContext, ComponentCustomCSS } from '../../types'
6+
import { initCss } from '../../utils'
7+
import InitializeComponent from '../helpers/InitializeComponent.svelte'
8+
import type { AppInput } from '../../inputType'
9+
import RunnableWrapper from '../helpers/RunnableWrapper.svelte'
10+
import ListWrapper from '../layout/ListWrapper.svelte'
11+
import { twMerge } from 'tailwind-merge'
12+
import ResolveStyle from '../helpers/ResolveStyle.svelte'
13+
14+
export let id: string
15+
export let componentInput: AppInput | undefined
16+
export let customCss: ComponentCustomCSS<'accordionlistcomponent'> | undefined = undefined
17+
export let render: boolean
18+
export let initializing: boolean | undefined
19+
export let componentContainerHeight: number
20+
21+
type AccordionListValue = { header: string; [key: string]: any };
22+
23+
type InternalAccordionListInput = AppInput & {
24+
value: AccordionListValue[];
25+
};
26+
const accordionInput = componentInput as InternalAccordionListInput
27+
28+
const { app, focusedGrid, selectedComponent, worldStore, connectingInput } =
29+
getContext<AppViewerContext>('AppViewerContext')
30+
31+
let activeIndex: number = 0
32+
33+
const outputs = initOutput($worldStore, id, {
34+
result: undefined,
35+
activeIndex: 0,
36+
loading: false,
37+
inputs: {}
38+
})
39+
40+
function onFocus() {
41+
$focusedGrid = {
42+
parentComponentId: id,
43+
subGridIndex: 0
44+
}
45+
}
46+
47+
let css = initCss($app.css?.accordionlistcomponent, customCss)
48+
let result: any[] | undefined = undefined
49+
50+
let inputs = {}
51+
52+
$: $selectedComponent?.includes(id) &&
53+
$focusedGrid === undefined &&
54+
($focusedGrid = {
55+
parentComponentId: id,
56+
subGridIndex: 0
57+
})
58+
59+
function toggleAccordion(index: number) {
60+
activeIndex = activeIndex === index ? -1 : index
61+
outputs.activeIndex.set(activeIndex)
62+
}
63+
64+
</script>
65+
66+
{#each Object.keys(css ?? {}) as key (key)}
67+
<ResolveStyle
68+
{id}
69+
{customCss}
70+
{key}
71+
bind:css={css[key]}
72+
componentStyle={$app.css?.accordionlistcomponent}
73+
/>
74+
{/each}
75+
76+
<InitializeComponent {id} />
77+
78+
<RunnableWrapper
79+
render={true}
80+
{outputs}
81+
autoRefresh
82+
{componentInput}
83+
{id}
84+
bind:initializing
85+
bind:result
86+
>
87+
<div class="w-full flex flex-col overflow-auto max-h-full">
88+
{#if $app.subgrids?.[`${id}-0`]}
89+
{#if Array.isArray(result) && result.length > 0}
90+
{#each result ?? [] as value, index}
91+
<div class="border-b">
92+
<button
93+
on:pointerdown|stopPropagation
94+
on:click={() => toggleAccordion(index)}
95+
class={twMerge(
96+
'w-full text-left bg-surface !truncate text-sm hover:text-primary px-1 py-2',
97+
'wm-tabs-alltabs',
98+
activeIndex === index
99+
? twMerge('bg-surface text-primary ', 'wm-tabs-selectedTab')
100+
: 'text-secondary'
101+
)}
102+
>
103+
<span class="mr-2 w-8 font-mono">{activeIndex === index ? '-' : '+'}</span>
104+
{accordionInput?.value[index].header || `Header ${index}`}
105+
</button>
106+
{#if activeIndex === index}
107+
<div class="p-2 overflow-auto w-full">
108+
<ListWrapper
109+
onSet={(id, value) => {
110+
if (!inputs[id]) {
111+
inputs[id] = { [index]: value }
112+
} else {
113+
inputs[id] = { ...inputs[id], [index]: value }
114+
}
115+
outputs?.inputs.set(inputs, true)
116+
}}
117+
onRemove={(id) => {
118+
if (inputs?.[id] == undefined) {
119+
return
120+
}
121+
if (index == 0) {
122+
delete inputs[id]
123+
inputs = { ...inputs }
124+
} else {
125+
delete inputs[id][index]
126+
inputs[id] = { ...inputs[id] }
127+
}
128+
outputs?.inputs.set(inputs, true)
129+
}}
130+
{value}
131+
{index}
132+
>
133+
<SubGridEditor
134+
{id}
135+
visible={render}
136+
class={twMerge(css?.container?.class, 'wm-accordion')}
137+
style={css?.container?.style}
138+
subGridId={`${id}-0`}
139+
containerHeight={componentContainerHeight -
140+
(30 * accordionInput?.value.length + 40)}
141+
on:focus={() => {
142+
if (!$connectingInput.opened) {
143+
$selectedComponent = [id]
144+
}
145+
onFocus()
146+
}}
147+
/>
148+
</ListWrapper>
149+
</div>
150+
{/if}
151+
</div>
152+
{/each}
153+
{:else}
154+
<ListWrapper disabled value={undefined} index={0}>
155+
<SubGridEditor visible={false} {id} subGridId={`${id}-0`} />
156+
</ListWrapper>
157+
{#if !Array.isArray(result)}
158+
<div class="text-center text-tertiary">Input data is not an array</div>
159+
{/if}
160+
{/if}
161+
{/if}
162+
</div>
163+
</RunnableWrapper>

frontend/src/lib/components/apps/components/layout/AppTabs.svelte

Lines changed: 69 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
selectedTabIndex: 0
4949
})
5050
51+
const titleBarHeight = 40; // Example height of a single accordion title bar in pixels
52+
5153
function handleTabSelection() {
5254
selectedIndex = tabs?.indexOf(selected)
5355
outputs?.selectedTabIndex.set(selectedIndex)
@@ -170,28 +172,72 @@
170172
</button>
171173
{/each}
172174
</div>
173-
{/if}
174-
175-
<div class="w-full">
176-
{#if $app.subgrids}
177-
{#each tabs ?? [] as _res, i}
178-
<SubGridEditor
179-
{id}
180-
visible={render && i === selectedIndex}
181-
subGridId={`${id}-${i}`}
182-
class={twMerge(css?.container?.class, 'wm-tabs-container')}
183-
style={css?.container?.style}
184-
containerHeight={resolvedConfig.tabsKind !== 'sidebar' && $mode !== 'preview'
185-
? componentContainerHeight - tabHeight
186-
: componentContainerHeight}
187-
on:focus={() => {
188-
if (!$connectingInput.opened) {
189-
$selectedComponent = [id]
190-
handleTabSelection()
191-
}
192-
}}
193-
/>
175+
{:else if resolvedConfig.tabsKind == 'accordion'}
176+
<div class="flex flex-col w-full">
177+
{#each tabs ?? [] as res, index}
178+
<div class="border-b">
179+
<button
180+
on:pointerdown|stopPropagation
181+
on:click={() => (selected = res)}
182+
class={twMerge(
183+
'w-full text-left bg-surface !truncate text-sm hover:text-primary px-1 py-2',
184+
css?.allTabs?.class,
185+
'wm-tabs-alltabs',
186+
selected == res
187+
? twMerge(
188+
'bg-surface text-primary ',
189+
css?.selectedTab?.class,
190+
'wm-tabs-selectedTab'
191+
)
192+
: 'text-secondary'
193+
)}
194+
>
195+
<span class="mr-2 w-8 font-mono">{selected == res ? '-' : '+'}</span>
196+
{res}
197+
</button>
198+
{#if selected == res}
199+
<div class="p-2 border-t">
200+
<SubGridEditor
201+
{id}
202+
visible={render && index === selectedIndex}
203+
subGridId={`${id}-${index}`}
204+
class={twMerge(css?.container?.class, 'wm-tabs-container')}
205+
style={css?.container?.style}
206+
containerHeight={componentContainerHeight - (titleBarHeight * tabs.length + 40)}
207+
on:focus={() => {
208+
if (!$connectingInput.opened) {
209+
$selectedComponent = [id]
210+
handleTabSelection()
211+
}
212+
}}
213+
/>
214+
</div>
215+
{/if}
216+
</div>
194217
{/each}
195-
{/if}
196-
</div>
218+
</div>
219+
{:else}
220+
<div class="w-full">
221+
{#if $app.subgrids}
222+
{#each tabs ?? [] as _res, i}
223+
<SubGridEditor
224+
{id}
225+
visible={render && i === selectedIndex}
226+
subGridId={`${id}-${i}`}
227+
class={twMerge(css?.container?.class, 'wm-tabs-container')}
228+
style={css?.container?.style}
229+
containerHeight={resolvedConfig.tabsKind !== 'sidebar' && $mode !== 'preview'
230+
? componentContainerHeight - tabHeight
231+
: componentContainerHeight}
232+
on:focus={() => {
233+
if (!$connectingInput.opened) {
234+
$selectedComponent = [id]
235+
handleTabSelection()
236+
}
237+
}}
238+
/>
239+
{/each}
240+
{/if}
241+
</div>
242+
{/if}
197243
</div>

frontend/src/lib/components/apps/editor/component/Component.svelte

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import AppJobIdLogComponent from '../../components/display/AppJobIdLogComponent.svelte'
2828
import AppJobIdFlowStatus from '../../components/display/AppJobIdFlowStatus.svelte'
2929
import AppCarouselList from '../../components/display/AppCarouselList.svelte'
30+
import AppAccordionList from '../../components/display/AppAccordionList.svelte'
3031
import AppAggridTableEe from '../../components/display/table/AppAggridTableEe.svelte'
3132
import AppCustomComponent from '../../components/display/AppCustomComponent.svelte'
3233
import AppStatCard from '../../components/display/AppStatCard.svelte'
@@ -871,6 +872,15 @@
871872
{render}
872873
bind:initializing
873874
/>
875+
{:else if component.type === 'accordionlistcomponent'}
876+
<AppAccordionList
877+
id={component.id}
878+
componentInput={component.componentInput}
879+
customCss={component.customCss}
880+
{componentContainerHeight}
881+
{render}
882+
bind:initializing
883+
/>
874884
{:else if component.type === 'statcomponent'}
875885
<AppStatCard
876886
id={component.id}

frontend/src/lib/components/apps/editor/component/components.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ import {
5151
CalendarClock,
5252
AppWindow,
5353
PanelTop,
54-
RefreshCw
54+
RefreshCw,
55+
ListCollapse,
56+
GalleryThumbnails
5557
} from 'lucide-svelte'
5658
import type {
5759
Aligned,
@@ -244,6 +246,7 @@ export type SelectTabComponent = BaseComponent<'selecttabcomponent'>
244246
export type SelectStepComponent = BaseComponent<'selectstepcomponent'>
245247

246248
export type CarouselListComponent = BaseComponent<'carousellistcomponent'>
249+
export type AccordionListComponent = BaseComponent<'accordionlistcomponent'>
247250
export type StatisticCardComponent = BaseComponent<'statcomponent'>
248251
export type MenuComponent = BaseComponent<'menucomponent'> & {
249252
menuItems: (BaseAppComponent & ButtonComponent & GridItem)[]
@@ -351,6 +354,7 @@ export type TypedComponent =
351354
| DownloadComponent
352355
| ChartJsComponent
353356
| CarouselListComponent
357+
| AccordionListComponent
354358
| PlotlyComponentV2
355359
| ChartJsComponentV2
356360
| StatisticCardComponent
@@ -446,7 +450,7 @@ const buttonColorOptions = [...BUTTON_COLORS]
446450

447451
export const selectOptions = {
448452
buttonColorOptions,
449-
tabsKindOptions: ['tabs', 'sidebar', 'invisibleOnView'],
453+
tabsKindOptions: ['tabs', 'sidebar', 'accordion', 'invisibleOnView'],
450454
buttonSizeOptions: ['xs', 'sm', 'md', 'lg', 'xl'],
451455
tableSearchOptions: ['By Component', 'By Runnable', 'Disabled'],
452456
chartThemeOptions: ['theme1', 'theme2', 'theme3'],
@@ -2852,7 +2856,7 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
28522856
},
28532857
carousellistcomponent: {
28542858
name: 'Carousel List',
2855-
icon: ListIcon,
2859+
icon: GalleryThumbnails,
28562860
documentationLink: `${documentationBaseUrl}/carousel`,
28572861
dims: '3:8-12:8' as AppComponentDimensions,
28582862
customCss: {
@@ -2878,6 +2882,25 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
28782882
numberOfSubgrids: 1
28792883
}
28802884
},
2885+
accordionlistcomponent: {
2886+
name: 'Accordion List',
2887+
icon: ListCollapse,
2888+
documentationLink: `${documentationBaseUrl}/accordion`,
2889+
dims: '3:8-12:8' as AppComponentDimensions,
2890+
customCss: {
2891+
container: { class: '', style: '' }
2892+
},
2893+
initialData: {
2894+
configuration: {},
2895+
componentInput: {
2896+
type: 'static',
2897+
fieldType: 'array',
2898+
subFieldType: 'object',
2899+
value: [{ header: 'First', foo: 1 }, { header: 'Second', foo: 2 }, { header: 'Third', foo: 3 }] as object[]
2900+
},
2901+
numberOfSubgrids: 1
2902+
}
2903+
},
28812904
iconcomponent: {
28822905
name: 'Icon',
28832906
icon: Smile,
@@ -4152,6 +4175,17 @@ export const presetComponents = {
41524175
},
41534176
type: 'sidebartabscomponent'
41544177
},
4178+
accordiontabcomponent: {
4179+
name: 'Accordion Tabs',
4180+
icon: ListCollapse,
4181+
targetComponent: 'tabscomponent' as const,
4182+
configuration: {
4183+
tabsKind: {
4184+
value: 'accordion'
4185+
}
4186+
},
4187+
type: 'accordiontabcomponent'
4188+
},
41554189
invisibletabscomponent: {
41564190
name: 'Invisible Tabs',
41574191
icon: PanelTopInactive,

frontend/src/lib/components/apps/editor/component/sets.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { ComponentSet } from '../../types'
33
const tabs: ComponentSet = {
44
title: 'Tabs',
55
components: ['tabscomponent', 'conditionalwrapper'],
6-
presets: ['sidebartabscomponent', 'invisibletabscomponent']
6+
presets: ['sidebartabscomponent', 'accordiontabcomponent', 'invisibletabscomponent']
77
} as const
88

99
const layout: ComponentSet = {
@@ -19,6 +19,7 @@ const layout: ComponentSet = {
1919
'modalcomponent',
2020
'steppercomponent',
2121
'carousellistcomponent',
22+
'accordionlistcomponent',
2223
'decisiontreecomponent',
2324
'navbarcomponent',
2425
'recomputeallcomponent'

0 commit comments

Comments
 (0)