feat: strengthen module contracts and shared WebUI runtime
This commit is contained in:
@@ -20,10 +20,26 @@ import DismissibleAlert from "./DismissibleAlert";
|
||||
import FormField from "./FormField";
|
||||
import LoadingFrame from "./LoadingFrame";
|
||||
import PasswordField from "./PasswordField";
|
||||
import {
|
||||
ReferenceMultiSelect,
|
||||
combineReferenceOptionProviders,
|
||||
customReferenceOption,
|
||||
staticReferenceOptionProvider,
|
||||
type ReferenceOption
|
||||
} from "./ReferenceSelect";
|
||||
import { platformModuleReferenceProvider } from "../platform/referenceProviders";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
import TableActionGroup from "./table/TableActionGroup";
|
||||
import ToggleSwitch from "./ToggleSwitch";
|
||||
import { useUnsavedDraftGuard } from "./UnsavedChangesGuard";
|
||||
import {
|
||||
usePlatformModules,
|
||||
usePlatformUiCapabilities
|
||||
} from "../platform/ModuleContext";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import type {
|
||||
CredentialReferenceSelectorsUiCapability
|
||||
} from "./ReferenceSelect";
|
||||
|
||||
export type CredentialEnvelopeTargetOption = {
|
||||
id: string;
|
||||
@@ -31,11 +47,19 @@ export type CredentialEnvelopeTargetOption = {
|
||||
secondary?: string | null;
|
||||
};
|
||||
|
||||
export type CredentialEnvelopeServerOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
secondary?: string | null;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type CredentialEnvelopeManagerProps = {
|
||||
settings: ApiSettings;
|
||||
scopeType: Extract<MailProfileScope, "system" | "tenant" | "user" | "group">;
|
||||
scopeId?: string | null;
|
||||
targetOptions?: CredentialEnvelopeTargetOption[];
|
||||
serverOptions?: CredentialEnvelopeServerOption[];
|
||||
targetLabel?: string;
|
||||
title?: string;
|
||||
canWrite: boolean;
|
||||
@@ -76,10 +100,13 @@ export default function CredentialEnvelopeManager({
|
||||
scopeType,
|
||||
scopeId,
|
||||
targetOptions = [],
|
||||
serverOptions = [],
|
||||
targetLabel = "Target",
|
||||
title = "Credential envelopes",
|
||||
canWrite
|
||||
}: CredentialEnvelopeManagerProps) {
|
||||
const modules = usePlatformModules();
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const [selectedTargetId, setSelectedTargetId] = useState(scopeId ?? targetOptions[0]?.id ?? "");
|
||||
const [credentials, setCredentials] = useState<CredentialEnvelopeSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -95,6 +122,56 @@ export default function CredentialEnvelopeManager({
|
||||
: selectedTargetId || null;
|
||||
const scopeReady = scopeType === "system" || scopeType === "tenant" || Boolean(activeScopeId);
|
||||
const draftDirty = Boolean(editing) && credentialDraftKey(draft) !== savedDraftKey;
|
||||
const referenceCapabilities =
|
||||
usePlatformUiCapabilities<CredentialReferenceSelectorsUiCapability>(
|
||||
"core.credentialReferenceSelectors"
|
||||
);
|
||||
const moduleReferenceOptions = useMemo<ReferenceOption[]>(
|
||||
() => modules.map((module) => ({
|
||||
value: module.id,
|
||||
label: translateText(module.label),
|
||||
description: `${module.id} · version ${module.version}`,
|
||||
kind: "module",
|
||||
sourceModule: "core",
|
||||
provenance: { version: module.version }
|
||||
})),
|
||||
[modules, translateText]
|
||||
);
|
||||
const serverReferenceOptions = useMemo<ReferenceOption[]>(
|
||||
() => serverOptions.map((server) => ({
|
||||
value: server.id,
|
||||
label: server.label,
|
||||
description: server.secondary ?? server.id,
|
||||
kind: "server",
|
||||
disabled: server.disabled,
|
||||
availability: server.disabled ? "inactive" : "available",
|
||||
provenance: { announcedByConsumer: true }
|
||||
})),
|
||||
[serverOptions]
|
||||
);
|
||||
const moduleReferenceProvider = useMemo(
|
||||
() => platformModuleReferenceProvider(settings, moduleReferenceOptions),
|
||||
[moduleReferenceOptions, settings]
|
||||
);
|
||||
const serverReferenceProvider = useMemo(
|
||||
() =>
|
||||
combineReferenceOptionProviders([
|
||||
staticReferenceOptionProvider(serverReferenceOptions),
|
||||
...referenceCapabilities.map((capability) =>
|
||||
capability.serverProvider(settings, {
|
||||
scopeType,
|
||||
scopeId: activeScopeId
|
||||
})
|
||||
)
|
||||
]),
|
||||
[
|
||||
activeScopeId,
|
||||
referenceCapabilities,
|
||||
scopeType,
|
||||
serverReferenceOptions,
|
||||
settings
|
||||
]
|
||||
);
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: draftDirty,
|
||||
@@ -436,11 +513,26 @@ export default function CredentialEnvelopeManager({
|
||||
<p>Empty module or server lists mean every module or server allowed by scope.</p>
|
||||
</header>
|
||||
<div className="form-grid two">
|
||||
<FormField label="Modules" help="Comma-separated module IDs, for example mail, files, calendar, addresses.">
|
||||
<input value={draft.allowedModules} disabled={saving} onChange={(event) => setDraft({ ...draft, allowedModules: event.target.value })} />
|
||||
<FormField label="Modules" help="Choose installed modules that may use this credential. Empty means every module allowed by scope.">
|
||||
<ReferenceMultiSelect
|
||||
values={splitValues(draft.allowedModules)}
|
||||
onChange={(values) => setDraft({ ...draft, allowedModules: values.join(", ") })}
|
||||
provider={moduleReferenceProvider}
|
||||
aria-label="Credential modules"
|
||||
placeholder="Add a module"
|
||||
disabled={saving}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Servers" help="Optional references such as mail:server-id, files:connection-id, calendar:source-id, or addresses:source-id.">
|
||||
<input value={draft.allowedServerRefs} disabled={saving} onChange={(event) => setDraft({ ...draft, allowedServerRefs: event.target.value })} />
|
||||
<FormField label="Servers" help="Choose announced servers or enter an explicit module-qualified reference. Empty means every permitted server.">
|
||||
<ReferenceMultiSelect
|
||||
values={splitValues(draft.allowedServerRefs)}
|
||||
onChange={(values) => setDraft({ ...draft, allowedServerRefs: values.join(", ") })}
|
||||
provider={serverReferenceProvider}
|
||||
createCustomOption={(value) => customReferenceOption(value, "server")}
|
||||
aria-label="Credential servers"
|
||||
placeholder="Add a server reference"
|
||||
disabled={saving}
|
||||
/>
|
||||
</FormField>
|
||||
<ToggleSwitch checked={draft.inheritToLowerScopes} disabled={saving} onChange={(inheritToLowerScopes) => setDraft({ ...draft, inheritToLowerScopes })} label="Visible to lower scopes" />
|
||||
<ToggleSwitch checked={draft.isActive} disabled={saving} onChange={(isActive) => setDraft({ ...draft, isActive })} label="Active" />
|
||||
|
||||
119
webui/src/components/DashboardWidgetContent.tsx
Normal file
119
webui/src/components/DashboardWidgetContent.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { adminErrorMessage } from "./admin/adminUtils";
|
||||
|
||||
export type DashboardWidgetDataState<T> = {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export function useDashboardWidgetData<T>(
|
||||
load: () => Promise<T>,
|
||||
refreshKey: number
|
||||
): DashboardWidgetDataState<T> {
|
||||
const [state, setState] = useState<DashboardWidgetDataState<T>>({
|
||||
data: null,
|
||||
loading: true,
|
||||
error: ""
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
loading: true,
|
||||
error: ""
|
||||
}));
|
||||
void load()
|
||||
.then((data) => {
|
||||
if (active) setState({ data, loading: false, error: "" });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!active) return;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
error: adminErrorMessage(error)
|
||||
}));
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [load, refreshKey]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export type DashboardWidgetListItem = {
|
||||
id: string;
|
||||
title: ReactNode;
|
||||
detail?: ReactNode;
|
||||
meta?: ReactNode;
|
||||
leading?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export function DashboardWidgetList({
|
||||
items,
|
||||
emptyText
|
||||
}: {
|
||||
items: DashboardWidgetListItem[];
|
||||
emptyText: ReactNode;
|
||||
}) {
|
||||
if (!items.length) {
|
||||
return <p className="muted dashboard-contribution-empty">{emptyText}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="dashboard-contribution-list">
|
||||
{items.map((item) => {
|
||||
const content = (
|
||||
<>
|
||||
{item.leading && (
|
||||
<span className="dashboard-contribution-leading">
|
||||
{item.leading}
|
||||
</span>
|
||||
)}
|
||||
<span className="dashboard-contribution-copy">
|
||||
<span className="dashboard-contribution-title">
|
||||
{item.title}
|
||||
</span>
|
||||
{item.detail && (
|
||||
<span className="dashboard-contribution-detail">
|
||||
{item.detail}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{item.meta && (
|
||||
<span className="dashboard-contribution-meta">
|
||||
{item.meta}
|
||||
</span>
|
||||
)}
|
||||
{item.trailing && (
|
||||
<span className="dashboard-contribution-trailing">
|
||||
{item.trailing}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li key={item.id}>
|
||||
{item.to ? (
|
||||
<Link className="dashboard-contribution-row" to={item.to}>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="dashboard-contribution-row">{content}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
472
webui/src/components/ReferenceSelect.tsx
Normal file
472
webui/src/components/ReferenceSelect.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import SearchableSelect, {
|
||||
filterSearchableSelectOptions,
|
||||
type SearchableSelectCreateCustomOption,
|
||||
type SearchableSelectOption,
|
||||
type SearchableSelectProps
|
||||
} from "./SearchableSelect";
|
||||
import IconButton from "./IconButton";
|
||||
import type { ApiSettings } from "../types";
|
||||
|
||||
export type ReferenceAvailability =
|
||||
| "available"
|
||||
| "inactive"
|
||||
| "unavailable";
|
||||
|
||||
export type ReferenceOption = SearchableSelectOption & {
|
||||
kind?: string | null;
|
||||
availability?: ReferenceAvailability;
|
||||
sourceModule?: string | null;
|
||||
provenance?: Record<string, unknown>;
|
||||
custom?: boolean;
|
||||
};
|
||||
|
||||
export type ReferenceProviderContext = {
|
||||
limit: number;
|
||||
selectedValues: readonly string[];
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
export type ReferenceOptionProvider = {
|
||||
search: (
|
||||
query: string,
|
||||
context: ReferenceProviderContext
|
||||
) => Promise<readonly ReferenceOption[]>;
|
||||
resolve?: (
|
||||
values: readonly string[],
|
||||
context: Omit<ReferenceProviderContext, "limit"> & { limit?: number }
|
||||
) => Promise<readonly ReferenceOption[]>;
|
||||
};
|
||||
|
||||
export type ConfigurationReferenceSelectorsUiCapability = {
|
||||
tenantProvider: (settings: ApiSettings) => ReferenceOptionProvider;
|
||||
changeRequestProvider: (
|
||||
settings: ApiSettings,
|
||||
options: {
|
||||
purpose: string;
|
||||
tenantId?: string | null;
|
||||
}
|
||||
) => ReferenceOptionProvider;
|
||||
};
|
||||
|
||||
export type CredentialReferenceSelectorContext = {
|
||||
scopeType: "system" | "tenant" | "user" | "group";
|
||||
scopeId?: string | null;
|
||||
};
|
||||
|
||||
export type CredentialReferenceSelectorsUiCapability = {
|
||||
serverProvider: (
|
||||
settings: ApiSettings,
|
||||
context: CredentialReferenceSelectorContext
|
||||
) => ReferenceOptionProvider;
|
||||
};
|
||||
|
||||
export type ReferenceSelectProps = Omit<
|
||||
SearchableSelectProps,
|
||||
"options" | "loadOptions" | "selectedOption"
|
||||
> & {
|
||||
provider: ReferenceOptionProvider;
|
||||
selectedOption?: ReferenceOption | null;
|
||||
};
|
||||
|
||||
export type ReferenceMultiSelectProps = {
|
||||
id?: string;
|
||||
values: readonly string[];
|
||||
onChange: (values: string[]) => void;
|
||||
provider: ReferenceOptionProvider;
|
||||
selectedOptions?: readonly ReferenceOption[];
|
||||
createCustomOption?: SearchableSelectCreateCustomOption;
|
||||
"aria-label"?: string;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
disabled?: boolean;
|
||||
minQueryLength?: number;
|
||||
searchLimit?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const EMPTY_REFERENCE_OPTIONS: readonly ReferenceOption[] = [];
|
||||
|
||||
export default function ReferenceSelect({
|
||||
provider,
|
||||
value,
|
||||
selectedOption,
|
||||
searchLimit = 50,
|
||||
...props
|
||||
}: ReferenceSelectProps) {
|
||||
const [resolved, setResolved] = useState<ReferenceOption | null>(
|
||||
selectedOption?.value === value ? selectedOption : null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setResolved(null);
|
||||
return;
|
||||
}
|
||||
setResolved(
|
||||
selectedOption?.value === value
|
||||
? selectedOption
|
||||
: unavailableReferenceOption(value)
|
||||
);
|
||||
}, [selectedOption, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value || selectedOption?.value === value || !provider.resolve) return;
|
||||
const controller = new AbortController();
|
||||
void provider
|
||||
.resolve([value], {
|
||||
selectedValues: [value],
|
||||
signal: controller.signal,
|
||||
limit: 1
|
||||
})
|
||||
.then((options) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setResolved(
|
||||
options.find((option) => option.value === value)
|
||||
?? unavailableReferenceOption(value)
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (
|
||||
!controller.signal.aborted
|
||||
&& !(error instanceof DOMException && error.name === "AbortError")
|
||||
) {
|
||||
setResolved(unavailableReferenceOption(value));
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [provider, selectedOption?.value, value]);
|
||||
|
||||
return (
|
||||
<SearchableSelect
|
||||
{...props}
|
||||
value={value}
|
||||
searchLimit={searchLimit}
|
||||
selectedOption={resolved}
|
||||
loadOptions={(query, context) =>
|
||||
provider.search(query, {
|
||||
...context,
|
||||
selectedValues: value ? [value] : []
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReferenceMultiSelect({
|
||||
id,
|
||||
values,
|
||||
onChange,
|
||||
provider,
|
||||
selectedOptions = EMPTY_REFERENCE_OPTIONS,
|
||||
createCustomOption,
|
||||
"aria-label": ariaLabel = "Select references",
|
||||
placeholder = "Add a reference",
|
||||
searchPlaceholder,
|
||||
emptyText,
|
||||
disabled = false,
|
||||
minQueryLength,
|
||||
searchLimit = 50,
|
||||
className = ""
|
||||
}: ReferenceMultiSelectProps) {
|
||||
const normalizedValues = useMemo(
|
||||
() => [...new Set(values.map((value) => value.trim()).filter(Boolean))],
|
||||
[values]
|
||||
);
|
||||
const normalizedValuesKey = normalizedValues.join("\u0000");
|
||||
const [resolvedOptions, setResolvedOptions] = useState<
|
||||
readonly ReferenceOption[]
|
||||
>(selectedOptions);
|
||||
|
||||
useEffect(() => {
|
||||
setResolvedOptions(selectedOptions);
|
||||
}, [selectedOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedValues.length || !provider.resolve) return;
|
||||
const controller = new AbortController();
|
||||
void provider
|
||||
.resolve(normalizedValues, {
|
||||
selectedValues: normalizedValues,
|
||||
signal: controller.signal,
|
||||
limit: normalizedValues.length
|
||||
})
|
||||
.then((options) => {
|
||||
if (!controller.signal.aborted) setResolvedOptions(options);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (
|
||||
!controller.signal.aborted
|
||||
&& !(error instanceof DOMException && error.name === "AbortError")
|
||||
) {
|
||||
setResolvedOptions(EMPTY_REFERENCE_OPTIONS);
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [normalizedValuesKey, provider]);
|
||||
|
||||
const optionsByValue = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
[...selectedOptions, ...resolvedOptions].map((option) => [
|
||||
option.value,
|
||||
option
|
||||
])
|
||||
),
|
||||
[resolvedOptions, selectedOptions]
|
||||
);
|
||||
const selected = normalizedValues.map(
|
||||
(value) =>
|
||||
optionsByValue.get(value)
|
||||
?? (createCustomOption?.(value) as ReferenceOption | null | undefined)
|
||||
?? unavailableReferenceOption(value)
|
||||
);
|
||||
const rootClassName = ["reference-multi-select", className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
function add(value: string, option: SearchableSelectOption | null) {
|
||||
if (!value || normalizedValues.includes(value)) return;
|
||||
if (option) {
|
||||
setResolvedOptions((current) => [
|
||||
...current.filter((item) => item.value !== value),
|
||||
option as ReferenceOption
|
||||
]);
|
||||
}
|
||||
onChange([...normalizedValues, value]);
|
||||
}
|
||||
|
||||
function remove(value: string) {
|
||||
onChange(normalizedValues.filter((item) => item !== value));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={rootClassName}>
|
||||
{selected.length ? (
|
||||
<div
|
||||
className="reference-multi-select-values"
|
||||
role="list"
|
||||
aria-label={`${ariaLabel} selected`}
|
||||
>
|
||||
{selected.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={[
|
||||
"reference-multi-select-value",
|
||||
option.availability
|
||||
? `is-${option.availability}`
|
||||
: "",
|
||||
option.custom ? "is-custom" : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
role="listitem"
|
||||
>
|
||||
<span className="reference-multi-select-value-copy">
|
||||
<strong>{option.label}</strong>
|
||||
<small>{referenceOptionDetail(option)}</small>
|
||||
</span>
|
||||
<IconButton
|
||||
label={`Remove ${option.label}`}
|
||||
icon={<X size={15} aria-hidden="true" />}
|
||||
onClick={() => remove(option.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<SearchableSelect
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
value=""
|
||||
onChange={add}
|
||||
loadOptions={async (query, context) => {
|
||||
const options = await provider.search(query, {
|
||||
...context,
|
||||
selectedValues: normalizedValues
|
||||
});
|
||||
return options.filter(
|
||||
(option) => !normalizedValues.includes(option.value)
|
||||
);
|
||||
}}
|
||||
createCustomOption={(value) => {
|
||||
if (normalizedValues.includes(value)) return null;
|
||||
return createCustomOption?.(value) ?? null;
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
emptyText={emptyText}
|
||||
disabled={disabled}
|
||||
minQueryLength={minQueryLength}
|
||||
searchLimit={searchLimit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function staticReferenceOptionProvider(
|
||||
options: readonly ReferenceOption[]
|
||||
): ReferenceOptionProvider {
|
||||
const byValue = new Map(options.map((option) => [option.value, option]));
|
||||
return {
|
||||
async search(query, context) {
|
||||
if (context.signal.aborted) throw abortError();
|
||||
return filterSearchableSelectOptions(
|
||||
options,
|
||||
query,
|
||||
context.limit
|
||||
) as ReferenceOption[];
|
||||
},
|
||||
async resolve(values, context) {
|
||||
if (context.signal.aborted) throw abortError();
|
||||
return values.map(
|
||||
(value) => byValue.get(value) ?? unavailableReferenceOption(value)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function combineReferenceOptionProviders(
|
||||
providers: readonly ReferenceOptionProvider[]
|
||||
): ReferenceOptionProvider {
|
||||
if (providers.length === 1) return providers[0];
|
||||
|
||||
async function collect(
|
||||
operation: (
|
||||
provider: ReferenceOptionProvider
|
||||
) => Promise<readonly ReferenceOption[]>
|
||||
): Promise<ReferenceOption[]> {
|
||||
const results = await Promise.allSettled(providers.map(operation));
|
||||
const successful = results.filter(
|
||||
(
|
||||
result
|
||||
): result is PromiseFulfilledResult<readonly ReferenceOption[]> =>
|
||||
result.status === "fulfilled"
|
||||
);
|
||||
if (!successful.length) {
|
||||
const failure = results.find(
|
||||
(result): result is PromiseRejectedResult =>
|
||||
result.status === "rejected"
|
||||
);
|
||||
throw failure?.reason ?? new Error("Reference options are unavailable.");
|
||||
}
|
||||
return mergeReferenceOptions(
|
||||
successful.flatMap((result) => [...result.value])
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
async search(query, context) {
|
||||
const options = await collect((provider) =>
|
||||
provider.search(query, context)
|
||||
);
|
||||
return options.slice(0, context.limit);
|
||||
},
|
||||
async resolve(values, context) {
|
||||
const options = await collect((provider) =>
|
||||
provider.resolve
|
||||
? provider.resolve(values, context)
|
||||
: provider.search("", {
|
||||
...context,
|
||||
limit: context.limit ?? Math.max(values.length, 50)
|
||||
})
|
||||
);
|
||||
const byValue = new Map(options.map((option) => [option.value, option]));
|
||||
return values.map(
|
||||
(value) =>
|
||||
byValue.get(value)
|
||||
?? unavailableReferenceOption(value)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function wildcardReferenceOption(
|
||||
value: string,
|
||||
kind = "pattern"
|
||||
): ReferenceOption | null {
|
||||
const clean = value.trim();
|
||||
if (!clean || !/[*?[\]]/.test(clean)) return null;
|
||||
return {
|
||||
value: clean,
|
||||
label: clean,
|
||||
description: "Custom wildcard pattern",
|
||||
kind,
|
||||
availability: "available",
|
||||
custom: true,
|
||||
provenance: { input: "custom_pattern" }
|
||||
};
|
||||
}
|
||||
|
||||
export function customReferenceOption(
|
||||
value: string,
|
||||
kind = "custom"
|
||||
): ReferenceOption | null {
|
||||
const clean = value.trim();
|
||||
if (!clean) return null;
|
||||
return {
|
||||
value: clean,
|
||||
label: clean,
|
||||
description: "Custom reference",
|
||||
kind,
|
||||
availability: "available",
|
||||
custom: true,
|
||||
provenance: { input: "custom_reference" }
|
||||
};
|
||||
}
|
||||
|
||||
export function unavailableReferenceOption(
|
||||
value: string,
|
||||
label = "Unavailable reference"
|
||||
): ReferenceOption {
|
||||
return {
|
||||
value,
|
||||
label,
|
||||
description: value,
|
||||
availability: "unavailable",
|
||||
disabled: true,
|
||||
provenance: { retainedReference: true }
|
||||
};
|
||||
}
|
||||
|
||||
function mergeReferenceOptions(
|
||||
options: readonly ReferenceOption[]
|
||||
): ReferenceOption[] {
|
||||
const merged = new Map<string, ReferenceOption>();
|
||||
for (const option of options) {
|
||||
const current = merged.get(option.value);
|
||||
if (!current || referenceOptionPriority(option) > referenceOptionPriority(current)) {
|
||||
merged.set(option.value, option);
|
||||
}
|
||||
}
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
function referenceOptionPriority(option: ReferenceOption): number {
|
||||
if (option.availability === "unavailable") return 0;
|
||||
if (option.availability === "inactive" || option.disabled) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function referenceOptionDetail(option: ReferenceOption): string {
|
||||
const state = option.custom
|
||||
? option.kind === "pattern"
|
||||
? "Pattern"
|
||||
: "Custom"
|
||||
: option.availability === "inactive"
|
||||
? "Inactive"
|
||||
: option.availability === "unavailable"
|
||||
? "Unavailable"
|
||||
: "";
|
||||
return [state, option.description, option.value !== option.label ? option.value : ""]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException("The operation was aborted.", "AbortError");
|
||||
}
|
||||
379
webui/src/components/SearchableSelect.tsx
Normal file
379
webui/src/components/SearchableSelect.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FocusEvent,
|
||||
type KeyboardEvent
|
||||
} from "react";
|
||||
import { ChevronDown, Search } from "lucide-react";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export type SearchableSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
searchText?: string | null;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type SearchableSelectLoadOptions = (
|
||||
query: string,
|
||||
options: { limit: number; signal: AbortSignal }
|
||||
) => Promise<readonly SearchableSelectOption[]>;
|
||||
|
||||
export type SearchableSelectCreateCustomOption = (
|
||||
value: string
|
||||
) => SearchableSelectOption | null;
|
||||
|
||||
export type SearchableSelectProps = {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (
|
||||
value: string,
|
||||
option: SearchableSelectOption | null
|
||||
) => void;
|
||||
options?: readonly SearchableSelectOption[];
|
||||
loadOptions?: SearchableSelectLoadOptions;
|
||||
createCustomOption?: SearchableSelectCreateCustomOption;
|
||||
selectedOption?: SearchableSelectOption | null;
|
||||
"aria-label"?: string;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
loadingText?: string;
|
||||
errorText?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
minQueryLength?: number;
|
||||
searchLimit?: number;
|
||||
debounceMs?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const EMPTY_OPTIONS: readonly SearchableSelectOption[] = [];
|
||||
|
||||
export function filterSearchableSelectOptions(
|
||||
options: readonly SearchableSelectOption[],
|
||||
query: string,
|
||||
limit = 50
|
||||
): SearchableSelectOption[] {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
const filtered = needle
|
||||
? options.filter((option) =>
|
||||
[
|
||||
option.label,
|
||||
option.description ?? "",
|
||||
option.searchText ?? "",
|
||||
option.value
|
||||
]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase()
|
||||
.includes(needle)
|
||||
)
|
||||
: [...options];
|
||||
return filtered.slice(0, Math.max(1, Math.floor(limit)));
|
||||
}
|
||||
|
||||
export default function SearchableSelect({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
options = EMPTY_OPTIONS,
|
||||
loadOptions,
|
||||
createCustomOption,
|
||||
selectedOption,
|
||||
"aria-label": ariaLabel = "Select an option",
|
||||
placeholder = "Select an option",
|
||||
searchPlaceholder = "Search...",
|
||||
emptyText = "No matching options.",
|
||||
loadingText = "Loading...",
|
||||
errorText = "Options could not be loaded.",
|
||||
disabled = false,
|
||||
required = false,
|
||||
minQueryLength = 0,
|
||||
searchLimit = 50,
|
||||
debounceMs = 200,
|
||||
className = ""
|
||||
}: SearchableSelectProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
const pickerId = id || `searchable-select-${generatedId}`;
|
||||
const listboxId = `${pickerId}-results`;
|
||||
const statusId = `${pickerId}-status`;
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const resolvedSelected = useMemo(
|
||||
() =>
|
||||
selectedOption ??
|
||||
options.find((option) => option.value === value) ??
|
||||
null,
|
||||
[options, selectedOption, value]
|
||||
);
|
||||
const selectedLabel = resolvedSelected?.label ?? value;
|
||||
const [inputValue, setInputValue] = useState(selectedLabel);
|
||||
const [results, setResults] =
|
||||
useState<readonly SearchableSelectOption[]>(EMPTY_OPTIONS);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const normalizedMinLength = Math.max(0, Math.floor(minQueryLength));
|
||||
const normalizedLimit = Math.max(1, Math.min(200, Math.floor(searchLimit)));
|
||||
const visibleResults = useMemo(() => {
|
||||
const customValue = inputValue.trim();
|
||||
const customOption = customValue ? createCustomOption?.(customValue) : null;
|
||||
if (
|
||||
!customOption
|
||||
|| results.some((option) => option.value === customOption.value)
|
||||
) {
|
||||
return results;
|
||||
}
|
||||
return [...results, customOption];
|
||||
}, [createCustomOption, inputValue, results]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setInputValue(selectedLabel);
|
||||
}, [open, selectedLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || disabled) {
|
||||
setLoading(false);
|
||||
setFailed(false);
|
||||
setResults(EMPTY_OPTIONS);
|
||||
return;
|
||||
}
|
||||
const query = inputValue.trim();
|
||||
if (query.length < normalizedMinLength) {
|
||||
setLoading(false);
|
||||
setFailed(false);
|
||||
setResults(EMPTY_OPTIONS);
|
||||
return;
|
||||
}
|
||||
if (!loadOptions) {
|
||||
setResults(filterSearchableSelectOptions(options, query, normalizedLimit));
|
||||
setLoading(false);
|
||||
setFailed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => {
|
||||
setLoading(true);
|
||||
setFailed(false);
|
||||
void loadOptions(query, {
|
||||
limit: normalizedLimit,
|
||||
signal: controller.signal
|
||||
})
|
||||
.then((nextOptions) => {
|
||||
if (!controller.signal.aborted) setResults(nextOptions);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
(error instanceof DOMException && error.name === "AbortError")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setResults(EMPTY_OPTIONS);
|
||||
setFailed(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
}, Math.max(0, Math.floor(debounceMs)));
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [
|
||||
debounceMs,
|
||||
disabled,
|
||||
inputValue,
|
||||
loadOptions,
|
||||
normalizedLimit,
|
||||
normalizedMinLength,
|
||||
open,
|
||||
options
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedIndex = visibleResults.findIndex(
|
||||
(option) => option.value === value && !option.disabled
|
||||
);
|
||||
const firstEnabledIndex = visibleResults.findIndex(
|
||||
(option) => !option.disabled
|
||||
);
|
||||
setActiveIndex(selectedIndex >= 0 ? selectedIndex : firstEnabledIndex);
|
||||
}, [value, visibleResults]);
|
||||
|
||||
function choose(option: SearchableSelectOption) {
|
||||
if (disabled || option.disabled) return;
|
||||
onChange(option.value, option);
|
||||
setInputValue(option.label);
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
|
||||
function moveActive(delta: 1 | -1) {
|
||||
const enabledIndexes = visibleResults
|
||||
.map((option, index) => ({ option, index }))
|
||||
.filter(({ option }) => !option.disabled)
|
||||
.map(({ index }) => index);
|
||||
if (!enabledIndexes.length) {
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
const currentPosition = enabledIndexes.indexOf(activeIndex);
|
||||
const nextPosition =
|
||||
currentPosition < 0
|
||||
? delta > 0
|
||||
? 0
|
||||
: enabledIndexes.length - 1
|
||||
: (currentPosition + delta + enabledIndexes.length) %
|
||||
enabledIndexes.length;
|
||||
setActiveIndex(enabledIndexes[nextPosition]);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
moveActive(1);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
moveActive(-1);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" && open && activeIndex >= 0) {
|
||||
const option = visibleResults[activeIndex];
|
||||
if (option && !option.disabled) {
|
||||
event.preventDefault();
|
||||
choose(option);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
setInputValue(selectedLabel);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
}
|
||||
|
||||
function closeOnFocusLeave(event: FocusEvent<HTMLDivElement>) {
|
||||
const nextTarget = event.relatedTarget;
|
||||
if (nextTarget instanceof Node && rootRef.current?.contains(nextTarget)) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
setInputValue(selectedLabel);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
|
||||
const minimumLengthMissing =
|
||||
inputValue.trim().length < normalizedMinLength;
|
||||
const visibleStatus = loading
|
||||
? loadingText
|
||||
: failed
|
||||
? errorText
|
||||
: minimumLengthMissing
|
||||
? `Type at least ${normalizedMinLength} characters.`
|
||||
: !visibleResults.length
|
||||
? emptyText
|
||||
: "";
|
||||
const rootClassName = ["searchable-select", className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={rootClassName}
|
||||
onBlur={closeOnFocusLeave}
|
||||
>
|
||||
<div className="searchable-select-control">
|
||||
<Search size={17} aria-hidden="true" />
|
||||
<input
|
||||
id={pickerId}
|
||||
type="search"
|
||||
role="combobox"
|
||||
value={inputValue}
|
||||
disabled={disabled}
|
||||
required={required && !value}
|
||||
placeholder={translateText(
|
||||
open ? searchPlaceholder : placeholder
|
||||
)}
|
||||
aria-label={translateText(ariaLabel)}
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={
|
||||
open && activeIndex >= 0
|
||||
? `${pickerId}-option-${activeIndex}`
|
||||
: undefined
|
||||
}
|
||||
aria-describedby={statusId}
|
||||
aria-busy={loading}
|
||||
onFocus={() => {
|
||||
if (!open) setInputValue("");
|
||||
setOpen(true);
|
||||
}}
|
||||
onChange={(event) => {
|
||||
setInputValue(event.target.value);
|
||||
setOpen(true);
|
||||
setFailed(false);
|
||||
if (value) onChange("", null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<ChevronDown size={17} aria-hidden="true" />
|
||||
</div>
|
||||
{open && !disabled && (
|
||||
<div
|
||||
id={listboxId}
|
||||
className="searchable-select-results"
|
||||
role="listbox"
|
||||
aria-label={translateText(ariaLabel)}
|
||||
>
|
||||
{visibleResults.map((option, index) => (
|
||||
<button
|
||||
key={option.value}
|
||||
id={`${pickerId}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
className={[
|
||||
"searchable-select-option",
|
||||
option.value === value ? "is-selected" : "",
|
||||
index === activeIndex ? "is-active" : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
disabled={option.disabled}
|
||||
onMouseEnter={() => {
|
||||
if (!option.disabled) setActiveIndex(index);
|
||||
}}
|
||||
onClick={() => choose(option)}
|
||||
>
|
||||
<strong>{option.label}</strong>
|
||||
{option.description && <small>{option.description}</small>}
|
||||
</button>
|
||||
))}
|
||||
{visibleStatus && (
|
||||
<p className="searchable-select-message">
|
||||
{translateText(visibleStatus)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span id={statusId} className="sr-only" aria-live="polite">
|
||||
{translateText(visibleStatus)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user