Converge textarea fields on shared primitive

This commit is contained in:
rcourtman 2026-06-13 15:15:55 +01:00
parent 5a16a777bd
commit 241ac9feaf
9 changed files with 134 additions and 101 deletions

View file

@ -94,6 +94,11 @@ operator-facing alert routing behavior for live runtime alerts.
## Shared Boundaries
1. `internal/proxmoxidentity/backup_identity.go` shared with `monitoring`, `storage-recovery`: Proxmox PBS backup subject identity is a shared runtime boundary for monitoring backup freshness, backup-age alert attribution, and recovery-point guest mapping.
Alert multiline field presentation is shared with frontend-primitives:
notification, timeline, threshold ignored-prefix, and resource threshold note
editors must compose the shared `FormTextarea` primitive for label/id/help
wiring and textarea chrome instead of rendering raw native `<textarea>` shells
in alert-owned runtime components.
## Extension Points
@ -210,8 +215,7 @@ consumers. Email recipient lists, Apprise target lists, webhook payload
templates, threshold ignored-prefix input, and incident timeline notes must
compose `FormTextarea` for label/id/help wiring and textarea chrome instead of
recreating raw labelled `<textarea>` shells locally. Alert resource row/mobile
note editors are the only bounded residual until their row-editor behavior is
migrated through the same primitive.
note editors now follow the same primitive contract.
Guest metric canonical state remains resource-backed and therefore node-scoped
for Proxmox guests, so node moves must not strand active alert state on the
previous resource ID. When a guest metric alert survives a node move, alerts

View file

@ -193,6 +193,10 @@ tabs. The thresholds platform IA is platform-shaped: Proxmox, Docker,
Kubernetes, TrueNAS, vSphere, PBS, PMG, and Systems. Frontend primitives own
the chip, reset, "+ Filter", and route-backed shell pattern; alerts must not
replace that with page-local search/tab chrome or legacy neutral buckets.
Native multiline form fields are also a shared primitive boundary.
`FormTextarea` owns label/id/help wiring, controlled value synchronization, and
textarea chrome for alert, settings, and infrastructure runtime surfaces; those
surfaces must not recreate raw native `<textarea>` shells locally.
The System Updates install guide is a shared settings primitive, not a
deployment-lane-only panel. `UpdateInstallGuide` must render the canonical
@ -2754,9 +2758,8 @@ label/id wiring, helper-text description merging, value synchronization, default
textarea chrome, and compact styling hooks for multi-line text fields. Alert
destination fields, incident notes, infrastructure merge reports, commercial
recovery input, and agent-profile prompt/description fields must compose
`FormTextarea`; bounded residual raw textareas are limited to editor-specific
surfaces called out in the shared-template registry until their dedicated
migration slices retire them.
`FormTextarea`; alert, settings, and infrastructure runtime code must not
recreate raw native `<textarea>` shells outside that primitive.
Search controls are registry-backed too. `SearchField` owns simple search
input chrome, clear affordance, keyboard forwarding, focus handling, aria
labels, and trailing-control padding, while `SearchInput` owns resource-list

View file

@ -3600,7 +3600,7 @@
{
"id": "form-textarea-local-alert-fields",
"category": "form-control",
"summary": "Alert destination and incident timeline textareas must compose FormTextarea; only the resource note editors remain as bounded residuals until their editor-specific migration.",
"summary": "Alert textareas must compose FormTextarea so labels, help text, controlled values, and multiline field styling stay canonical.",
"canonical": {
"path": "src/components/shared/FormTextarea.tsx",
"export": "FormTextarea"
@ -3608,11 +3608,8 @@
"scopes": ["src/components/Alerts", "src/features/alerts"],
"extensions": [".tsx"],
"allPatterns": ["<textarea"],
"legacyReason": "Bounded residual. Alert resource note editors still own row/mobile-specific editing behavior; new alert textareas must use FormTextarea.",
"allowedPaths": [
"src/components/Alerts/AlertResourceTableMobile.tsx",
"src/components/Alerts/AlertResourceTableRow.tsx"
],
"legacyReason": "Retired migration debt. Alert multiline fields belong to FormTextarea so row, mobile, destination, and timeline editors use the same labelled primitive.",
"allowedPaths": [],
"ignoredPaths": [
"src/components/Alerts/EmailProviderSelect.test.tsx",
"src/components/shared/SharedPrimitives.guardrails.test.ts"
@ -3625,7 +3622,7 @@
{
"id": "form-textarea-local-settings-fields",
"category": "form-control",
"summary": "Settings textareas must compose FormTextarea; the SSO provider editor remains as bounded residual until its multi-field provider metadata editor is migrated.",
"summary": "Settings textareas must compose FormTextarea so labels, help text, controlled values, and multiline field styling stay canonical.",
"canonical": {
"path": "src/components/shared/FormTextarea.tsx",
"export": "FormTextarea"
@ -3633,8 +3630,8 @@
"scopes": ["src/components/Settings"],
"extensions": [".tsx"],
"allPatterns": ["<textarea"],
"legacyReason": "Bounded residual. SSO provider metadata textareas still require a dedicated editor migration; new settings textareas must use FormTextarea.",
"allowedPaths": ["src/components/Settings/SSOProvidersPanel.tsx"],
"legacyReason": "Retired migration debt. Settings multiline fields belong to FormTextarea, including provider metadata and access mapping editors.",
"allowedPaths": [],
"ignoredPaths": [
"src/components/Settings/__tests__/ProLicensePanel.test.tsx",
"src/components/shared/SharedPrimitives.guardrails.test.ts"

View file

@ -2,6 +2,7 @@ import { For, Show } from 'solid-js';
import RotateCcw from 'lucide-solid/icons/rotate-ccw';
import { Card } from '@/components/shared/Card';
import { FormTextarea } from '@/components/shared/FormTextarea';
import { TogglePrimitive } from '@/components/shared/Toggle';
import { AlertResourceGroupHeader } from './AlertResourceGroupHeader';
import {
@ -259,7 +260,12 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
class="p-1.5 bg-blue-50 dark:bg-blue-900 text-blue-600 rounded"
aria-label={`Edit thresholds for ${getAlertResourceLabel(resource)}`}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -276,7 +282,12 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
class="p-1.5 bg-surface-hover text-slate-600 rounded"
aria-label="Cancel threshold edits"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -291,7 +302,12 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
class="p-1.5 bg-green-50 dark:bg-green-900 text-green-600 rounded"
aria-label={`Save threshold edits for ${getAlertResourceLabel(resource)}`}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -321,8 +337,11 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
</div>
<Show when={isEditing()}>
<textarea
class="w-full text-xs p-2 rounded border border-border bg-surface-alt"
<FormTextarea
label="Override note"
labelClass="sr-only"
fieldBaseClass="w-full"
textareaBaseClass="w-full text-xs p-2 rounded border border-border bg-surface-alt"
rows={2}
placeholder={getAlertResourceTableEditNotePlaceholder()}
value={props.table.editingNote()}
@ -368,17 +387,13 @@ export function AlertResourceTableMobile(props: AlertResourceTableMobileProps) {
min={bounds.min}
max={bounds.max}
value={thresholds()?.[metric] ?? ''}
placeholder={getAlertResourceTableMetricPlaceholder(
isDisabled(),
)}
placeholder={getAlertResourceTableMetricPlaceholder(isDisabled())}
class="w-16 text-right text-xs p-1 rounded border border-border bg-surface"
onInput={(e) => {
const nextValue = parseFloat(e.currentTarget.value);
props.table.setEditingThresholds({
...props.table.editingThresholds(),
[metric]: Number.isNaN(nextValue)
? undefined
: nextValue,
[metric]: Number.isNaN(nextValue) ? undefined : nextValue,
});
}}
/>

View file

@ -1,6 +1,7 @@
import { For, Show } from 'solid-js';
import RotateCcw from 'lucide-solid/icons/rotate-ccw';
import { FormTextarea } from '@/components/shared/FormTextarea';
import { TogglePrimitive } from '@/components/shared/Toggle';
import { StatusBadge } from '@/components/shared/StatusBadge';
import { ThresholdSlider } from '@/components/Workloads/ThresholdSlider';
@ -305,19 +306,17 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
</Show>
</div>
<Show when={isEditing()}>
<div class="mt-2 w-full">
<label class="sr-only" for={`note-${props.resource.id}`}>
Override note
</label>
<textarea
id={`note-${props.resource.id}`}
class="w-full rounded border border-border bg-surface px-2 py-1 text-xs text-base-content focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
rows={2}
placeholder={getAlertResourceTableOverrideNotePlaceholder()}
value={props.editingNote()}
onInput={(e) => props.setEditingNote(e.currentTarget.value)}
/>
</div>
<FormTextarea
id={`note-${props.resource.id}`}
label="Override note"
labelClass="sr-only"
fieldBaseClass="mt-2 w-full"
textareaBaseClass="w-full rounded border border-border bg-surface px-2 py-1 text-xs text-base-content focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
rows={2}
placeholder={getAlertResourceTableOverrideNotePlaceholder()}
value={props.editingNote()}
onInput={(e) => props.setEditingNote(e.currentTarget.value)}
/>
</Show>
<Show when={!isEditing() && props.resource.note}>
<p class="mt-2 text-xs italic text-muted break-words">{props.resource.note as string}</p>

View file

@ -293,9 +293,13 @@ describe('ResourceTable', () => {
expect(alertResourceTableDesktopSource).toContain('AlertResourceGroupHeader');
expect(alertResourceTableMobileSource).toContain('export function AlertResourceTableMobile');
expect(alertResourceTableMobileSource).toContain('AlertResourceGroupHeader');
expect(alertResourceTableMobileSource).toContain('FormTextarea');
expect(alertResourceTableMobileSource).not.toContain(['<', 'textarea'].join(''));
expect(alertResourceGroupHeaderSource).toContain('export function AlertResourceGroupHeader');
expect(alertResourceTableRowSource).toContain('export function AlertResourceTableRow');
expect(alertResourceTableRowSource).toContain('alertResourceSupportsMetric');
expect(alertResourceTableRowSource).toContain('FormTextarea');
expect(alertResourceTableRowSource).not.toContain(['<', 'textarea'].join(''));
expect(alertResourceTableStateSource).toContain('export function useAlertResourceTableState');
expect(alertResourceTableModelSource).toContain(
'export function normalizeAlertResourceMetricKey',

View file

@ -2,6 +2,7 @@ import { Component, For, Show } from 'solid-js';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { Toggle } from '@/components/shared/Toggle';
import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form';
import { FormTextarea } from '@/components/shared/FormTextarea';
import { Dialog } from '@/components/shared/Dialog';
import Plus from 'lucide-solid/icons/plus';
import Pencil from 'lucide-solid/icons/pencil';
@ -132,14 +133,20 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-base-content truncate" title={provider.name}>
<span
class="font-medium text-base-content truncate"
title={provider.name}
>
{provider.name}
</span>
<span class={getSSOProviderTypeBadgeClass()}>
{getSSOProviderTypeLabel(provider.type)}
</span>
</div>
<p class="text-xs text-muted truncate" title={getSSOProviderSummary(provider)}>
<p
class="text-xs text-muted truncate"
title={getSSOProviderSummary(provider)}
>
{getSSOProviderSummary(provider)}
</p>
</div>
@ -393,16 +400,14 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
</div>
</div>
<div class={formField}>
<label class={labelClass()}>IdP Certificate (PEM)</label>
<textarea
rows={4}
value={form.samlIdpCertificate}
onInput={(e) => setForm('samlIdpCertificate', e.currentTarget.value)}
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
class={controlClass('font-mono text-xs')}
/>
</div>
<FormTextarea
label="IdP Certificate (PEM)"
rows={4}
value={form.samlIdpCertificate}
onInput={(e) => setForm('samlIdpCertificate', e.currentTarget.value)}
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
textareaBaseClass={controlClass('font-mono text-xs')}
/>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class={formField}>
@ -470,7 +475,10 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
<Show when={testResult()?.details?.entityId}>
<div class="flex gap-2">
<dt class="text-muted">Entity ID:</dt>
<dd class="text-base-content truncate" title={testResult()?.details?.entityId}>
<dd
class="text-base-content truncate"
title={testResult()?.details?.entityId}
>
{testResult()?.details?.entityId}
</dd>
</div>
@ -478,7 +486,10 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
<Show when={testResult()?.details?.ssoUrl}>
<div class="flex gap-2">
<dt class="text-muted">SSO URL:</dt>
<dd class="text-base-content truncate" title={testResult()?.details?.ssoUrl}>
<dd
class="text-base-content truncate"
title={testResult()?.details?.ssoUrl}
>
{testResult()?.details?.ssoUrl}
</dd>
</div>
@ -486,7 +497,10 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
<Show when={testResult()?.details?.tokenEndpoint}>
<div class="flex gap-2">
<dt class="text-muted">Token Endpoint:</dt>
<dd class="text-base-content truncate" title={testResult()?.details?.tokenEndpoint}>
<dd
class="text-base-content truncate"
title={testResult()?.details?.tokenEndpoint}
>
{testResult()?.details?.tokenEndpoint}
</dd>
</div>
@ -569,49 +583,41 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
</div>
</Show>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class={formField}>
<label class={labelClass()}>Allowed Groups</label>
<textarea
rows={2}
value={form.allowedGroups}
onInput={(e) => setForm('allowedGroups', e.currentTarget.value)}
placeholder="admin, sso-users"
class={controlClass('min-h-[60px]')}
/>
<p class={formHelpText}>Comma-separated. Empty allows all.</p>
</div>
<div class={formField}>
<label class={labelClass()}>Allowed Domains</label>
<textarea
rows={2}
value={form.allowedDomains}
onInput={(e) => setForm('allowedDomains', e.currentTarget.value)}
placeholder="example.com, corp.io"
class={controlClass('min-h-[60px]')}
/>
<p class={formHelpText}>Email domains (without @)</p>
</div>
<div class={formField}>
<label class={labelClass()}>Allowed Emails</label>
<textarea
rows={2}
value={form.allowedEmails}
onInput={(e) => setForm('allowedEmails', e.currentTarget.value)}
placeholder={ALERT_EMAIL_REPLY_TO_PLACEHOLDER}
class={controlClass('min-h-[60px]')}
/>
</div>
<div class={formField}>
<label class={labelClass()}>Group Role Mappings</label>
<textarea
rows={2}
value={form.groupRoleMappings}
onInput={(e) => setForm('groupRoleMappings', e.currentTarget.value)}
placeholder="admins=admin, ops=operator"
class={controlClass('min-h-[60px]')}
/>
<p class={formHelpText}>Format: group=roleId</p>
</div>
<FormTextarea
label="Allowed Groups"
rows={2}
value={form.allowedGroups}
onInput={(e) => setForm('allowedGroups', e.currentTarget.value)}
placeholder="admin, sso-users"
textareaBaseClass={controlClass('min-h-[60px]')}
help="Comma-separated. Empty allows all."
/>
<FormTextarea
label="Allowed Domains"
rows={2}
value={form.allowedDomains}
onInput={(e) => setForm('allowedDomains', e.currentTarget.value)}
placeholder="example.com, corp.io"
textareaBaseClass={controlClass('min-h-[60px]')}
help="Email domains (without @)"
/>
<FormTextarea
label="Allowed Emails"
rows={2}
value={form.allowedEmails}
onInput={(e) => setForm('allowedEmails', e.currentTarget.value)}
placeholder={ALERT_EMAIL_REPLY_TO_PLACEHOLDER}
textareaBaseClass={controlClass('min-h-[60px]')}
/>
<FormTextarea
label="Group Role Mappings"
rows={2}
value={form.groupRoleMappings}
onInput={(e) => setForm('groupRoleMappings', e.currentTarget.value)}
placeholder="admins=admin, ops=operator"
textareaBaseClass={controlClass('min-h-[60px]')}
help="Format: group=roleId"
/>
</div>
</div>
</Show>

View file

@ -523,6 +523,8 @@ describe('settings architecture guardrails', () => {
expect(ssoProvidersPanelSource).toContain("openAddModal('saml')");
expect(ssoProvidersPanelSource).toContain('getSSOProviderAddButtonLabel');
expect(ssoProvidersPanelSource).toContain('Groups Claim');
expect(ssoProvidersPanelSource).toContain('FormTextarea');
expect(ssoProvidersPanelSource).not.toContain(['<', 'textarea'].join(''));
expect(ssoProvidersPanelSource).toContain(
'Claim used for OIDC allowed groups and role mappings.',
);

View file

@ -202,6 +202,8 @@ import alertOverviewStatsCardsSource from '@/features/alerts/AlertOverviewStatsC
import alertHistoryTableSectionSource from '@/features/alerts/AlertHistoryTableSection.tsx?raw';
import alertHistoryTableGroupRowSource from '@/features/alerts/AlertHistoryTableGroupRow.tsx?raw';
import alertResourceTableDesktopSource from '@/components/Alerts/AlertResourceTableDesktop.tsx?raw';
import alertResourceTableMobileSource from '@/components/Alerts/AlertResourceTableMobile.tsx?raw';
import alertResourceTableRowSource from '@/components/Alerts/AlertResourceTableRow.tsx?raw';
import aiCostDashboardSource from '@/components/AI/AICostDashboard.tsx?raw';
import resourceDetailSummarySource from '@/components/Infrastructure/ResourceDetailSummary.tsx?raw';
import resourceDetailDrawerSource from '@/components/Infrastructure/ResourceDetailDrawer.tsx?raw';
@ -233,6 +235,7 @@ import reportingPanelSource from '@/components/Settings/ReportingPanel.tsx?raw';
import rolesPanelSource from '@/components/Settings/RolesPanel.tsx?raw';
import securityAuthPanelSource from '@/components/Settings/SecurityAuthPanel.tsx?raw';
import securityOverviewPanelSource from '@/components/Settings/SecurityOverviewPanel.tsx?raw';
import ssoProvidersPanelSource from '@/components/Settings/SSOProvidersPanel.tsx?raw';
import updatesSettingsPanelSource from '@/components/Settings/UpdatesSettingsPanel.tsx?raw';
import userAssignmentsPanelSource from '@/components/Settings/UserAssignmentsPanel.tsx?raw';
import infrastructureSourcePickerSource from '@/components/Settings/InfrastructureSourcePicker.tsx?raw';
@ -560,15 +563,12 @@ describe('shared primitive guardrails', () => {
expect(alertGuard?.canonical?.export).toBe('FormTextarea');
expect(alertGuard?.allPatterns).toEqual(['<textarea']);
expect(alertGuard?.scopes).toEqual(['src/components/Alerts', 'src/features/alerts']);
expect(alertGuard?.allowedPaths).toEqual([
'src/components/Alerts/AlertResourceTableMobile.tsx',
'src/components/Alerts/AlertResourceTableRow.tsx',
]);
expect(alertGuard?.allowedPaths ?? []).toHaveLength(0);
expect(settingsGuard?.canonical?.path).toBe('src/components/shared/FormTextarea.tsx');
expect(settingsGuard?.canonical?.export).toBe('FormTextarea');
expect(settingsGuard?.allPatterns).toEqual(['<textarea']);
expect(settingsGuard?.scopes).toEqual(['src/components/Settings']);
expect(settingsGuard?.allowedPaths).toEqual(['src/components/Settings/SSOProvidersPanel.tsx']);
expect(settingsGuard?.allowedPaths ?? []).toHaveLength(0);
expect(infrastructureGuard?.canonical?.path).toBe('src/components/shared/FormTextarea.tsx');
expect(infrastructureGuard?.canonical?.export).toBe('FormTextarea');
expect(infrastructureGuard?.allPatterns).toEqual(['<textarea']);
@ -597,8 +597,11 @@ describe('shared primitive guardrails', () => {
webhookConfigFormSource,
reportMergeModalSource,
agentProfilesPanelSource,
alertResourceTableMobileSource,
alertResourceTableRowSource,
selfHostedCommercialRecoverySectionSource,
suggestProfileModalSource,
ssoProvidersPanelSource,
alertAppriseDestinationsSectionSource,
];
for (const source of migratedConsumers) {