mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 16:02:11 +00:00
add fuzzy search to instance settings (#8000)
* feat: add fuzzy search to instance settings sidebar Adds a search input at the top of the superadmin settings sidebar that uses uFuzzy for fuzzy matching against all setting labels, descriptions, and categories. Selecting a result navigates to the correct tab and scrolls to the specific setting card with a brief highlight. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: improve settings search display and description matching - Extract only the label portion from uFuzzy highlighted text for cleaner dropdown display - Show description only when the match is in the description and NOT in the label - Truncate descriptions to 80 chars in searchable items - Add maxHeight prop to SelectDropdown for configurable height Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: fix search description truncation and handle undefined marked values Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove description from settings search dropdown Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add smooth outline transition for setting highlight animation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: highlight first search result by default for enter-to-select Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * nit * clean code * fix: address review feedback - sanitize html, remove max-w-40, document description field Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: constrain search dropdown width to prevent long title overflow Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * perf: add 150ms debounce to settings search filter Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: clean up timeouts on destroy and re-invocation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: extract settings search into reusable SettingsSearchInput component Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use twMerge for class prop in SettingsSearchInput Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: wrap debounced state write in untrack to prevent re-triggering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,25 +4,34 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@media (min-width: 1760px) {
|
||||
:root {
|
||||
font-size: 18px;
|
||||
}
|
||||
:root {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
/* Light mode: default border color */
|
||||
.border, .border-t, .border-r, .border-b, .border-l,
|
||||
.border-x, .border-y,
|
||||
.divide-x > :not([hidden]) ~ :not([hidden]),
|
||||
.border,
|
||||
.border-t,
|
||||
.border-r,
|
||||
.border-b,
|
||||
.border-l,
|
||||
.border-x,
|
||||
.border-y,
|
||||
.divide-x > :not([hidden]) ~ :not([hidden]),
|
||||
.divide-y > :not([hidden]) ~ :not([hidden]) {
|
||||
border-color: rgb(var(--color-border-light));
|
||||
}
|
||||
|
||||
/* Dark mode: change border color */
|
||||
.dark .border, .dark .border-t, .dark .border-r, .dark .border-b, .dark .border-l,
|
||||
.dark .border-x, .dark .border-y,
|
||||
.dark .divide-x > :not([hidden]) ~ :not([hidden]),
|
||||
.dark .border,
|
||||
.dark .border-t,
|
||||
.dark .border-r,
|
||||
.dark .border-b,
|
||||
.dark .border-l,
|
||||
.dark .border-x,
|
||||
.dark .border-y,
|
||||
.dark .divide-x > :not([hidden]) ~ :not([hidden]),
|
||||
.dark .divide-y > :not([hidden]) ~ :not([hidden]) {
|
||||
border-color: rgb(var(--color-border-light));
|
||||
}
|
||||
@@ -205,11 +214,21 @@ svelte-virtual-list-contents > * + * {
|
||||
/* Prevent clock icon in input[type="time"] making the input taller */
|
||||
|
||||
/* Chrome, Safari, Edge, Opera */
|
||||
input[type="time"]::-webkit-calendar-picker-indicator {
|
||||
input[type='time']::-webkit-calendar-picker-indicator {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Settings search highlight (used by both the drawer and the setup page) */
|
||||
[data-setting-key] {
|
||||
transition: outline 0.8s ease;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
[data-setting-key].setting-highlight {
|
||||
outline: 2px solid rgb(var(--color-border-accent, 59 130 246));
|
||||
}
|
||||
|
||||
.svelte-flow__edges {
|
||||
z-index: -10;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,15 @@
|
||||
warning?: string
|
||||
}
|
||||
|
||||
let { setting, version, values, loading = true, openSmtpSettings, oauths, warning }: Props = $props()
|
||||
let {
|
||||
setting,
|
||||
version,
|
||||
values,
|
||||
loading = true,
|
||||
openSmtpSettings,
|
||||
oauths,
|
||||
warning
|
||||
}: Props = $props()
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let latestKeyRenewalAttempt: {
|
||||
@@ -174,7 +182,12 @@
|
||||
<!-- {JSON.stringify($values, null, 2)} -->
|
||||
{#if (!setting.cloudonly || isCloudHosted()) && showSetting(setting.key, $values) && !(setting.hiddenIfNull && $values[setting.key] == null) && !(setting.hiddenIfEmpty && !$values[setting.key]) && !(setting.hiddenInEe && $enterpriseLicense)}
|
||||
{#if setting.fieldType == 'select'}
|
||||
<SettingCard label={setting.label} description={setting.description} ee_only={setting.ee_only}>
|
||||
<SettingCard
|
||||
label={setting.label}
|
||||
description={setting.description}
|
||||
ee_only={setting.ee_only}
|
||||
settingKey={setting.key}
|
||||
>
|
||||
<ToggleButtonGroup bind:selected={$values[setting.key]}>
|
||||
{#snippet children({ item: toggleButton })}
|
||||
{#each setting.select_items ?? [] as item}
|
||||
@@ -189,7 +202,12 @@
|
||||
</ToggleButtonGroup>
|
||||
</SettingCard>
|
||||
{:else if setting.fieldType == 'select_python'}
|
||||
<SettingCard label={setting.label} description={setting.description} ee_only={setting.ee_only}>
|
||||
<SettingCard
|
||||
label={setting.label}
|
||||
description={setting.description}
|
||||
ee_only={setting.ee_only}
|
||||
settingKey={setting.key}
|
||||
>
|
||||
<ToggleButtonGroup bind:selected={$values[setting.key]}>
|
||||
{#snippet children({ item: toggleButton })}
|
||||
{#each setting.select_items ?? [] as item}
|
||||
@@ -234,6 +252,7 @@
|
||||
label="Memory"
|
||||
description="Configure the memory budget for the indexer and manage index clearing."
|
||||
ee_only=""
|
||||
settingKey="indexer_settings_memory"
|
||||
>
|
||||
<div class="p-4 rounded-md border mt-2">
|
||||
<IndexerMemorySettings {values} disabled={!$enterpriseLicense} errors={fieldErrors} />
|
||||
@@ -243,6 +262,7 @@
|
||||
label="Completed Job Index"
|
||||
description="Configure indexing parameters for completed jobs."
|
||||
ee_only=""
|
||||
settingKey="indexer_settings_jobs"
|
||||
>
|
||||
<div class="p-4 rounded-md border mt-2">
|
||||
<IndexerJobIndexSettings {values} disabled={!$enterpriseLicense} errors={fieldErrors} />
|
||||
@@ -252,6 +272,7 @@
|
||||
label="Service Logs Index"
|
||||
description="Configure indexing parameters for service logs."
|
||||
ee_only=""
|
||||
settingKey="indexer_settings_logs"
|
||||
>
|
||||
<div class="p-4 rounded-md border mt-2">
|
||||
<IndexerLogIndexSettings {values} disabled={!$enterpriseLicense} errors={fieldErrors} />
|
||||
@@ -264,6 +285,7 @@
|
||||
description={setting.description}
|
||||
ee_only={setting.ee_only}
|
||||
tooltip={setting.tooltip}
|
||||
settingKey={setting.key}
|
||||
actionButton={setting.actionButton}
|
||||
values={$values}
|
||||
>
|
||||
|
||||
@@ -26,16 +26,19 @@
|
||||
import InstanceNameEditor from './InstanceNameEditor.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import { instanceSettingsSelectedTab } from '$lib/stores'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { onDestroy, tick } from 'svelte'
|
||||
import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte'
|
||||
import {
|
||||
instanceSettingsNavigationGroups,
|
||||
tabToCategoryMap,
|
||||
tabToAuthSubTab,
|
||||
categoryToTabMap
|
||||
categoryToTabMap,
|
||||
buildSearchableSettingItems,
|
||||
type SearchableSettingItem
|
||||
} from './instanceSettings'
|
||||
import TextInput from './text_input/TextInput.svelte'
|
||||
import SettingsPageHeader from './settings/SettingsPageHeader.svelte'
|
||||
import SettingsSearchInput from './instanceSettings/SettingsSearchInput.svelte'
|
||||
|
||||
let filter = $state('')
|
||||
|
||||
@@ -138,6 +141,35 @@
|
||||
export function syncBeforeDiff(): boolean {
|
||||
return instanceSettings?.syncBeforeDiff() ?? true
|
||||
}
|
||||
|
||||
// --- Settings search ---
|
||||
const searchableItems = buildSearchableSettingItems()
|
||||
|
||||
let scrollTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let highlightTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function handleSearchSelect(item: SearchableSettingItem) {
|
||||
handleNavigate(item.tabId)
|
||||
if (item.settingKey) {
|
||||
clearTimeout(scrollTimeout)
|
||||
clearTimeout(highlightTimeout)
|
||||
await tick()
|
||||
// Wait for the tab content to render before scrolling
|
||||
scrollTimeout = setTimeout(() => {
|
||||
const el = document.querySelector(`[data-setting-key="${item.settingKey}"]`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
el.classList.add('setting-highlight')
|
||||
highlightTimeout = setTimeout(() => el.classList.remove('setting-highlight'), 2500)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(scrollTimeout)
|
||||
clearTimeout(highlightTimeout)
|
||||
})
|
||||
</script>
|
||||
|
||||
<SearchItems
|
||||
@@ -174,13 +206,14 @@
|
||||
{#if !yamlMode && !diffMode}
|
||||
<!-- Sidebar Navigation -->
|
||||
<div class="w-52 shrink-0 h-full overflow-auto p-4 bg-surface flex flex-col">
|
||||
<SettingsSearchInput {searchableItems} onSelect={handleSearchSelect} class="mb-3" />
|
||||
<SidebarNavigation
|
||||
groups={instanceSettingsNavigationGroups}
|
||||
selectedId={tab}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
{#if $workspaceStore !== 'admins'}
|
||||
<div class="mt-auto pt-4 border-t border-surface-hover">
|
||||
<div class="mt-4 pt-2 border-t border-surface-hover">
|
||||
<a
|
||||
href="{base}/?workspace=admins"
|
||||
target="_blank"
|
||||
@@ -510,3 +543,4 @@
|
||||
<span>Are you sure you want to remove <b>{deleteUserEmail}</b>?</span>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
|
||||
|
||||
@@ -321,7 +321,7 @@ export const settings: Record<string, Setting[]> = {
|
||||
],
|
||||
SMTP: [
|
||||
{
|
||||
label: 'SMTP',
|
||||
label: 'SMTP configuration',
|
||||
key: 'smtp_settings',
|
||||
fieldType: 'smtp_connect',
|
||||
storage: 'setting',
|
||||
@@ -782,3 +782,84 @@ export const categoryToTabMap: Record<string, string> = {
|
||||
Jobs: 'jobs',
|
||||
'Private Hub': 'private_hub'
|
||||
}
|
||||
|
||||
export interface SearchableSettingItem {
|
||||
label: string
|
||||
tabId: string
|
||||
settingKey?: string
|
||||
category: string
|
||||
/** Full description text (HTML stripped), used for search matching only — not displayed */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the label portion from a uFuzzy marked/highlighted string.
|
||||
* Only allows `<mark>` and `</mark>` tags through (sanitizes everything else).
|
||||
*/
|
||||
export function extractMarkedLabel(marked: string | undefined, labelLength: number): string {
|
||||
if (!marked) return ''
|
||||
let plainIdx = 0
|
||||
let markedIdx = 0
|
||||
while (plainIdx < labelLength && markedIdx < marked.length) {
|
||||
if (marked[markedIdx] === '<') {
|
||||
while (markedIdx < marked.length && marked[markedIdx] !== '>') markedIdx++
|
||||
markedIdx++
|
||||
} else {
|
||||
plainIdx++
|
||||
markedIdx++
|
||||
}
|
||||
}
|
||||
// Include any closing </mark> right after
|
||||
if (marked.startsWith('</mark>', markedIdx)) {
|
||||
markedIdx += '</mark>'.length
|
||||
}
|
||||
// Sanitize: only allow <mark> and </mark> tags from uFuzzy highlight
|
||||
return marked.slice(0, markedIdx).replace(/<(?!\/?mark>)[^>]*>/g, '')
|
||||
}
|
||||
|
||||
export function buildSearchableSettingItems(
|
||||
navigationGroups: typeof instanceSettingsNavigationGroups = instanceSettingsNavigationGroups
|
||||
): SearchableSettingItem[] {
|
||||
const items: SearchableSettingItem[] = []
|
||||
|
||||
// Add sidebar navigation items (tab-level)
|
||||
for (const group of navigationGroups) {
|
||||
for (const navItem of group.items) {
|
||||
items.push({
|
||||
label: navItem.label,
|
||||
tabId: navItem.id,
|
||||
category: group.title
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add individual settings from each category
|
||||
for (const [category, categorySettings] of Object.entries(settings)) {
|
||||
const tabId = categoryToTabMap[category]
|
||||
if (!tabId) continue
|
||||
for (const setting of categorySettings) {
|
||||
if (!setting.label) continue
|
||||
items.push({
|
||||
label: setting.label,
|
||||
tabId,
|
||||
settingKey: setting.key,
|
||||
category,
|
||||
description: setting.description?.replace(/<[^>]*>/g, '') ?? ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add SCIM/SAML settings
|
||||
for (const setting of scimSamlSetting) {
|
||||
if (!setting.label) continue
|
||||
items.push({
|
||||
label: setting.label,
|
||||
tabId: 'scim_saml',
|
||||
settingKey: setting.key,
|
||||
category: 'SCIM/SAML',
|
||||
description: setting.description?.replace(/<[^>]*>/g, '') ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
description?: string
|
||||
ee_only?: string
|
||||
tooltip?: string
|
||||
settingKey?: string
|
||||
actionButton?: {
|
||||
label: string
|
||||
onclick: (values: Record<string, any>) => Promise<void>
|
||||
@@ -26,6 +27,7 @@
|
||||
description,
|
||||
ee_only,
|
||||
tooltip,
|
||||
settingKey,
|
||||
actionButton,
|
||||
values,
|
||||
children,
|
||||
@@ -33,7 +35,10 @@
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class={twMerge('p-4 rounded-md bg-surface-tertiary shadow-sm flex flex-col gap-1', clazz)}>
|
||||
<div
|
||||
data-setting-key={settingKey}
|
||||
class={twMerge('p-4 rounded-md bg-surface-tertiary shadow-sm flex flex-col gap-1', clazz)}
|
||||
>
|
||||
{#if label}
|
||||
<div class="flex items-center justify-between gap-2 w-full">
|
||||
<div class="flex gap-1 items-baseline">
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import SearchItems from '../SearchItems.svelte'
|
||||
import SelectDropdown from '../select/SelectDropdown.svelte'
|
||||
import type { ProcessedItem } from '../select/utils.svelte'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import { Search } from 'lucide-svelte'
|
||||
import {
|
||||
extractMarkedLabel,
|
||||
type SearchableSettingItem
|
||||
} from '../instanceSettings'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { untrack } from 'svelte'
|
||||
|
||||
let {
|
||||
searchableItems,
|
||||
onSelect,
|
||||
class: className = ''
|
||||
}: {
|
||||
searchableItems: SearchableSettingItem[]
|
||||
onSelect: (item: SearchableSettingItem) => void
|
||||
class?: string
|
||||
} = $props()
|
||||
|
||||
let settingsSearchFilter = $state('')
|
||||
let debouncedSearchFilter = $state('')
|
||||
let filteredSearchItems: (SearchableSettingItem & { marked: string })[] = $state([])
|
||||
let searchInputEl: HTMLDivElement | undefined = $state()
|
||||
|
||||
// Debounce search to avoid running uFuzzy on every keystroke
|
||||
$effect(() => {
|
||||
const val = settingsSearchFilter
|
||||
const timeout = setTimeout(() => untrack(() => (debouncedSearchFilter = val)), 150)
|
||||
return () => clearTimeout(timeout)
|
||||
})
|
||||
|
||||
const searchDropdownOpen = $derived(
|
||||
settingsSearchFilter.trim().length > 0 && filteredSearchItems.length > 0
|
||||
)
|
||||
|
||||
let searchProcessedItems: ProcessedItem<SearchableSettingItem & { marked: string }>[] = $derived(
|
||||
filteredSearchItems.map((item) => ({
|
||||
label: item.label,
|
||||
value: item,
|
||||
subtitle: item.category
|
||||
}))
|
||||
)
|
||||
|
||||
function handleSelect(item: SearchableSettingItem) {
|
||||
settingsSearchFilter = ''
|
||||
onSelect(item)
|
||||
}
|
||||
</script>
|
||||
|
||||
<SearchItems
|
||||
filter={debouncedSearchFilter}
|
||||
items={searchableItems}
|
||||
bind:filteredItems={filteredSearchItems}
|
||||
f={(x) => x.label + ' ' + (x.description ?? '') + ' ' + x.category}
|
||||
/>
|
||||
|
||||
<div class={twMerge('relative', className)}>
|
||||
<div bind:this={searchInputEl} class="relative w-full">
|
||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 text-tertiary" size={14} />
|
||||
<TextInput
|
||||
inputProps={{ placeholder: 'Search settings...' }}
|
||||
bind:value={settingsSearchFilter}
|
||||
class="pl-7 text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
<SelectDropdown
|
||||
processedItems={searchProcessedItems}
|
||||
value={undefined}
|
||||
open={searchDropdownOpen}
|
||||
disablePortal
|
||||
class="max-w-full"
|
||||
itemLabelWrapperClasses="hidden"
|
||||
itemButtonWrapperClasses="overflow-hidden"
|
||||
getInputRect={searchInputEl ? () => searchInputEl!.getBoundingClientRect() : undefined}
|
||||
onSelectValue={(item) => handleSelect(item.value)}
|
||||
highlightFirstOnOpen
|
||||
maxHeight={400}
|
||||
>
|
||||
{#snippet startSnippet({ item })}
|
||||
<div class="text-xs truncate w-full min-w-0"
|
||||
>{@html extractMarkedLabel(item.value.marked, item.value.label.length)}</div
|
||||
>
|
||||
{/snippet}
|
||||
</SelectDropdown>
|
||||
</div>
|
||||
@@ -21,12 +21,14 @@
|
||||
ulClass = '',
|
||||
itemLabelWrapperClasses = '',
|
||||
itemButtonWrapperClasses = '',
|
||||
maxHeight = 256,
|
||||
header,
|
||||
getInputRect,
|
||||
onSelectValue,
|
||||
startSnippet,
|
||||
endSnippet,
|
||||
bottomSnippet
|
||||
bottomSnippet,
|
||||
highlightFirstOnOpen = false
|
||||
}: {
|
||||
processedItems?: ProcessedItem<T>[]
|
||||
value: T | undefined
|
||||
@@ -40,12 +42,15 @@
|
||||
ulClass?: string
|
||||
itemLabelWrapperClasses?: string
|
||||
itemButtonWrapperClasses?: string
|
||||
maxHeight?: number
|
||||
header?: Snippet
|
||||
getInputRect?: () => DOMRect
|
||||
onSelectValue: (item: ProcessedItem<T>) => void
|
||||
startSnippet?: Snippet<[{ item: ProcessedItem<T>; close: () => void }]>
|
||||
endSnippet?: Snippet<[{ item: ProcessedItem<T>; close: () => void }]>
|
||||
bottomSnippet?: Snippet<[{ close: () => void }]>
|
||||
/** When true, the first item is highlighted when the dropdown opens (even without filterText) */
|
||||
highlightFirstOnOpen?: boolean
|
||||
} = $props()
|
||||
|
||||
let processedItems = $derived(
|
||||
@@ -92,7 +97,7 @@
|
||||
|
||||
$effect(() => {
|
||||
;[open, processedItems]
|
||||
untrack(() => (keyArrowPos = open && filterText ? 0 : undefined))
|
||||
untrack(() => (keyArrowPos = open && (filterText || highlightFirstOnOpen) ? 0 : undefined))
|
||||
})
|
||||
|
||||
// We do not want to render the dropdown when it is closed for performance reasons
|
||||
@@ -185,7 +190,11 @@
|
||||
)}
|
||||
style="height: {uiState.visible ? dropdownPos.height : 0}px;"
|
||||
>
|
||||
<div bind:this={listEl} class="flex flex-col max-h-64 rounded-md bg-surface-input">
|
||||
<div
|
||||
bind:this={listEl}
|
||||
class="flex flex-col rounded-md bg-surface-input"
|
||||
style="max-height: {maxHeight}px;"
|
||||
>
|
||||
{@render header?.()}
|
||||
{#if processedItems?.length === 0}
|
||||
<div class="py-8 px-4 text-center text-primary text-xs">{noItemsMsg}</div>
|
||||
|
||||
@@ -10,11 +10,15 @@
|
||||
setupNavigationGroups,
|
||||
tabToCategoryMap,
|
||||
tabToAuthSubTab,
|
||||
categoryToTabMap
|
||||
categoryToTabMap,
|
||||
buildSearchableSettingItems,
|
||||
type SearchableSettingItem
|
||||
} from '$lib/components/instanceSettings'
|
||||
import SettingsSearchInput from '$lib/components/instanceSettings/SettingsSearchInput.svelte'
|
||||
import Breadcrumb from '$lib/components/common/breadcrumb/Breadcrumb.svelte'
|
||||
import { ChevronRight, ArrowLeft } from 'lucide-svelte'
|
||||
import { superadmin } from '$lib/stores'
|
||||
import { onDestroy, tick } from 'svelte'
|
||||
import { UserService, JobService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
@@ -30,7 +34,10 @@
|
||||
const wizardStepLabels = [...settingsSteps.map((s) => s.label), 'Root login & Resource Types']
|
||||
|
||||
const initialMode = $page.url.searchParams.get('mode') === 'full' ? 'full' : 'wizard'
|
||||
const initialStep = Math.max(0, Math.min(parseInt($page.url.searchParams.get('step') ?? '0') || 0, wizardStepLabels.length - 1))
|
||||
const initialStep = Math.max(
|
||||
0,
|
||||
Math.min(parseInt($page.url.searchParams.get('step') ?? '0') || 0, wizardStepLabels.length - 1)
|
||||
)
|
||||
let mode: 'wizard' | 'full' = $state(initialMode)
|
||||
let wizardStep = $state(initialStep)
|
||||
|
||||
@@ -106,7 +113,12 @@
|
||||
hubSyncStatus = 'success'
|
||||
hubSyncMessage = 'Resource types synced from hub successfully'
|
||||
} catch (e: any) {
|
||||
hubSyncMessage = e?.body?.error?.message || e?.body?.message || (typeof e?.body === 'string' ? e.body : null) || e?.message || 'Failed to sync from hub'
|
||||
hubSyncMessage =
|
||||
e?.body?.error?.message ||
|
||||
e?.body?.message ||
|
||||
(typeof e?.body === 'string' ? e.body : null) ||
|
||||
e?.message ||
|
||||
'Failed to sync from hub'
|
||||
hubSyncStatus = 'error'
|
||||
}
|
||||
}
|
||||
@@ -151,7 +163,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- Settings search (full mode) ---
|
||||
const searchableItems = buildSearchableSettingItems(setupNavigationGroups)
|
||||
|
||||
let scrollTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let highlightTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function handleSearchSelect(item: SearchableSettingItem) {
|
||||
handleNavigate(item.tabId)
|
||||
if (item.settingKey) {
|
||||
clearTimeout(scrollTimeout)
|
||||
clearTimeout(highlightTimeout)
|
||||
await tick()
|
||||
scrollTimeout = setTimeout(() => {
|
||||
const el = document.querySelector(`[data-setting-key="${item.settingKey}"]`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
el.classList.add('setting-highlight')
|
||||
highlightTimeout = setTimeout(() => el.classList.remove('setting-highlight'), 2500)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(scrollTimeout)
|
||||
clearTimeout(highlightTimeout)
|
||||
})
|
||||
|
||||
/** Check if we need to warn about missing EE license key before proceeding */
|
||||
function proceedFromCore(callback: () => void) {
|
||||
@@ -312,9 +350,7 @@
|
||||
{/key}
|
||||
{:else}
|
||||
<!-- Account setup step -->
|
||||
<SettingsPageHeader
|
||||
title="Root login & Resource Types"
|
||||
/>
|
||||
<SettingsPageHeader title="Root login & Resource Types" />
|
||||
|
||||
<div class="flex flex-col gap-6 pb-6">
|
||||
<SettingCard
|
||||
@@ -411,17 +447,14 @@
|
||||
{:else}
|
||||
<!-- Action bar (full mode) -->
|
||||
<div class="flex items-center justify-end gap-2 pb-2 border-b shrink-0">
|
||||
<Toggle
|
||||
bind:checked={yamlMode}
|
||||
options={{ right: 'YAML' }}
|
||||
size="sm"
|
||||
/>
|
||||
<Toggle bind:checked={yamlMode} options={{ right: 'YAML' }} size="sm" />
|
||||
</div>
|
||||
|
||||
<!-- Sidebar + Content -->
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<div class="flex flex-1 min-h-0 pt-2">
|
||||
{#if !yamlMode}
|
||||
<div class="w-44 shrink-0 overflow-auto pb-4 pr-4">
|
||||
<SettingsSearchInput {searchableItems} onSelect={handleSearchSelect} class="mb-3" />
|
||||
<SidebarNavigation
|
||||
groups={setupNavigationGroups}
|
||||
selectedId={fullTab}
|
||||
@@ -503,11 +536,16 @@
|
||||
>
|
||||
Quick setup
|
||||
</Button>
|
||||
<Button variant="accent" unifiedSize="md" onClick={() => saveAndProceed(() => {
|
||||
yamlMode = false
|
||||
wizardStep = wizardStepLabels.length - 1
|
||||
mode = 'wizard'
|
||||
})}>Continue</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
onClick={() =>
|
||||
saveAndProceed(() => {
|
||||
yamlMode = false
|
||||
wizardStep = wizardStepLabels.length - 1
|
||||
mode = 'wizard'
|
||||
})}>Continue</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user