feat: strengthen module contracts and shared WebUI runtime
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { createContext, type ReactNode, useContext, useMemo } from "react";
|
||||
import type { PlatformWebModule } from "../types";
|
||||
import { moduleInstalled, uiCapabilities, uiCapability } from "./modules";
|
||||
import { useEffectiveView, useViewSurfaces } from "./ViewContext";
|
||||
@@ -19,22 +19,34 @@ export function usePlatformModuleInstalled(moduleId: string): boolean {
|
||||
}
|
||||
|
||||
export function usePlatformUiCapability<T = unknown>(capabilityName: string): T | null {
|
||||
return uiCapability<T>(capabilityName, useViewVisibleModules());
|
||||
const modules = useViewVisibleModules();
|
||||
return useMemo(
|
||||
() => uiCapability<T>(capabilityName, modules),
|
||||
[capabilityName, modules]
|
||||
);
|
||||
}
|
||||
|
||||
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
|
||||
return uiCapabilities<T>(capabilityName, useViewVisibleModules());
|
||||
const modules = useViewVisibleModules();
|
||||
return useMemo(
|
||||
() => uiCapabilities<T>(capabilityName, modules),
|
||||
[capabilityName, modules]
|
||||
);
|
||||
}
|
||||
|
||||
function useViewVisibleModules(): PlatformWebModule[] {
|
||||
const modules = usePlatformModules();
|
||||
const projection = useEffectiveView();
|
||||
const surfaces = useViewSurfaces();
|
||||
return modules.filter((module) =>
|
||||
isViewSurfaceVisible(
|
||||
projection,
|
||||
moduleViewSurfaceId(module.id),
|
||||
surfaces
|
||||
)
|
||||
return useMemo(
|
||||
() =>
|
||||
modules.filter((module) =>
|
||||
isViewSurfaceVisible(
|
||||
projection,
|
||||
moduleViewSurfaceId(module.id),
|
||||
surfaces
|
||||
)
|
||||
),
|
||||
[modules, projection, surfaces]
|
||||
);
|
||||
}
|
||||
|
||||
163
webui/src/platform/referenceProviders.ts
Normal file
163
webui/src/platform/referenceProviders.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
apiFetch,
|
||||
apiPath,
|
||||
type ApiQueryParams
|
||||
} from "../api/client";
|
||||
import { fetchPlatformModules } from "../api/platform";
|
||||
import { filterSearchableSelectOptions } from "../components/SearchableSelect";
|
||||
import {
|
||||
unavailableReferenceOption,
|
||||
type ReferenceOption,
|
||||
type ReferenceOptionProvider
|
||||
} from "../components/ReferenceSelect";
|
||||
import type { ApiSettings } from "../types";
|
||||
|
||||
type ApiReferenceOptionPayload = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
kind?: string | null;
|
||||
availability?: ReferenceOption["availability"];
|
||||
disabled?: boolean;
|
||||
source_module?: string | null;
|
||||
provenance?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function apiReferenceOptionProvider(
|
||||
settings: ApiSettings,
|
||||
path: string,
|
||||
params: ApiQueryParams = {}
|
||||
): ReferenceOptionProvider {
|
||||
async function load(
|
||||
query: string,
|
||||
selectedValues: readonly string[],
|
||||
limit: number,
|
||||
signal: AbortSignal
|
||||
): Promise<ReferenceOption[]> {
|
||||
const response = await apiFetch<{
|
||||
options: ApiReferenceOptionPayload[];
|
||||
provider_available: boolean;
|
||||
}>(
|
||||
settings,
|
||||
apiPath(path, {
|
||||
...params,
|
||||
q: query,
|
||||
selected: selectedValues,
|
||||
limit
|
||||
}),
|
||||
{ signal }
|
||||
);
|
||||
return response.options.map(referenceOptionFromPayload);
|
||||
}
|
||||
|
||||
return {
|
||||
search: (query, context) =>
|
||||
load(query, context.selectedValues, context.limit, context.signal),
|
||||
resolve: (values, context) =>
|
||||
load("", values, context.limit ?? values.length, context.signal)
|
||||
};
|
||||
}
|
||||
|
||||
export function platformModuleReferenceProvider(
|
||||
settings: ApiSettings,
|
||||
presentedOptions: readonly ReferenceOption[] = []
|
||||
): ReferenceOptionProvider {
|
||||
const presentedByValue = new Map(
|
||||
presentedOptions.map((option) => [option.value, option])
|
||||
);
|
||||
let cataloguePromise: Promise<ReferenceOption[]> | null = null;
|
||||
|
||||
async function catalogue(signal: AbortSignal): Promise<ReferenceOption[]> {
|
||||
cataloguePromise ??= loadPlatformModuleOptions(
|
||||
settings,
|
||||
presentedOptions,
|
||||
presentedByValue
|
||||
).catch((error: unknown) => {
|
||||
cataloguePromise = null;
|
||||
throw error;
|
||||
});
|
||||
const options = await cataloguePromise;
|
||||
if (signal.aborted) throw abortError();
|
||||
return options;
|
||||
}
|
||||
|
||||
return {
|
||||
async search(query, context) {
|
||||
const options = await catalogue(context.signal);
|
||||
return filterSearchableSelectOptions(
|
||||
options,
|
||||
query,
|
||||
context.limit
|
||||
) as ReferenceOption[];
|
||||
},
|
||||
async resolve(values, context) {
|
||||
const options = await catalogue(context.signal);
|
||||
const byValue = new Map(
|
||||
[...presentedOptions, ...options].map((option) => [
|
||||
option.value,
|
||||
option
|
||||
])
|
||||
);
|
||||
return values.map(
|
||||
(value) =>
|
||||
byValue.get(value)
|
||||
?? unavailableReferenceOption(value, "Unavailable module")
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPlatformModuleOptions(
|
||||
settings: ApiSettings,
|
||||
presentedOptions: readonly ReferenceOption[],
|
||||
presentedByValue: ReadonlyMap<string, ReferenceOption>
|
||||
): Promise<ReferenceOption[]> {
|
||||
let modules;
|
||||
try {
|
||||
modules = (await fetchPlatformModules(settings)).modules;
|
||||
} catch (error) {
|
||||
if (presentedOptions.length) return [...presentedOptions];
|
||||
throw error;
|
||||
}
|
||||
return modules
|
||||
.filter((module) => module.enabled)
|
||||
.map((module) => {
|
||||
const presented = presentedByValue.get(module.id);
|
||||
return {
|
||||
value: module.id,
|
||||
label: presented?.label ?? module.name ?? module.id,
|
||||
description: presented?.description
|
||||
?? `${module.id} · version ${module.version}`,
|
||||
searchText: presented?.searchText
|
||||
?? `${module.id} ${module.name} ${module.version}`,
|
||||
kind: "module",
|
||||
availability: presented?.availability ?? "available",
|
||||
disabled: presented?.disabled ?? false,
|
||||
sourceModule: "core",
|
||||
provenance: {
|
||||
version: module.version,
|
||||
activeRegistry: true,
|
||||
...(presented?.provenance ?? {})
|
||||
}
|
||||
} satisfies ReferenceOption;
|
||||
});
|
||||
}
|
||||
|
||||
function referenceOptionFromPayload(
|
||||
option: ApiReferenceOptionPayload
|
||||
): ReferenceOption {
|
||||
return {
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
kind: option.kind,
|
||||
availability: option.availability,
|
||||
disabled: option.disabled,
|
||||
sourceModule: option.source_module,
|
||||
provenance: option.provenance
|
||||
};
|
||||
}
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException("The operation was aborted.", "AbortError");
|
||||
}
|
||||
Reference in New Issue
Block a user