Étend le flux de création de layout backend (DTO, usecases, store) et le frontend (sélecteur, adaptateurs, LayoutTabs/LayoutGrid) pour permettre d'ouvrir un layout déclaré par un plugin installé (ex. Android Health), sans passer par le message bloquant "extension backend pas encore livrée". Ticket #141 — QA vert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
102 lines
3.4 KiB
TypeScript
102 lines
3.4 KiB
TypeScript
/**
|
|
* `PluginLayoutSelectorSection` — the "Layouts plugins" section building block
|
|
* (ticket #43, F4, carnet §10: "Section `Layouts plugins` dans le sélecteur").
|
|
*
|
|
* Lists every plugin layout contribution currently loaded (disabled/missing
|
|
* plugins never appear — carnet §1.3: only `enabled` plugins are loaded, so
|
|
* there is nothing to filter here beyond what the registry already omits).
|
|
*
|
|
* Presentational only: the caller owns whether selecting a contribution creates
|
|
* a named plugin layout, replaces an unavailable cell, or just previews it.
|
|
*/
|
|
|
|
import type { PluginLayoutContribution } from "@/domain";
|
|
import type { PluginRuntimeRegistry } from "@/plugins/runtime";
|
|
|
|
export interface PluginLayoutChoice {
|
|
pluginId: string;
|
|
pluginDisplayName: string;
|
|
layout: PluginLayoutContribution;
|
|
}
|
|
|
|
function nonEmptyString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
}
|
|
|
|
function objectOrNull(value: unknown): Record<string, unknown> | null {
|
|
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
|
}
|
|
|
|
function finiteOrder(value: unknown): number {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
}
|
|
|
|
function compareText(a: unknown, b: unknown): number {
|
|
return String(a ?? "").localeCompare(String(b ?? ""));
|
|
}
|
|
|
|
export function listPluginLayoutChoices(registry: PluginRuntimeRegistry): PluginLayoutChoice[] {
|
|
return registry
|
|
.layoutContributions()
|
|
.flatMap(({ pluginId, pluginDisplayName, layout }) => {
|
|
const layoutObject = objectOrNull(layout);
|
|
if (!layoutObject) return [];
|
|
const type = nonEmptyString(layoutObject.type);
|
|
const label = nonEmptyString(layoutObject.label);
|
|
if (!type || !label) return [];
|
|
return [
|
|
{
|
|
pluginId,
|
|
pluginDisplayName: nonEmptyString(pluginDisplayName) ?? pluginId,
|
|
layout: {
|
|
...layout,
|
|
type,
|
|
label,
|
|
order: finiteOrder(layoutObject.order),
|
|
icon: nonEmptyString(layoutObject.icon),
|
|
},
|
|
},
|
|
];
|
|
})
|
|
.sort(
|
|
(a, b) =>
|
|
(a.layout.order ?? 0) - (b.layout.order ?? 0) ||
|
|
compareText(a.pluginDisplayName, b.pluginDisplayName) ||
|
|
compareText(a.layout.label, b.layout.label),
|
|
);
|
|
}
|
|
|
|
interface PluginLayoutSelectorSectionProps {
|
|
registry: PluginRuntimeRegistry;
|
|
onSelect: (choice: PluginLayoutChoice) => void;
|
|
}
|
|
|
|
export function PluginLayoutSelectorSection({
|
|
registry,
|
|
onSelect,
|
|
}: PluginLayoutSelectorSectionProps) {
|
|
const choices = listPluginLayoutChoices(registry);
|
|
if (choices.length === 0) return null;
|
|
|
|
return (
|
|
<div role="group" aria-label="Layouts plugins">
|
|
<p className="px-3 pt-2 text-[0.65rem] font-semibold uppercase tracking-wide text-faint">
|
|
Layouts plugins
|
|
</p>
|
|
{choices.map((choice) => (
|
|
<button
|
|
key={`${choice.pluginId}:${choice.layout.type}`}
|
|
type="button"
|
|
role="menuitem"
|
|
aria-label={`create ${choice.layout.label} layout`}
|
|
onClick={() => onSelect(choice)}
|
|
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm text-content hover:bg-raised"
|
|
>
|
|
<span>{choice.layout.label}</span>
|
|
<span className="text-xs text-faint">{choice.pluginDisplayName}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|