diff --git a/src/lib/holocene/combobox/combobox-option.svelte b/src/lib/holocene/combobox/combobox-option.svelte index ba54b8daea..b52fd8a5c2 100644 --- a/src/lib/holocene/combobox/combobox-option.svelte +++ b/src/lib/holocene/combobox/combobox-option.svelte @@ -13,11 +13,13 @@ } interface EnabledProps extends BaseProps { + active?: boolean; selected?: boolean; disabled?: boolean; } interface DisabledProps extends BaseProps { + active?: never; disabled: true; selected?: never; } @@ -25,6 +27,7 @@ type Props = EnabledProps | DisabledProps; let { + active = false, selected = false, disabled = false, label, @@ -41,6 +44,7 @@ aria-selected={selected} aria-disabled={disabled} {onclick} + {active} {selected} {disabled} {leading} diff --git a/src/lib/holocene/combobox/combobox.stories.svelte b/src/lib/holocene/combobox/combobox.stories.svelte index 94c11dfeb7..fe827c1b4b 100644 --- a/src/lib/holocene/combobox/combobox.stories.svelte +++ b/src/lib/holocene/combobox/combobox.stories.svelte @@ -188,6 +188,34 @@ + { + const canvas = within(canvasElement); + const combobox = canvas.getByTestId(id); + await userEvent.type(combobox, 'Spanish'); + }} +/> + + { + const canvas = within(canvasElement); + const combobox = canvas.getByTestId(id); + await userEvent.type(combobox, 'Spanish'); + }} +/> + ; maxMenuHeight?: string; variant?: ComboboxStyles['variant']; @@ -174,6 +176,7 @@ hrefDisabled = false, loading = false, loadingText = 'Loading more results', + allowCustomValue = false, variant = 'default', optionClass = '', onchange, @@ -189,9 +192,23 @@ let filterValue: string = $state(''); let menuElement: HTMLUListElement | null = $state(null); let inputElement: HTMLInputElement | null = $state(null); + let inputFocused: boolean = $state(false); + let customOptions: string[] = $state([]); - const selectedOption = $derived(getSelectedOption(options)); - let list = $derived(filterOptions(filterValue, options)); + const allOptions = $derived( + allowCustomValue ? [...customOptions, ...options] : options, + ); + const selectedOption = $derived(getSelectedOption(allOptions)); + let list = $derived(filterOptions(filterValue, allOptions)); + const trimmedFilterValue = $derived(filterValue.trim()); + const showAddCustom = $derived( + allowCustomValue && + trimmedFilterValue && + !list.some( + (o) => + getDisplayValue(o).toLowerCase() === trimmedFilterValue.toLowerCase(), + ), + ); let displayValue = $derived( !multiselect ? getDisplayValue(selectedOption) : undefined, ); @@ -240,13 +257,24 @@ const resetValueAndOptions = () => { displayValue = getDisplayValue(selectedOption); - list = options; + list = allOptions; }; const isArrayValue = (value: string | string[]): value is string[] => { return Array.isArray(value); }; + const addCustomValue = () => { + if (!trimmedFilterValue) return; + if (isArrayValue(value) && value.includes(trimmedFilterValue)) return; + if (!customOptions.includes(trimmedFilterValue)) { + customOptions = [trimmedFilterValue, ...customOptions]; + } + handleSelectOption(trimmedFilterValue); + filterValue = ''; + displayValue = ''; + }; + const isStringOption = (option: string | T): option is string => { return typeof option === 'string'; }; @@ -382,8 +410,12 @@ focusFirstOption(); break; case 'Enter': - openList(); - focusFirstOption(); + if (showAddCustom) { + addCustomValue(); + } else { + openList(); + focusFirstOption(); + } break; case 'ArrowUp': case 'ArrowRight': @@ -396,9 +428,14 @@ const handleFocus: FocusEventHandler = (event) => { event.stopPropagation(); + inputFocused = true; openList(); }; + const handleBlur: FocusEventHandler = () => { + inputFocused = false; + }; + const handleInput: FormEventHandler = (event) => { event.stopPropagation(); if (!$open) $open = true; @@ -477,7 +514,7 @@ > {#if multiselect && isArrayValue(value) && value.length > 0} {#if displayChips} - {#each value.slice(0, chipLimit) as v} + {#each value.slice(0, chipLimit) as v, i (i)} removeOption(v)} removeButtonLabel={removeChipLabel}>{v} {#if multiselect && isArrayValue(value)} @@ -602,7 +640,22 @@ {/if} - {#each list as option} + {#if showAddCustom} + + {#snippet leading()} + + {/snippet} + + {#if list.length > 0} + + {/if} + {/if} + + {#each list as option, i (i)} handleSelectOption(option)} selected={isSelected(option, value)} @@ -610,7 +663,7 @@ class={optionClass} /> {:else} - {#if loading === false} + {#if !showAddCustom && loading === false} {/if} {/each} diff --git a/src/lib/holocene/menu/menu-item.svelte b/src/lib/holocene/menu/menu-item.svelte index eb3e0dcf7f..2cc155651f 100644 --- a/src/lib/holocene/menu/menu-item.svelte +++ b/src/lib/holocene/menu/menu-item.svelte @@ -18,6 +18,7 @@ import { MENU_CONTEXT, type MenuContext } from './menu-container.svelte'; export interface BaseProps { + active?: boolean; selected?: boolean; destructive?: boolean; disabled?: boolean; @@ -47,6 +48,7 @@ const { class: className, + active = false, selected, destructive = false, disabled = false, @@ -137,6 +139,7 @@ centered ? 'justify-center' : 'justify-between', className, )} + class:active class:disabled class:hoverable aria-hidden={disabled ? 'true' : 'false'} @@ -168,6 +171,7 @@ class:disabled class:selected class:hoverable + class:active aria-hidden={disabled ? 'true' : 'false'} aria-disabled={disabled} tabindex={disabled ? -1 : 0} @@ -200,6 +204,10 @@ .menu-item { @apply cursor-pointer border border-transparent text-sm focus-visible:border-inverse focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 dark:focus-visible:border-interactive; + &.active { + @apply bg-interactive-secondary-hover; + } + &.hoverable { @apply hover:surface-interactive-secondary focus-visible:surface-interactive-secondary; } diff --git a/src/lib/i18n/locales/en/nexus.ts b/src/lib/i18n/locales/en/nexus.ts index 0a3c781d0c..39769bfb02 100644 --- a/src/lib/i18n/locales/en/nexus.ts +++ b/src/lib/i18n/locales/en/nexus.ts @@ -24,6 +24,7 @@ export const Strings = { 'Add a link to your repo or instructions to help other users in this account use this endpoint.', 'description-placeholder': '//Provide a readme for users to use this endpoint', + 'no-description': 'No description provided.', handler: 'Handler', 'delete-endpoint': 'Delete Endpoint', 'delete-modal-title': 'Delete Nexus Endpoint?', @@ -37,9 +38,11 @@ export const Strings = { 'endpoint-name-hint-with-dash': 'Endpoint name must start with A-Z or a-z and can only contain A-Z, a-z, 0-9 or -', 'access-policy': 'Access Policy', - 'allowed-caller-namespaces': 'Allowed caller Namespaces', + 'allowed-caller-namespaces': 'Allowed Caller Namespaces', + 'no-allowed-caller-namespaces': 'No Allowed Caller Namespaces', 'allowed-caller-namespaces-description': 'Namespace(s) that are allowed to call this Endpoint.', + 'allowed-caller-namespaces-error': 'Please select at least one Namespace.', 'select-namespaces': 'Select Namespace(s)', 'selected-namespaces_one': '{{count}} Namespace selected', 'selected-namespaces_other': '{{count}} Namespaces selected', diff --git a/src/lib/pages/nexus-endpoint.svelte b/src/lib/pages/nexus-endpoint.svelte index 4418918fad..3c70f0d4d8 100644 --- a/src/lib/pages/nexus-endpoint.svelte +++ b/src/lib/pages/nexus-endpoint.svelte @@ -1,10 +1,17 @@ -
-
-
-

- {endpoint.spec?.name || ''} -

- -
-

UUID: {endpoint.id}

-
-
-

Target

-
-
- Namespace +{#snippet target()} + +
{translate('nexus.target')}
+
+
{translate('namespaces.namespace')}
+
{endpoint.spec?.target?.worker?.namespace || ''} -
-
- Task Queue + +
+ +
+
{translate('common.task-queue')}
+
{endpoint.spec?.target?.worker?.taskQueue || ''} -
- {@render taskQueueStatus?.()} +
-
-
-

Description

+ + {@render taskQueueStatus?.()} + +{/snippet} + +{#snippet description()} + +
{translate('common.description')}
-
- {#if endpoint.spec?.allowedCallerNamespaces} -

Allowed Caller Namespaces

+ +{/snippet} + +{#snippet allowedCallerNamespacesTable()} + +
+
{translate('nexus.allowed-caller-namespaces')}
+ {allowedCallerNamespaces.length} +
- {#each endpoint.spec?.allowedCallerNamespaces as namespace (namespace)} - {namespace} - {/each} + translate('common.go-to-page', { page })} + variant="primary" + items={allowedCallerNamespaces} + let:visibleItems + maxHeight="24rem" + > + {translate('nexus.allowed-caller-namespaces')} + + {translate('common.name')} + + {#each visibleItems as namespace (namespace)} + + + {namespace} + + + {:else} + + {/each} +
- {/if} +
+{/snippet} + +{#snippet editButton(className: ClassNameValue = undefined)} + +{/snippet} + +
+
+
+

+ {endpoint.spec?.name || ''} +

+ {@render editButton('max-sm:hidden')} +
+

UUID: {endpoint.id}

+ {@render editButton('sm:hidden mt-6 w-full')} +
+
+
+ {@render target()} + {#if allowedCallerNamespaces} + {@render allowedCallerNamespacesTable()} + {/if} +
+ {@render description()} +
diff --git a/src/lib/pages/nexus-form.svelte b/src/lib/pages/nexus-form.svelte index 3427af498b..8dcedbd079 100644 --- a/src/lib/pages/nexus-form.svelte +++ b/src/lib/pages/nexus-form.svelte @@ -54,7 +54,7 @@ $endpointForm = { spec: { name, - descriptionString, + descriptionString: descriptionString.trim() || undefined, target: { worker: { namespace: target, @@ -137,13 +137,14 @@ id="caller-namespace-filter-menu" multiselect displayChips={false} + allowCustomValue bind:value={allowedCallerNamespaces} options={callerNamespaces} label={translate('nexus.allowed-caller-namespaces')} leadingIcon="search" noResultsText={translate('common.no-results')} valid={!!allowedCallerNamespaces.length} - error="Please select at least one Namespace." + error={translate('nexus.allowed-caller-namespaces-error')} placeholder={translate('nexus.select-namespaces')} optionValueKey="value" optionLabelKey="label"