mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 00:01:55 +00:00
feat: dynamically hide tabs in app builder (#6653)
* feat: dynamically hide tabs in app builder * claude code comments
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
customCss?: ComponentCustomCSS<'tabscomponent'> | undefined
|
||||
render: boolean
|
||||
disabledTabs: RichConfiguration[]
|
||||
hiddenTabs: RichConfiguration[]
|
||||
onTabChange?: string[] | undefined
|
||||
}
|
||||
|
||||
@@ -39,6 +40,7 @@
|
||||
customCss = undefined,
|
||||
render,
|
||||
disabledTabs,
|
||||
hiddenTabs,
|
||||
onTabChange = undefined
|
||||
}: Props = $props()
|
||||
|
||||
@@ -101,18 +103,28 @@
|
||||
}
|
||||
},
|
||||
left: () => {
|
||||
const index = tabs.indexOf(selected)
|
||||
if (index > 0) {
|
||||
selected = tabs[index - 1]
|
||||
return true
|
||||
const currentIndex = tabs.indexOf(selected)
|
||||
if (currentIndex > 0) {
|
||||
// Find the next visible tab to the left
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
if (!resolvedHiddenTabs[i]) {
|
||||
selected = tabs[i]
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
right: () => {
|
||||
const index = tabs.indexOf(selected)
|
||||
if (index < tabs.length - 1) {
|
||||
selected = tabs[index + 1]
|
||||
return true
|
||||
const currentIndex = tabs.indexOf(selected)
|
||||
if (currentIndex < tabs.length - 1) {
|
||||
// Find the next visible tab to the right
|
||||
for (let i = currentIndex + 1; i < tabs.length; i++) {
|
||||
if (!resolvedHiddenTabs[i]) {
|
||||
selected = tabs[i]
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -144,6 +156,31 @@
|
||||
})
|
||||
|
||||
let resolvedDisabledTabs: boolean[] = $state([])
|
||||
let resolvedHiddenTabs: boolean[] = $state([])
|
||||
|
||||
// Filter visible tabs based on hiddenTabs configuration
|
||||
let visibleTabsIndices = $derived(
|
||||
(tabs ?? []).map((_, index) => index).filter((index) => !resolvedHiddenTabs[index])
|
||||
)
|
||||
|
||||
// Auto-select first visible tab if current selection is hidden or invalid
|
||||
$effect(() => {
|
||||
if (visibleTabsIndices.length === 0) {
|
||||
// Handle case where all tabs are hidden
|
||||
selected = ''
|
||||
selectedIndex = -1
|
||||
return
|
||||
}
|
||||
|
||||
const currentIndex = tabs?.indexOf(selected) ?? -1
|
||||
if (currentIndex === -1 || resolvedHiddenTabs[currentIndex]) {
|
||||
const firstVisibleTab = tabs[visibleTabsIndices[0]]
|
||||
if (selected !== firstVisibleTab) {
|
||||
// Current selection is hidden or invalid, select first visible tab
|
||||
selected = firstVisibleTab
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<InputValue key="kind" {id} input={configuration.tabsKind} bind:value={resolvedConfig.tabsKind} />
|
||||
@@ -169,6 +206,15 @@
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#each hiddenTabs ?? [] as hideTab, index}
|
||||
<InputValue
|
||||
key="tabHidden {index}"
|
||||
{id}
|
||||
input={hideTab}
|
||||
bind:value={resolvedHiddenTabs[index]}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if everRender}
|
||||
<div class={resolvedConfig.tabsKind == 'sidebar' ? 'flex gap-4 w-full h-full' : 'w-full'}>
|
||||
{#if !resolvedConfig.tabsKind || resolvedConfig.tabsKind == 'tabs' || (resolvedConfig.tabsKind == 'invisibleOnView' && $mode == 'dnd')}
|
||||
@@ -179,16 +225,18 @@
|
||||
style={css?.tabRow?.style}
|
||||
>
|
||||
{#each tabs ?? [] as res, index}
|
||||
<Tab
|
||||
value={res}
|
||||
class={twMerge(css?.allTabs?.class, 'wm-tabs-alltabs')}
|
||||
style={css?.allTabs?.style}
|
||||
selectedClass={twMerge(css?.selectedTab?.class, 'wm-tabs-selectedTab')}
|
||||
selectedStyle={css?.selectedTab?.style}
|
||||
disabled={resolvedDisabledTabs[index]}
|
||||
>
|
||||
<span class="font-semibold">{res}</span>
|
||||
</Tab>
|
||||
{#if !resolvedHiddenTabs[index]}
|
||||
<Tab
|
||||
value={res}
|
||||
class={twMerge(css?.allTabs?.class, 'wm-tabs-alltabs')}
|
||||
style={css?.allTabs?.style}
|
||||
selectedClass={twMerge(css?.selectedTab?.class, 'wm-tabs-selectedTab')}
|
||||
selectedStyle={css?.selectedTab?.style}
|
||||
disabled={resolvedDisabledTabs[index]}
|
||||
>
|
||||
<span class="font-semibold">{res}</span>
|
||||
</Tab>
|
||||
{/if}
|
||||
{/each}
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -202,78 +250,82 @@
|
||||
style={css?.tabRow?.style}
|
||||
>
|
||||
{#each tabs ?? [] as res, index}
|
||||
<button
|
||||
onpointerdown={stopPropagation(bubble('pointerdown'))}
|
||||
onclick={() => !resolvedDisabledTabs[index] && (selected = res)}
|
||||
disabled={resolvedDisabledTabs[index]}
|
||||
class={twMerge(
|
||||
'rounded-sm !truncate text-sm hover:text-primary px-1 py-2',
|
||||
css?.allTabs?.class,
|
||||
'wm-tabs-alltabs',
|
||||
selected == res
|
||||
? twMerge(
|
||||
'border-r border-primary border-l bg-surface text-primary',
|
||||
css?.selectedTab?.class,
|
||||
'wm-tabs-selectedTab'
|
||||
)
|
||||
: '',
|
||||
resolvedDisabledTabs[index]
|
||||
? 'opacity-50 cursor-not-allowed hover:text-secondary'
|
||||
: ''
|
||||
)}
|
||||
style={selected == res
|
||||
? [css?.allTabs?.style, css?.selectedTab?.style].filter(Boolean).join(';')
|
||||
: css?.allTabs?.style}
|
||||
>
|
||||
{res}
|
||||
</button>
|
||||
{#if !resolvedHiddenTabs[index]}
|
||||
<button
|
||||
onpointerdown={stopPropagation(bubble('pointerdown'))}
|
||||
onclick={() => !resolvedDisabledTabs[index] && (selected = res)}
|
||||
disabled={resolvedDisabledTabs[index]}
|
||||
class={twMerge(
|
||||
'rounded-sm !truncate text-sm hover:text-primary px-1 py-2',
|
||||
css?.allTabs?.class,
|
||||
'wm-tabs-alltabs',
|
||||
selected == res
|
||||
? twMerge(
|
||||
'border-r border-primary border-l bg-surface text-primary',
|
||||
css?.selectedTab?.class,
|
||||
'wm-tabs-selectedTab'
|
||||
)
|
||||
: '',
|
||||
resolvedDisabledTabs[index]
|
||||
? 'opacity-50 cursor-not-allowed hover:text-secondary'
|
||||
: ''
|
||||
)}
|
||||
style={selected == res
|
||||
? [css?.allTabs?.style, css?.selectedTab?.style].filter(Boolean).join(';')
|
||||
: css?.allTabs?.style}
|
||||
>
|
||||
{res}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if resolvedConfig.tabsKind == 'accordion'}
|
||||
<div class="flex flex-col w-full">
|
||||
{#each tabs ?? [] as res, index}
|
||||
<div class="border-b">
|
||||
<button
|
||||
onpointerdown={stopPropagation(bubble('pointerdown'))}
|
||||
onclick={() => !resolvedDisabledTabs[index] && (selected = res)}
|
||||
disabled={resolvedDisabledTabs[index]}
|
||||
class={twMerge(
|
||||
'w-full text-left bg-surface !truncate text-sm hover:text-primary px-1 py-2',
|
||||
css?.allTabs?.class,
|
||||
'wm-tabs-alltabs',
|
||||
selected == res
|
||||
? twMerge(
|
||||
'bg-surface text-primary ',
|
||||
css?.selectedTab?.class,
|
||||
'wm-tabs-selectedTab'
|
||||
)
|
||||
: 'text-secondary',
|
||||
resolvedDisabledTabs[index]
|
||||
? 'opacity-50 cursor-not-allowed hover:text-secondary'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<span class="mr-2 w-8 font-mono">{selected == res ? '-' : '+'}</span>
|
||||
{res}
|
||||
</button>
|
||||
<div class={selected == res ? 'border-t' : ''}>
|
||||
<SubGridEditor
|
||||
{id}
|
||||
visible={render && index === selectedIndex}
|
||||
subGridId={`${id}-${index}`}
|
||||
class={twMerge(css?.container?.class, 'wm-tabs-container')}
|
||||
style={css?.container?.style}
|
||||
containerHeight={componentContainerHeight - (titleBarHeight * tabs.length + 40)}
|
||||
onFocus={() => {
|
||||
if (!$connectingInput.opened) {
|
||||
$selectedComponent = [id]
|
||||
handleTabSelection()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if !resolvedHiddenTabs[index]}
|
||||
<div class="border-b">
|
||||
<button
|
||||
onpointerdown={stopPropagation(bubble('pointerdown'))}
|
||||
onclick={() => !resolvedDisabledTabs[index] && (selected = res)}
|
||||
disabled={resolvedDisabledTabs[index]}
|
||||
class={twMerge(
|
||||
'w-full text-left bg-surface !truncate text-sm hover:text-primary px-1 py-2',
|
||||
css?.allTabs?.class,
|
||||
'wm-tabs-alltabs',
|
||||
selected == res
|
||||
? twMerge(
|
||||
'bg-surface text-primary ',
|
||||
css?.selectedTab?.class,
|
||||
'wm-tabs-selectedTab'
|
||||
)
|
||||
: 'text-secondary',
|
||||
resolvedDisabledTabs[index]
|
||||
? 'opacity-50 cursor-not-allowed hover:text-secondary'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<span class="mr-2 w-8 font-mono">{selected == res ? '-' : '+'}</span>
|
||||
{res}
|
||||
</button>
|
||||
<div class={selected == res ? 'border-t' : ''}>
|
||||
<SubGridEditor
|
||||
{id}
|
||||
visible={render && index === selectedIndex}
|
||||
subGridId={`${id}-${index}`}
|
||||
class={twMerge(css?.container?.class, 'wm-tabs-container')}
|
||||
style={css?.container?.style}
|
||||
containerHeight={componentContainerHeight - (titleBarHeight * tabs.length + 40)}
|
||||
onFocus={() => {
|
||||
if (!$connectingInput.opened) {
|
||||
$selectedComponent = [id]
|
||||
handleTabSelection()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -853,6 +853,7 @@
|
||||
id={component.id}
|
||||
tabs={component.tabs}
|
||||
disabledTabs={component.disabledTabs}
|
||||
hiddenTabs={component.hiddenTabs}
|
||||
onTabChange={component.onTabChange}
|
||||
customCss={component.customCss}
|
||||
{componentContainerHeight}
|
||||
|
||||
@@ -239,6 +239,7 @@ export type FileInputComponent = BaseComponent<'fileinputcomponent'> & {
|
||||
export type TabsComponent = BaseComponent<'tabscomponent'> & {
|
||||
tabs: string[]
|
||||
disabledTabs: RichConfiguration[]
|
||||
hiddenTabs: RichConfiguration[]
|
||||
onTabChange?: string[]
|
||||
}
|
||||
|
||||
@@ -3012,7 +3013,15 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
|
||||
},
|
||||
componentInput: undefined,
|
||||
numberOfSubgrids: 2,
|
||||
tabs: ['First tab', 'Second tab'] as string[]
|
||||
tabs: ['First tab', 'Second tab'] as string[],
|
||||
disabledTabs: [
|
||||
{ type: 'static', value: false, fieldType: 'boolean' },
|
||||
{ type: 'static', value: false, fieldType: 'boolean' }
|
||||
] as RichConfiguration[],
|
||||
hiddenTabs: [
|
||||
{ type: 'static', value: false, fieldType: 'boolean' },
|
||||
{ type: 'static', value: false, fieldType: 'boolean' }
|
||||
] as RichConfiguration[]
|
||||
}
|
||||
},
|
||||
steppercomponent: {
|
||||
|
||||
@@ -391,8 +391,10 @@
|
||||
<GridTab
|
||||
bind:tabs={item.data.tabs}
|
||||
bind:disabledTabs={item.data.disabledTabs}
|
||||
bind:hiddenTabs={item.data.hiddenTabs}
|
||||
bind:component={item.data}
|
||||
canDisableTabs
|
||||
canHideTabs
|
||||
/>
|
||||
{:else if item.data.type === 'aggridcomponentee'}
|
||||
<GridAgGridLicenseKey bind:license={item.data.license} />
|
||||
|
||||
@@ -13,11 +13,14 @@
|
||||
import { generateRandomString } from '$lib/utils'
|
||||
import { GripVertical, Plus } from 'lucide-svelte'
|
||||
import GridTabDisabled from './GridTabDisabled.svelte'
|
||||
import GridTabHidden from './GridTabHidden.svelte'
|
||||
|
||||
interface Props {
|
||||
tabs?: string[]
|
||||
disabledTabs?: RichConfiguration[]
|
||||
hiddenTabs?: RichConfiguration[]
|
||||
canDisableTabs?: boolean
|
||||
canHideTabs?: boolean
|
||||
word?: string
|
||||
component: AppComponent
|
||||
}
|
||||
@@ -25,7 +28,9 @@
|
||||
let {
|
||||
tabs = $bindable(undefined),
|
||||
disabledTabs = $bindable(undefined),
|
||||
hiddenTabs = $bindable(undefined),
|
||||
canDisableTabs = false,
|
||||
canHideTabs = false,
|
||||
word = 'Tab',
|
||||
component = $bindable()
|
||||
}: Props = $props()
|
||||
@@ -40,6 +45,12 @@
|
||||
{ type: 'static', value: false, fieldType: 'boolean' }
|
||||
]
|
||||
}
|
||||
if (hiddenTabs == undefined) {
|
||||
hiddenTabs = [
|
||||
{ type: 'static', value: false, fieldType: 'boolean' },
|
||||
{ type: 'static', value: false, fieldType: 'boolean' }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
let items = $state.raw(
|
||||
@@ -77,6 +88,7 @@
|
||||
component.numberOfSubgrids = items.length
|
||||
|
||||
disabledTabs = [...(disabledTabs ?? []), { type: 'static', value: false, fieldType: 'boolean' }]
|
||||
hiddenTabs = [...(hiddenTabs ?? []), { type: 'static', value: false, fieldType: 'boolean' }]
|
||||
}
|
||||
|
||||
function deleteSubgrid(index: number) {
|
||||
@@ -99,6 +111,9 @@
|
||||
// Delete the item in the disabledTabs array
|
||||
disabledTabs = (disabledTabs ?? []).filter((_, i) => i !== index)
|
||||
|
||||
// Delete the item in the hiddenTabs array
|
||||
hiddenTabs = (hiddenTabs ?? []).filter((_, i) => i !== index)
|
||||
|
||||
component.numberOfSubgrids = items.length
|
||||
// Update the originalIndex of the remaining items
|
||||
items.forEach((item, i) => {
|
||||
@@ -136,10 +151,13 @@
|
||||
}
|
||||
|
||||
const newDisabledTabs: RichConfiguration[] = []
|
||||
const newHiddenTabs: RichConfiguration[] = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
disabledTabs && newDisabledTabs.push(disabledTabs[items[i].originalIndex])
|
||||
hiddenTabs && newHiddenTabs.push(hiddenTabs[items[i].originalIndex])
|
||||
}
|
||||
disabledTabs = newDisabledTabs
|
||||
hiddenTabs = newHiddenTabs
|
||||
|
||||
// update originalIndex
|
||||
items.forEach((item, i) => {
|
||||
@@ -203,6 +221,11 @@
|
||||
{#if canDisableTabs && disabledTabs}
|
||||
<GridTabDisabled {index} bind:field={disabledTabs[index]} id={component.id} />
|
||||
{/if}
|
||||
{#if canHideTabs && hiddenTabs}
|
||||
<div class="mt-2">
|
||||
<GridTabHidden {index} bind:field={hiddenTabs[index]} id={component.id} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import type { RichConfiguration } from '../../types'
|
||||
import InputsSpecEditor from './InputsSpecEditor.svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
field: RichConfiguration
|
||||
index: number
|
||||
}
|
||||
|
||||
let { id, field = $bindable(), index }: Props = $props()
|
||||
|
||||
let hideable = $state(field && !(field?.type === 'static' && field?.value === false))
|
||||
</script>
|
||||
|
||||
<Toggle
|
||||
bind:checked={hideable}
|
||||
size="xs"
|
||||
options={{
|
||||
right: 'Can be hidden'
|
||||
}}
|
||||
on:change={() => {
|
||||
if (hideable) {
|
||||
field = {
|
||||
type: 'evalv2',
|
||||
expr: 'false',
|
||||
fieldType: 'boolean',
|
||||
connections: []
|
||||
}
|
||||
} else {
|
||||
field = {
|
||||
type: 'static',
|
||||
value: false,
|
||||
fieldType: 'boolean'
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if hideable}
|
||||
<div transition:slide|local>
|
||||
<InputsSpecEditor
|
||||
key="tabHidden {index}"
|
||||
bind:componentInput={field}
|
||||
{id}
|
||||
userInputEnabled={false}
|
||||
shouldCapitalize={true}
|
||||
resourceOnly={false}
|
||||
fieldType={field?.['fieldType']}
|
||||
subFieldType={field?.['subFieldType']}
|
||||
format={field?.['format']}
|
||||
selectOptions={field?.['selectOptions']}
|
||||
tooltip={field?.['tooltip']}
|
||||
fileUpload={field?.['fileUpload']}
|
||||
placeholder={field?.['placeholder']}
|
||||
customTitle={field?.['customTitle']}
|
||||
displayType={false}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user