Files
windmill/frontend/src/lib/components/select/MultiSelect.svelte
T
Diego ImbertandClaude Opus 5 504d01a563 feat: rework the git sync setup into a guided flow (#11308)
* feat: rework git sync settings into a flat repo list and setup modal

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: add the connect step for gitlab and github in git sync setup

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: open the github app install page from the git sync setup next step

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: let git sync setup set the repository folder

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: add the configure sync step to git sync setup

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: even out the git sync filter settings columns

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: pin the filter input to the bottom of the filter list

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: frame the git sync path filters in a box with an empty hint

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: use a multiselect for the git sync path filters

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: grey out the skipped connect step in git sync setup

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: move the check job line to the bottom of the setup modal

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: hide the check job line once the repository check resolved

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: center the repository check loader in the setup modal

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop the setup modal shifting when the check job id arrives

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: move the existing resource hint to the setup modal footer

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: always show the existing resource option when one exists

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: drop the card and collapse from the git sync filter settings

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: use a success alert for the pull from git notice

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: make initializing the repository a step of the git sync setup

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: hide the push job lines once they succeed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: reword the push step intro

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: copy button on the cli snippet and a taller changes list

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: match the push step loading to the check step

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop the push step shifting when a job id arrives

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: let the changes list fill the push step

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: end the git sync setup on a done step instead of a modal

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: delete a git sync repository from a row menu with a confirmation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: lift the git sync done message off the notices

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: restore github app installation transfer between instances

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address regressions from the git sync setup rework

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the overlay z-index floor in step with the ai chat

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: hold the push state in the setup dialog, not in the step it destroys

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: block every dismissal while setup work is in flight and keep sync reachable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: regenerate the resource path when the repository changes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: configure the resource the connect step actually created

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep a modal open when a drawer above it takes the click

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: snapshot the topmost surface for keyboard clicks too

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: size the add repository buttons and explain git promotion

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* revert: drop the promotion-first action from the empty state

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: shrink the test connection result line

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: offer the fork sync and fork pull request toggles during setup

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: group promotion repositories under their own label

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: offer the deploy branch pull request toggle during setup

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: wait for the repository credential before saving the setup

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: ignore a superseded credential load and drop the bindable default

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: key setup readiness to the repository it was loaded for

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: tie setup readiness to the attempt that asked for it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: link to where the access token is created

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: say plainly how each provider connects

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: sync resources, variables, schedules and triggers by default

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: use the accent colour for the token help link

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: move the CI/CD deploy hint next to the pull toggle

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep setup gated when the connection could not be read

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: only explain pull-from-git where the workspace can turn it on

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: offer a save retry once the repository is initialized

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: say when pulling fell back to polling on the last setup step

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: clear the initialized flag when a new draft starts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 11:54:25 +02:00

214 lines
5.7 KiB
Svelte

<script lang="ts" generics="Item extends { label?: string; value: any; }">
import { clickOutside, reorder } from '$lib/utils'
import { untrack } from 'svelte'
import { processItems, type ProcessedItem } from './utils.svelte'
import SelectDropdown from './SelectDropdown.svelte'
import CloseButton from '../common/CloseButton.svelte'
import DraggableTags from './DraggableTags.svelte'
import { Search } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import TextInput, {
inputBaseClass,
inputBorderClass,
inputSizeClasses
} from '../text_input/TextInput.svelte'
type Value = Item['value']
let {
items,
placeholder = 'Select items',
searchPlaceholder = 'Search...',
value = $bindable(),
class: className = '',
style,
listAutoWidth = true,
disabled = false,
disablePortal = false,
createText,
reorderable = true,
noItemsMsg,
selectedUlClass = '',
placeholderClass = '',
allowClear = true,
hideMainClearBtn = false,
size = 'md',
id,
error = false,
onOpen,
groupBy,
sortBy,
onCreateItem
}: {
items?: Item[]
value: Value[]
placeholder?: string
/** Placeholder of the dropdown's search field. Worth setting where the list is there to
* create entries rather than pick from, so the field says what to type. */
searchPlaceholder?: string
class?: string
style?: string
filterText?: string
disabled?: boolean
listAutoWidth?: boolean
containerStyle?: string
inputClass?: string
disablePortal?: boolean
createText?: string
reorderable?: boolean
noItemsMsg?: string
selectedUlClass?: string
placeholderClass?: string
allowClear?: boolean
hideMainClearBtn?: boolean
size?: keyof typeof inputSizeClasses
id?: string
error?: boolean
groupBy?: (item: Item) => string
sortBy?: (a: Item, b: Item) => number
onOpen?: () => void
onClear?: () => void
onCreateItem?: (value: string) => void
} = $props()
let filterText = $state<string>('')
let open = $state<boolean>(false)
let wrapperEl: HTMLDivElement | undefined = $state()
let searchInputEl: TextInput | undefined = $state()
let currentValue = $derived(value ?? [])
$effect(() => searchInputEl?.focus())
let processedItems: ProcessedItem<Value>[] = $derived.by(() => {
let args = { items, createText, filterText, groupBy, onCreateItem, sortBy }
return untrack(() => processItems(args))
})
$effect(() => {
if (!open) filterText = ''
})
$effect(() => {
open && untrack(() => onOpen?.())
})
let valueEntry = $derived(
currentValue.map(
(v) => processedItems.find((item) => item.value === v) ?? { value: v, label: v }
)
)
function onAddValue(item: ProcessedItem<Value>) {
if (item.__is_create && onCreateItem) {
const before = currentValue.length
onCreateItem(item.value)
// Cleared only once the entry exists: it is then in the list, and leaving it in the
// field would filter the list down to it. A consumer that refused the value (a
// duplicate, a name it rejects) keeps what was typed, to be corrected.
if ((value?.length ?? 0) !== before) filterText = ''
} else {
value = [...currentValue, item.value]
}
}
function onRemoveValue(item: ProcessedItem<Value>) {
value = currentValue.filter((v) => v !== item.value)
}
function clearValue() {
filterText = ''
value = []
}
export function getFilteredInputText() {
return filterText
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={wrapperEl}
class={twMerge(
'flex items-center flex-wrap relative',
inputBaseClass,
inputSizeClasses[size],
inputBorderClass({ forceFocus: open && !disabled, error }),
disabled ? 'pointer-events-none' : '',
open && !disabled ? 'open' : '',
disabled ? 'disabled' : '',
className
)}
{style}
onpointerup={() => (open = true)}
use:clickOutside={{ onClickOutside: () => (open = false) }}
{id}
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
{#if currentValue.length === 0}
<span class={twMerge('text-xs h-full flex items-center flex-1 text-hint', placeholderClass)}>
{placeholder}
</span>
{:else}
<ul
class={twMerge(
'overflow-clip overflow-x-hidden h-full cursor-pointer items-center flex flex-wrap gap-1 py-0.5 flex-1 text-primary',
selectedUlClass
)}
role="list"
>
<DraggableTags
items={valueEntry}
{allowClear}
onRemove={onRemoveValue}
onReorder={reorderable
? (oldIdx, newIdx) => (value = reorder(currentValue, oldIdx, newIdx))
: undefined}
/>
</ul>
{/if}
{#if allowClear && !hideMainClearBtn && !!value?.length}
<CloseButton
noBg
class="ml-2 remove-all bg-transparent text-hint"
small
on:close={(e) => (clearValue(), e.stopPropagation())}
/>
{/if}
<SelectDropdown
{disablePortal}
onSelectValue={onAddValue}
{open}
processedItems={processedItems.filter((item) => !currentValue.includes(item.value))}
value={undefined}
{disabled}
{filterText}
getInputRect={wrapperEl && (() => wrapperEl!.getBoundingClientRect())}
{listAutoWidth}
{noItemsMsg}
class={twMerge(
'multiselect dropdown',
open && !disabled ? 'open' : '',
disabled ? 'disabled' : ''
)}
ulClass="options"
>
{#snippet header()}
{#if processedItems.length - currentValue.length > 0 || onCreateItem}
<div class="mx-2 mb-1 mt-2 flex items-center relative">
<TextInput
bind:this={searchInputEl}
bind:value={filterText}
inputProps={{
onblur: (e) => (e.preventDefault(), searchInputEl?.focus()),
placeholder: searchPlaceholder
}}
class="!pr-7 !bg-surface"
/>
<Search size={16} class="absolute right-2 text-primary" />
</div>
{/if}
{/snippet}
</SelectDropdown>
</div>