Files
IdeA/frontend/src/features/plugins/PluginLayoutSelectorSection.tsx
Blomios 45617992ef feat(layout): supporte la création de layouts plugins (customPluginLayout)
É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>
2026-08-03 18:34:21 +02:00

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>
);
}