feat: enhance access management with quick create functionality and improved forms

- Added QuickCreateDialog for creating systems and environments quickly.
- Implemented quickCreatePlan to handle creation logic based on context.
- Updated AccessForm to support quick creation and related modals.
- Enhanced i18n support for new quick add features in both English and Spanish.
- Refactored AssignmentForm to allow department creation directly from the form.
- Introduced SelectWithAction component for better user interaction with selections.
- Added tests for new components and functionalities to ensure reliability.
This commit is contained in:
David Arranz 2026-10-04 15:46:39 +02:00
parent 8386679181
commit 5adbe81364
19 changed files with 1166 additions and 177 deletions

View File

@ -62,3 +62,26 @@ ContextHelp muestra una explicación breve y permite desplegar ejemplos y recome
El botón Ayuda de Cuentas y accesos y Catálogos informáticos permite consultar la explicación de la sección sin abrir un alta. La misma ayuda aparece dentro de los modales de alta y edición. FormDialog utiliza una variante amplia para personas y formularios con más campos; el número de campos no cambia la navegación a una página propia.
La ayuda de los formularios se coloca en su parte inferior, inmediatamente antes de FormActions. Este pie compartido reúne Guardar y Cancelar dentro del formulario, conservando el envío y el cierre accesible del diálogo. Los diálogos de consulta mantienen únicamente Cerrar.
## SelectWithAction: desplegable con acción integrada
El componente compartido vive en `@repo/shadcn-ui/components/select-with-action`. Compone Select, Button y ButtonGroup de shadcn/ui (Base UI, Vega). Presenta selección y botón «+» como un único grupo visual, manteniendo controles separados para teclado y lectores de pantalla. ButtonGroup utiliza fieldset para expresar semánticamente la agrupación.
Su contrato recibe id, etiqueta, opciones, placeholder, valor controlado y callback de selección. La acción es opcional y contiene etiqueta accesible, callback y estado deshabilitado. Admite opciones deshabilitadas, estado inválido, descripción accesible y obligatoriedad. Deshabilitar el selector también deshabilita la acción. El botón usa type=button y no envía el formulario.
No conoce compañías, HTTP, formularios ni modales: esas responsabilidades pertenecen al consumidor. No contiene textos propios que deban traducirse.
### Formularios que permiten alta rápida
Sistema permite crear un sistema propio de la compañía mediante un modal adicional. Entorno permite crear un entorno para el sistema activo seleccionado; su acción no aparece antes de elegir un sistema. Los campos fijos en edición no ofrecen alta rápida. Uso y otros valores cerrados tampoco ofrecen altas.
QuickCreateDialog conserva los valores del formulario principal, actualiza el catálogo y selecciona el nuevo UUID después de guardar. Usa las rutas, protección CSRF y reglas de negocio existentes. Un nombre activo ya existente permite usar ese registro sin duplicarlo. Cancelar el modal adicional conserva la cuenta sin cambios. El elemento creado permanece en el catálogo aunque se cancele después la cuenta.
La ayuda aparece antes del pie con Guardar y seleccionar / Cancelar. Solo hay un nivel de alta adicional; el nuevo formulario no vuelve a ofrecer altas rápidas.
La revisión de los formularios extiende este patrón a personas, departamentos, cuentas, grupos, recursos, tipos de recurso, permisos y plantillas. Las asignaciones de departamento permiten añadir el primero sin abandonar el formulario. Se reutilizan los formularios y rutas existentes, manteniendo su validación y ayuda inferior.
Los entornos necesitan un sistema activo seleccionado. Los permisos nuevos heredan el tipo del recurso elegido. Al crear un grupo desde una pertenencia, sistema y entorno quedan fijados a los de la cuenta. Las plantillas siguen siendo globales; el modal lo explica y carga únicamente sus opciones globales. Los elementos creados desde un selector están activos para poder seleccionarlos.
Uso de cuenta, estado de configuración e idioma son listas cerradas. Las referencias históricas de cobertura (necesidad, autorización, titularidad y pertenencia) conservan sus operaciones propias. Los campos inmutables en edición no ofrecen altas. Los permisos múltiples de una necesidad continúan como casillas.

View File

@ -1,13 +1,8 @@
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components/select";
import type { SelectAction } from "@repo/shadcn-ui/components/select-with-action";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
@ -24,6 +19,9 @@ export function AccessFieldControl({
options,
disabled,
onSelected,
onCreate,
createDisabled = false,
createLabel,
}: {
field: AccessField;
id: string;
@ -32,6 +30,9 @@ export function AccessFieldControl({
options: readonly Option[];
disabled: boolean;
onSelected: (value: string) => void;
onCreate?: (() => void) | undefined;
createDisabled?: boolean;
createLabel?: string | undefined;
}) {
const { t } = useTranslation("access");
if (field.type === "boolean") {
@ -39,6 +40,7 @@ export function AccessFieldControl({
<div className="flex items-center gap-2">
<Checkbox
checked={Boolean(value)}
disabled={disabled}
id={id}
onCheckedChange={(checked) => form.setValue(field.key, checked === true)}
/>
@ -48,28 +50,13 @@ export function AccessFieldControl({
}
if (field.type === "permissions") {
return (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">{t(field.key)}</legend>
{options.map((option) => (
<div className="flex items-center gap-2" key={option.value}>
<Checkbox
checked={Array.isArray(value) && value.includes(option.value)}
id={`${id}-${option.value}`}
onCheckedChange={(checked) => {
const values = Array.isArray(value) ? value : [];
form.setValue(
field.key,
checked
? [...values, option.value]
: values.filter((item) => item !== option.value)
);
}}
/>
<Label htmlFor={`${id}-${option.value}`}>{option.label}</Label>
</div>
))}
{!options.length && <p className="text-sm text-muted-foreground">{t("noPermissions")}</p>}
</fieldset>
<PermissionsControl
fieldKey={field.key}
form={form}
id={id}
options={options}
value={value}
/>
);
}
return (
@ -81,28 +68,20 @@ export function AccessFieldControl({
{field.type === "note" ? (
<Textarea id={id} maxLength={4000} rows={4} {...form.register(field.key)} />
) : field.type === "select" ? (
<Select
<SelectWithAction
action={selectAction(
onCreate,
createDisabled,
createLabel ?? t(quickAddLabel(field.key))
)}
disabled={disabled}
items={[{ value: "", label: t("choose") }, ...options]}
onValueChange={(selected) => {
if (typeof selected === "string") {
onSelected(selected);
}
}}
id={id}
label={t(field.key)}
onValueChange={onSelected}
options={options}
placeholder={t("choose")}
value={stringValue(value)}
>
<SelectTrigger className="w-full" id={id}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{t("choose")}</SelectItem>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
/>
) : (
<Input
id={id}
@ -119,3 +98,55 @@ export function AccessFieldControl({
function stringValue(value: string | boolean | string[] | undefined) {
return typeof value === "string" ? value : "";
}
function PermissionsControl({
fieldKey,
id,
value,
options,
form,
}: {
fieldKey: string;
id: string;
value: string | boolean | string[] | undefined;
options: readonly Option[];
form: UseFormReturn<AccessFormValues>;
}) {
const { t } = useTranslation("access");
const values = Array.isArray(value) ? value : [];
return (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">{t(fieldKey)}</legend>
{options.map((option) => (
<div className="flex items-center gap-2" key={option.value}>
<Checkbox
checked={values.includes(option.value)}
id={`${id}-${option.value}`}
onCheckedChange={(checked) =>
form.setValue(
fieldKey,
checked ? [...values, option.value] : values.filter((item) => item !== option.value)
)
}
/>
<Label htmlFor={`${id}-${option.value}`}>{option.label}</Label>
</div>
))}
{!options.length && <p className="text-sm text-muted-foreground">{t("noPermissions")}</p>}
</fieldset>
);
}
function quickAddLabel(key: string) {
return key === "systemId" ? "quickAddSystem" : "quickAddEnvironment";
}
function selectAction(
onClick: (() => void) | undefined,
disabled: boolean,
label: string
): SelectAction | undefined {
if (!onClick) {
return undefined;
}
return { onClick, disabled, label };
}

View File

@ -1,21 +1,31 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useId, useState } from "react";
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { AccessItemDTO, AccessKind, AccessResponseDTO, PersonDTO } from "../../common";
import type {
AccessFieldsDTO,
AccessItemDTO,
AccessKind,
AccessResponseDTO,
PersonDTO,
} from "../../common";
import { AccessFieldsSchema } from "../../common";
import { PersonForm } from "../components/person-form";
import { AccessApiError, accessApi, accessKeys } from "./access-api";
import { AccessFieldControl } from "./access-field-control";
import type { AccessField } from "./access-fields";
import { accessFields } from "./access-fields";
import { accessFields, createLabels } from "./access-fields";
import type { AccessFormValues } from "./access-form-values";
import { compatibleOption, fieldDefault, formBody } from "./access-form-values";
import { accessLabel } from "./access-labels";
import { EntityHelp } from "./entity-help";
import { QuickCreateDialog } from "./quick-create-dialog";
import type { QuickCreatePlan } from "./quick-create-plan";
import { matchesSubmittedValues, quickCreatePlan } from "./quick-create-plan";
export function AccessForm({
scope,
kind,
@ -24,6 +34,9 @@ export function AccessForm({
people,
csrfToken,
onSaved,
allowQuickCreate = true,
initialValues = {},
lockedFields = [],
}: {
scope: string;
kind: AccessKind;
@ -31,16 +44,23 @@ export function AccessForm({
data: AccessResponseDTO;
people: readonly PersonDTO[];
csrfToken: string;
onSaved: () => void;
onSaved: (id?: string, values?: AccessFieldsDTO) => void;
allowQuickCreate?: boolean;
initialValues?: AccessFormValues;
lockedFields?: readonly string[];
}) {
const { t } = useTranslation("access");
const prefix = useId();
const client = useQueryClient();
const [validation, setValidation] = useState<string>();
const fields = accessFields[kind];
const [quickCreate, setQuickCreate] = useState<{
field: AccessField;
plan: QuickCreatePlan;
}>();
const form = useForm<AccessFormValues>({
defaultValues: Object.fromEntries(
fields.map((field) => [field.key, fieldDefault(field, item)])
fields.map((field) => [field.key, initialValues[field.key] ?? fieldDefault(field, item)])
),
});
const watched = form.watch();
@ -56,10 +76,18 @@ export function AccessForm({
}
return accessApi.request(scope, parsed.data, item?.id, csrfToken);
},
onSuccess: async (result) => {
onSuccess: async (result, submitted) => {
const savedId =
item?.id ??
result.items.find(
(candidate) =>
candidate.companyId === (scope === "global" ? null : scope) &&
!data.items.some((existing) => existing.id === candidate.id) &&
matchesSubmittedValues(candidate.values, AccessFieldsSchema.parse(submitted))
)?.id;
await client.invalidateQueries({ queryKey: ["access"] });
client.setQueryData(accessKeys.scope(scope), result);
onSaved();
onSaved(savedId, AccessFieldsSchema.parse(submitted));
},
});
const submit = form.handleSubmit(async (raw) => {
@ -71,8 +99,12 @@ export function AccessForm({
}
await mutation.mutateAsync(parsed.data).catch(() => undefined);
});
const select = (field: AccessField, value: string) => {
const select = (field: AccessField, value: string, createdValues?: AccessFieldsDTO) => {
form.setValue(field.key, value);
if (field.key === "resourceId" || field.key === "resourceTypeId") {
form.setValue("permissionId", "");
form.setValue("permissionIds", []);
}
if (field.key === "systemId") {
form.setValue("environmentId", "");
}
@ -83,17 +115,17 @@ export function AccessForm({
form.setValue("accountId", "");
}
if (field.key === "templateId") {
applyTemplate(value);
applyTemplate(value, createdValues);
}
};
const applyTemplate = (id: string) => {
const template = data.items.find((target) => target.id === id)?.values;
const applyTemplate = (id: string, createdValues?: AccessFieldsDTO) => {
const template = createdValues ?? data.items.find((target) => target.id === id)?.values;
if (template?.kind !== "template") {
return;
}
form.setValue("name", template.name);
form.setValue("description", template.description ?? "");
if (template.systemId) {
if (template.systemId && !lockedFields.includes("systemId")) {
form.setValue("systemId", template.systemId);
form.setValue("environmentId", "");
}
@ -113,6 +145,19 @@ export function AccessForm({
)
.map((target) => ({ value: target.id, label: accessLabel(target, data.items, people) }));
};
const quickAction = (field: AccessField) => {
if (!allowQuickCreate || (item && field.fixed) || lockedFields.includes(field.key)) {
return undefined;
}
const plan = quickCreatePlan(field, scope, kind, data, watched);
return plan ? () => setQuickCreate({ field, plan }) : undefined;
};
const created = (id?: string, values?: AccessFieldsDTO) => {
if (quickCreate && id) {
select(quickCreate.field, id, values);
setQuickCreate(undefined);
}
};
const help: Readonly<Record<string, string>> = {
account: "accountHelp",
coverage: "coverageHelp",
@ -120,47 +165,168 @@ export function AccessForm({
};
const editableFields = fields.filter((field) => field.key !== "configuration" || !technical);
return (
<form className="space-y-4" onSubmit={submit}>
<div className="grid gap-4 sm:grid-cols-2">
{editableFields.map((field) => (
<div
className={["note", "permissions"].includes(field.type) ? "sm:col-span-2" : ""}
key={field.key}
>
<AccessFieldControl
disabled={Boolean(item && field.fixed)}
field={field}
form={form}
id={`${prefix}-${field.key}`}
onSelected={(value) => select(field, value)}
options={options(field)}
value={watched[field.key]}
/>
</div>
))}
</div>
{validation && (
<p className="text-destructive" role="alert">
{validation}
</p>
<>
<form className="space-y-4" onSubmit={submit}>
<div className="grid gap-4 sm:grid-cols-2">
{editableFields.map((field) => (
<div
className={["note", "permissions"].includes(field.type) ? "sm:col-span-2" : ""}
key={field.key}
>
<AccessFieldControl
createLabel={
field.key === "systemId" || field.key === "environmentId"
? undefined
: t("quickAddEntry", { name: t(field.key) })
}
disabled={
Boolean(item && field.fixed) ||
lockedFields.includes(field.key) ||
mutation.isPending
}
field={field}
form={form}
id={`${prefix}-${field.key}`}
onCreate={quickAction(field)}
onSelected={(value) => select(field, value)}
options={options(field)}
value={watched[field.key]}
/>
</div>
))}
</div>
{validation && (
<p className="text-destructive" role="alert">
{validation}
</p>
)}
{mutation.isError && (
<p className="text-destructive" role="alert">
{t(
`errors.${mutation.error instanceof AccessApiError ? mutation.error.code : "INTERNAL_ERROR"}`
)}
</p>
)}
{help[kind] && <p className="text-sm text-muted-foreground">{t(help[kind])}</p>}
{["account_person", "membership", "grant"].includes(kind) && (
<p className="text-sm text-muted-foreground">{t("configurationHelp")}</p>
)}
<EntityHelp kind={kind} scope={scope} />
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{quickCreate && (
<AccessQuickEditor
csrfToken={csrfToken}
data={data}
onClose={() => setQuickCreate(undefined)}
onCreated={created}
people={people}
plan={quickCreate.plan}
scope={scope}
/>
)}
{mutation.isError && (
<p className="text-destructive" role="alert">
{t(
`errors.${mutation.error instanceof AccessApiError ? mutation.error.code : "INTERNAL_ERROR"}`
)}
</p>
)}
{help[kind] && <p className="text-sm text-muted-foreground">{t(help[kind])}</p>}
{["account_person", "membership", "grant"].includes(kind) && (
<p className="text-sm text-muted-foreground">{t("configurationHelp")}</p>
)}
<EntityHelp kind={kind} scope={scope} />
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
</>
);
}
function AccessQuickEditor({
plan,
scope,
data,
people,
csrfToken,
onCreated,
onClose,
}: {
plan: QuickCreatePlan;
scope: string;
data: AccessResponseDTO;
people: readonly PersonDTO[];
csrfToken: string;
onCreated: (id?: string, values?: AccessFieldsDTO) => void;
onClose: () => void;
}) {
const { t } = useTranslation("access");
const active = useRef(true);
useEffect(() => {
active.current = true;
return () => {
active.current = false;
};
}, []);
const close = () => {
active.current = false;
onClose();
};
const created = (id?: string, values?: AccessFieldsDTO) => {
if (active.current) {
onCreated(id, values);
}
};
const globalQuery = useQuery({
queryKey: accessKeys.scope("global"),
queryFn: () => accessApi.request("global"),
enabled: plan.type === "access" && plan.scope === "global" && scope !== "global",
});
if (plan.type === "simple") {
return (
<QuickCreateDialog
csrfToken={csrfToken}
items={data.items}
onClose={close}
onCreated={created}
scope={scope}
target={plan.target}
/>
);
}
const title = plan.type === "person" ? "quickAddPerson" : createLabels[plan.kind];
const childData = plan.type === "access" && plan.scope !== scope ? globalQuery.data : data;
return (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
close();
}
}}
open
title={t(title)}
wide
>
{plan.type === "person" ? (
<>
<p className="text-sm text-muted-foreground">{t("quickCreateNotice")}</p>
<PersonForm companyId={scope} csrfToken={csrfToken} onSaved={created} requireActive />
</>
) : (
<>
<p className="text-sm text-muted-foreground">
{t(plan.scope === "global" ? "quickGlobalNotice" : "quickCreateNotice")}
</p>
{childData ? (
<AccessForm
allowQuickCreate={false}
csrfToken={csrfToken}
data={childData}
initialValues={plan.defaults}
kind={plan.kind}
lockedFields={plan.locked}
onSaved={created}
people={people}
scope={plan.scope}
/>
) : (
<p role="status">{t("loading")}</p>
)}
{globalQuery.isError && <p role="alert">{t("errors.INTERNAL_ERROR")}</p>}
</>
)}
</FormDialog>
);
}

View File

@ -6,10 +6,14 @@ import { createInstance } from "i18next";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import type { AccessResponseDTO } from "../../common";
import { accessKeys } from "./access-api";
import { accessFields } from "./access-fields";
import { AccessForm } from "./access-form";
import { AccessWorkspacePage } from "./access-workspace";
import { accessEnglish, accessSpanish } from "./i18n";
import { quickCreatePlan } from "./quick-create-plan";
test("los catálogos presentan alta mediante acción y no un formulario junto al listado", async () => {
for (const [language, label] of [
@ -54,6 +58,8 @@ test("cuentas ofrecen caducidad opcional y campos sin contraseña", async () =>
</I18nextProvider>
);
assert.ok(markup.includes("Fecha de caducidad"));
assert.ok(markup.includes('aria-label="Añadir sistema"'));
assert.equal(markup.includes('aria-label="Añadir entorno"'), false);
assert.ok(markup.includes("Sistema, entorno y uso quedan fijos"));
assert.equal(markup.includes('type="password"'), false);
const field = markup.match(/<input[^>]*name="expiresOn"[^>]*>/)?.[0];
@ -143,3 +149,130 @@ test("los ejemplos del sistema orientan sin rellenar datos del alta", async () =
assert.equal(markup.includes('value="Microsoft 365"'), false);
client.clear();
});
test("las altas rápidas respetan el contexto, ámbito y relaciones históricas", () => {
const data: AccessResponseDTO = {
items: [
{
id: "system",
companyId: null,
history: [],
values: { kind: "system", name: "Directorio", active: true },
},
{
id: "type",
companyId: null,
history: [],
values: { kind: "resource_type", name: "Carpeta", active: true },
},
{
id: "resource",
companyId: "company",
history: [],
values: {
kind: "resource",
name: "Documentos",
resourceTypeId: "type",
active: true,
systemId: null,
environmentId: null,
reference: null,
notes: null,
},
},
{
id: "account",
companyId: "company",
history: [],
values: {
kind: "account",
systemId: "system",
environmentId: "env",
login: "ana",
expiresOn: null,
notes: null,
usage: "personal",
active: true,
},
},
],
reviews: [],
coverage: [],
};
const field = (kind: keyof typeof accessFields, key: string) => {
const result = accessFields[kind].find((candidate) => candidate.key === key);
assert.ok(result);
return result;
};
const plan = (kind: keyof typeof accessFields, key: string, values = {}, scope = "company") =>
quickCreatePlan(field(kind, key), scope, kind, data, values);
assert.equal(plan("account", "usage"), undefined);
assert.equal(plan("account", "environmentId"), undefined);
assert.deepEqual(plan("account", "environmentId", { systemId: "system" }), {
type: "simple",
target: { kind: "environment", systemId: "system", systemName: "Directorio" },
});
assert.equal(plan("grant", "permissionId"), undefined);
assert.deepEqual(plan("grant", "permissionId", { resourceId: "resource" }), {
type: "access",
kind: "permission",
scope: "company",
defaults: { active: true, resourceTypeId: "type" },
locked: ["active", "resourceTypeId"],
});
assert.equal(plan("membership", "groupId"), undefined);
assert.deepEqual(plan("membership", "groupId", { accountId: "account" }), {
type: "access",
kind: "group",
scope: "company",
defaults: { active: true, systemId: "system", environmentId: "env" },
locked: ["active", "systemId", "environmentId"],
});
assert.deepEqual(plan("account_person", "personId"), { type: "person" });
assert.equal(plan("account_person", "personId", {}, "global"), undefined);
assert.deepEqual(plan("group", "templateId"), {
type: "access",
kind: "template",
scope: "global",
defaults: { active: true },
locked: ["active"],
});
for (const entry of accessFields.coverage) {
assert.equal(quickCreatePlan(entry, "company", "coverage", data, {}), undefined);
}
});
test("los formularios ofrecen altas relacionadas y el segundo modal evita altas anidadas", async () => {
const i18n = createInstance();
await i18n.init({ lng: "es", resources: { es: { access: accessSpanish } } });
const client = new QueryClient();
const render = (kind: keyof typeof accessFields, allowQuickCreate = true) =>
renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>
<AccessForm
allowQuickCreate={allowQuickCreate}
csrfToken="test"
data={{ items: [], reviews: [], coverage: [] }}
kind={kind}
onSaved={() => undefined}
people={[]}
scope="company"
/>
</QueryClientProvider>
</I18nextProvider>
);
for (const kind of [
"permission",
"account_person",
"resource",
"grant",
"need",
"group",
] as const) {
assert.ok(render(kind).includes('data-slot="button-group"'), kind);
assert.equal(render(kind, false).includes('data-slot="button-group"'), false, kind);
}
assert.equal(render("coverage").includes('data-slot="button-group"'), false);
client.clear();
});

View File

@ -2,6 +2,19 @@ import { helpEnglish, helpSpanish } from "./help-i18n";
export const accessSpanish = {
...helpSpanish,
quickAddPerson: "Nueva persona",
quickAddEntry: "Añadir: {{name}}",
quickGlobalNotice:
"Se guardará en el catálogo global y estará disponible para todas las compañías, aunque canceles el formulario principal.",
quickExisting:
"Ya existe un elemento activo con este nombre. Puedes seleccionarlo sin crear otro.",
quickUseExisting: "Usar existente",
quickAddSystem: "Añadir sistema",
quickAddEnvironment: "Añadir entorno",
quickSaveSelect: "Guardar y seleccionar",
quickEnvironmentContext: "Sistema: {{name}}",
quickCreateNotice:
"Se guardará en el catálogo de esta compañía, aunque después canceles el formulario principal.",
title: "Cuentas y accesos",
catalogTitle: "Catálogos informáticos",
catalogHelp:
@ -132,6 +145,19 @@ export const accessSpanish = {
};
export const accessEnglish = {
...helpEnglish,
quickAddPerson: "New person",
quickAddEntry: "Add: {{name}}",
quickGlobalNotice:
"It will be saved in the global catalogue for all companies even if you cancel the main form.",
quickExisting:
"An active entry with this name already exists. You can select it without creating another.",
quickUseExisting: "Use existing",
quickAddSystem: "Add system",
quickAddEnvironment: "Add environment",
quickSaveSelect: "Save and select",
quickEnvironmentContext: "System: {{name}}",
quickCreateNotice:
"It will be saved in this company's catalogue even if you later cancel the main form.",
title: "Accounts and access",
catalogTitle: "IT catalogs",
catalogHelp: "Shared definitions for all companies. Companies can add their own entries.",

View File

@ -0,0 +1,151 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useId, useRef } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { AccessFieldsDTO, AccessItemDTO } from "../../common";
import { AccessFieldsSchema } from "../../common";
import { AccessApiError, accessApi, accessKeys } from "./access-api";
import { EntityHelp } from "./entity-help";
export type QuickCreateTarget =
| { kind: "system" }
| { kind: "environment"; systemId: string; systemName: string };
export function QuickCreateDialog({
target,
scope,
csrfToken,
onClose,
onCreated,
items,
}: {
target: QuickCreateTarget;
scope: string;
csrfToken: string;
onClose: () => void;
onCreated: (id: string) => void;
items: readonly AccessItemDTO[];
}) {
const { t } = useTranslation("access");
const id = useId();
const client = useQueryClient();
const dismissed = useRef(false);
const form = useForm<{ name: string }>({ defaultValues: { name: "" } });
const enteredName = form.watch("name").trim().toLocaleLowerCase();
const existing = items.find((item) => {
const values = item.values;
return (
"name" in values &&
"active" in values &&
values.active &&
values.name.toLocaleLowerCase() === enteredName &&
(target.kind === "system"
? values.kind === "system"
: values.kind === "environment" && values.systemId === target.systemId)
);
});
const mutation = useMutation({
mutationFn: async ({ name }: { name: string }) => {
const values = AccessFieldsSchema.parse(
target.kind === "system"
? { kind: "system", name, active: true }
: { kind: "environment", name, active: true, systemId: target.systemId }
);
const result = await accessApi.request(scope, values, undefined, csrfToken);
const created = result.items.find(
(item) =>
item.companyId === (scope === "global" ? null : scope) && matches(item.values, values)
);
if (!created) {
throw new AccessApiError("INTERNAL_ERROR");
}
return { result, id: created.id };
},
onSuccess: async ({ result, id }) => {
await client.invalidateQueries({ queryKey: ["access"] });
client.setQueryData(accessKeys.scope(scope), result);
if (!dismissed.current) {
onCreated(id);
}
},
});
return (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
dismissed.current = true;
onClose();
}
}}
open
title={t(target.kind === "system" ? "quickAddSystem" : "quickAddEnvironment")}
>
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
{target.kind === "environment" && (
<p className="text-sm text-muted-foreground">
{t("quickEnvironmentContext", { name: target.systemName })}
</p>
)}
<div className="space-y-2">
<Label htmlFor={id}>{t("name")}</Label>
<Input
id={id}
maxLength={100}
required
{...form.register("name", { validate: (value) => value.trim().length > 0 })}
/>
{form.formState.errors.name && (
<p className="text-destructive" role="alert">
{t("invalid")}
</p>
)}
</div>
{mutation.isError && (
<p className="text-destructive" role="alert">
{t(
`errors.${mutation.error instanceof AccessApiError ? mutation.error.code : "INTERNAL_ERROR"}`
)}
</p>
)}
{existing && (
<div className="space-y-2">
<p>{t("quickExisting")}</p>
<Button
disabled={mutation.isPending}
onClick={() => onCreated(existing.id)}
type="button"
variant="outline"
>
{t("quickUseExisting")}
</Button>
</div>
)}
<p className="text-sm text-muted-foreground">{t("quickCreateNotice")}</p>
<EntityHelp kind={target.kind} scope={scope} />
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "quickSaveSelect")}
</Button>
</FormActions>
</form>
</FormDialog>
);
}
function matches(item: AccessFieldsDTO, values: AccessFieldsDTO) {
if (values.kind === "system" && item.kind === "system") {
return item.name === values.name;
}
if (values.kind === "environment" && item.kind === "environment") {
return item.name === values.name && item.systemId === values.systemId;
}
return false;
}

View File

@ -0,0 +1,120 @@
import type { AccessFieldsDTO, AccessKind, AccessResponseDTO } from "../../common";
import type { AccessField } from "./access-fields";
import type { AccessFormValues } from "./access-form-values";
import type { QuickCreateTarget } from "./quick-create-dialog";
export type QuickCreatePlan =
| { type: "simple"; target: QuickCreateTarget }
| { type: "person" }
| {
type: "access";
kind: AccessKind;
scope: string;
defaults: AccessFormValues;
locked: readonly string[];
};
const CREATABLE = new Set<AccessKind>([
"system",
"environment",
"resource_type",
"permission",
"template",
"account",
"group",
"resource",
]);
/** Solo ofrece altas de fichas y catálogos; las autorizaciones históricas mantienen su flujo propio. */
export function quickCreatePlan(
field: AccessField,
scope: string,
kind: AccessKind,
data: AccessResponseDTO,
watched: AccessFormValues
): QuickCreatePlan | undefined {
if (field.key === "personId" && scope !== "global") {
return { type: "person" };
}
if (!(field.target && CREATABLE.has(field.target))) {
return undefined;
}
if (field.target === "system") {
return { type: "simple", target: { kind: "system" } };
}
if (field.target === "environment") {
return environmentPlan(data, watched);
}
const context = contextValues(field.target, kind, data, watched);
if (!context) {
return undefined;
}
const { defaults, locked } = context;
return {
type: "access",
kind: field.target,
scope: field.target === "template" ? "global" : scope,
defaults,
locked,
};
}
function environmentPlan(
data: AccessResponseDTO,
watched: AccessFormValues
): QuickCreatePlan | undefined {
const system = data.items.find((item) => item.id === watched.systemId)?.values;
if (system?.kind !== "system" || !system.active || typeof watched.systemId !== "string") {
return undefined;
}
return {
type: "simple",
target: { kind: "environment", systemId: watched.systemId, systemName: system.name },
};
}
function contextValues(
target: AccessKind,
kind: AccessKind,
data: AccessResponseDTO,
watched: AccessFormValues
) {
const defaults: AccessFormValues = { active: true };
const locked = ["active"];
if (target === "permission") {
const resource = data.items.find((item) => item.id === watched.resourceId)?.values;
const typeId = resource?.kind === "resource" ? resource.resourceTypeId : watched.resourceTypeId;
const resourceType = data.items.find((item) => item.id === typeId)?.values;
if (
resourceType?.kind !== "resource_type" ||
!resourceType.active ||
typeof typeId !== "string"
) {
return undefined;
}
defaults.resourceTypeId = typeId;
locked.push("resourceTypeId");
}
if (target === "group" && kind === "membership") {
const account = data.items.find((item) => item.id === watched.accountId)?.values;
if (account?.kind !== "account" || !account.active) {
return undefined;
}
defaults.systemId = account.systemId;
defaults.environmentId = account.environmentId ?? "";
locked.push("systemId", "environmentId");
}
return { defaults, locked };
}
export function matchesSubmittedValues(left: AccessFieldsDTO, right: AccessFieldsDTO): boolean {
const entries = new Map(Object.entries(left));
return Object.entries(right).every(([key, value]) => {
const actual = entries.get(key);
if (Array.isArray(value) && Array.isArray(actual)) {
return value.length === actual.length && value.every((entry) => actual.includes(entry));
}
return actual === value;
});
}

View File

@ -1,11 +1,12 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useId } from "react";
import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod/v4";
@ -15,6 +16,7 @@ import { EntityHelp } from "../access/entity-help";
import { companyApi, companyKeys } from "../api/company-api";
import { CompanyFeedback } from "./company-feedback";
import { DepartmentForm } from "./department-section";
const FormSchema = z.strictObject({
departmentId: z.uuid(),
@ -47,6 +49,7 @@ export function AssignmentForm({
}) {
const { t } = useTranslation("companies");
const id = useId();
const [creatingDepartment, setCreatingDepartment] = useState(false);
const client = useQueryClient();
const form = useForm<FormValues>({
resolver: zodResolver(FormSchema),
@ -79,69 +82,106 @@ export function AssignmentForm({
},
});
return (
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
<div className="space-y-2">
<Label htmlFor={`${id}-department`}>{t("department")}</Label>
<select
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
disabled={assignment !== undefined}
id={`${id}-department`}
required
{...form.register("departmentId")}
>
<option value="">{t("chooseDepartment")}</option>
{company.departments
.filter((item) => item.active || item.id === assignment?.departmentId)
.map((department) => (
<option key={department.id} value={department.id}>
{department.name}
{department.active ? "" : ` · ${t("inactive")}`}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-position`}>{t("departmentPosition")}</Label>
<Input id={`${id}-position`} maxLength={100} {...form.register("position")} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<>
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
<div className="space-y-2">
<Label htmlFor={`${id}-start`}>{t("startDate")}</Label>
<Input id={`${id}-start`} required type="date" {...form.register("startDate")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-end`}>{t("endDate")}</Label>
<Input
id={`${id}-end`}
min={form.watch("startDate")}
type="date"
{...form.register("endDate")}
<Label htmlFor={`${id}-department`}>{t("department")}</Label>
<SelectWithAction
action={
assignment
? undefined
: {
label: t("newDepartment"),
onClick: () => setCreatingDepartment(true),
}
}
disabled={assignment !== undefined || mutation.isPending}
id={`${id}-department`}
invalid={!!form.formState.errors.departmentId}
label={t("department")}
onValueChange={(value) =>
form.setValue("departmentId", value, { shouldDirty: true, shouldValidate: true })
}
options={company.departments
.filter((item) => item.active || item.id === assignment?.departmentId)
.map((department) => ({
value: department.id,
label: department.name + (department.active ? "" : ` · ${t("inactive")}`),
}))}
placeholder={t("chooseDepartment")}
required
value={form.watch("departmentId")}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={!form.watch("endDate") && form.watch("isPrimary")}
disabled={!!form.watch("endDate")}
id={`${id}-primary`}
onCheckedChange={(checked) =>
form.setValue("isPrimary", checked === true, { shouldDirty: true })
}
<div className="space-y-2">
<Label htmlFor={`${id}-position`}>{t("departmentPosition")}</Label>
<Input id={`${id}-position`} maxLength={100} {...form.register("position")} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${id}-start`}>{t("startDate")}</Label>
<Input id={`${id}-start`} required type="date" {...form.register("startDate")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-end`}>{t("endDate")}</Label>
<Input
id={`${id}-end`}
min={form.watch("startDate")}
type="date"
{...form.register("endDate")}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={!form.watch("endDate") && form.watch("isPrimary")}
disabled={!!form.watch("endDate")}
id={`${id}-primary`}
onCheckedChange={(checked) =>
form.setValue("isPrimary", checked === true, { shouldDirty: true })
}
/>
<Label htmlFor={`${id}-primary`}>{t("primaryDepartment")}</Label>
</div>
<p className="text-xs text-muted-foreground">{t("assignmentHelp")}</p>
<CompanyFeedback
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
/>
<Label htmlFor={`${id}-primary`}>{t("primaryDepartment")}</Label>
</div>
<p className="text-xs text-muted-foreground">{t("assignmentHelp")}</p>
<CompanyFeedback
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
/>
<EntityHelp kind="assignment" />
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
<EntityHelp kind="assignment" />
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{creatingDepartment && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setCreatingDepartment(false);
}
}}
open
title={t("newDepartment")}
>
<DepartmentForm
companyId={company.id}
csrfToken={csrfToken}
onSaved={(departmentId) => {
form.setValue("departmentId", departmentId, {
shouldDirty: true,
shouldValidate: true,
});
setCreatingDepartment(false);
}}
requireActive
/>
</FormDialog>
)}
</>
);
}

View File

@ -10,6 +10,7 @@ import { I18nextProvider } from "react-i18next";
import { companyKeys } from "../api/company-api";
import { companiesEnglish, companiesSpanish } from "../i18n";
import { AssignmentForm } from "./assignment-form";
import { CompaniesPage } from "./companies-page";
import { CompanyDetail } from "./company-detail";
import { PersonForm } from "./person-form";
@ -123,3 +124,51 @@ test("el formulario de persona distingue contactos de cuentas de acceso y permit
}
assert.equal(markup.includes('name="password"'), false);
});
test("asignar un departamento permite añadir el primero y conserva el selector en edición", async () => {
const company = {
id: companyId,
name: "Empresa",
active: true,
departments: [],
assignments: [],
people: [],
contacts: [],
sites: [],
};
const markup = await renderPage(
"es",
<AssignmentForm
company={company}
csrfToken="test"
onSaved={() => undefined}
personId={companyId}
/>,
() => undefined
);
assert.ok(markup.includes('aria-label="Nuevo departamento"'));
assert.ok(markup.includes('role="combobox"'));
assert.equal(markup.includes("<select"), false);
const edited = await renderPage(
"es",
<AssignmentForm
assignment={{
id: companyId,
personId: companyId,
departmentId: companyId,
companyId,
history: [],
position: null,
startDate: "2026-01-01",
endDate: null,
isPrimary: false,
}}
company={company}
csrfToken="test"
onSaved={() => undefined}
personId={companyId}
/>,
() => undefined
);
assert.equal(edited.includes('aria-label="Nuevo departamento"'), false);
});

View File

@ -14,7 +14,7 @@ import {
TableRow,
} from "@repo/shadcn-ui/components/table";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
@ -25,20 +25,29 @@ import { companyApi, companyKeys } from "../api/company-api";
import { CompanyFeedback } from "./company-feedback";
function DepartmentForm({
export function DepartmentForm({
companyId,
department,
csrfToken,
onSaved,
requireActive = false,
}: {
companyId: string;
department?: DepartmentDTO | undefined;
csrfToken: string;
onSaved: () => void;
onSaved: (id: string) => void;
requireActive?: boolean;
}) {
const { t } = useTranslation("companies");
const id = useId();
const client = useQueryClient();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const form = useForm<DepartmentRequestDTO>({
resolver: zodResolver(DepartmentRequestSchema),
defaultValues: { name: department?.name ?? "", active: department?.active ?? true },
@ -48,7 +57,12 @@ function DepartmentForm({
companyApi.department(companyId, department?.id, values, csrfToken),
onSuccess: (response) => {
client.setQueryData(companyKeys.detail(companyId), response);
onSaved();
const saved = department
? response.company.departments.find((item) => item.id === department.id)
: response.company.departments.at(-1);
if (saved && mounted.current) {
onSaved(saved.id);
}
},
});
return (
@ -60,6 +74,7 @@ function DepartmentForm({
<div className="flex items-center gap-2">
<Checkbox
checked={form.watch("active")}
disabled={requireActive}
id={`${id}-active`}
onCheckedChange={(checked) =>
form.setValue("active", checked === true, { shouldDirty: true })

View File

@ -84,10 +84,7 @@ function PersonDetails({
<CardContent className="space-y-4 pt-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<h3 className="text-lg font-semibold">{t("departmentAssignments")}</h3>
<Button
disabled={!(person.active && company.departments.some((item) => item.active))}
onClick={() => setEditor("new")}
>
<Button disabled={!person.active} onClick={() => setEditor("new")}>
{t("addAssignment")}
</Button>
</div>

View File

@ -29,11 +29,13 @@ export function PersonForm({
companyId,
csrfToken,
onSaved,
requireActive = false,
}: {
person?: PersonDTO | undefined;
companyId: string;
csrfToken: string;
onSaved: (id: string) => void;
requireActive?: boolean;
}) {
const { t } = useTranslation("companies");
const id = useId();
@ -101,6 +103,7 @@ export function PersonForm({
<div className="flex items-center gap-2">
<Checkbox
checked={form.watch("active")}
disabled={requireActive}
id={`${id}-active`}
onCheckedChange={(checked) =>
form.setValue("active", checked === true, { shouldDirty: true })

View File

@ -42,7 +42,7 @@ export const companiesSpanish = {
"Sin fecha de fin, la asignación sigue abierta. Para registrar un cambio de puesto con un nuevo periodo, cierra esta asignación y crea la siguiente; puedes usar el mismo día. Las ediciones conservan su historial.",
history: "Historial",
assignmentHistory: "Historial de la asignación",
createDepartmentFirst: "Crea o activa un departamento para poder asignarlo.",
createDepartmentFirst: "Puedes añadir un departamento desde el formulario de asignación.",
yes: "Sí",
no: "No",
@ -148,7 +148,7 @@ export const companiesEnglish = {
"Without an end date, the assignment remains open. To record a new job period, close this assignment and create the next one; both may use the same day. Edits retain history.",
history: "History",
assignmentHistory: "Assignment history",
createDepartmentFirst: "Create or activate a department before assigning it.",
createDepartmentFirst: "You can add a department from the assignment form.",
yes: "Yes",
no: "No",

View File

@ -11,7 +11,8 @@
},
"scripts": {
"ui:add": "shadcn add",
"typecheck": "tsc -p tsconfig.json"
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",

View File

@ -0,0 +1,77 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { Separator } from "@repo/shadcn-ui/components/separator";
import { type VariantProps, cva } from "class-variance-authority";
import { cn } from "cn";
import type { ComponentProps } from "react";
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
);
function ButtonGroup({
className,
orientation,
...props
}: ComponentProps<"fieldset"> & VariantProps<typeof buttonGroupVariants>) {
return (
<fieldset
className={cn(buttonGroupVariants({ orientation }), className)}
data-orientation={orientation}
data-slot="button-group"
{...props}
/>
);
}
function ButtonGroupText({ className, render, ...props }: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex items-center gap-2 rounded-md border bg-muted px-2.5 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
),
},
props
),
render,
state: {
slot: "button-group-text",
},
});
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: ComponentProps<typeof Separator>) {
return (
<Separator
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
data-slot="button-group-separator"
orientation={orientation}
{...props}
/>
);
}
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };

View File

@ -0,0 +1,86 @@
import { PlusIcon } from "lucide-react";
import { Button } from "./button";
import { ButtonGroup } from "./button-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
export type SelectOption = { value: string; label: string; disabled?: boolean };
export type SelectAction = { label: string; onClick: () => void; disabled?: boolean };
/** Selección controlada con una acción adyacente; el consumidor gestiona el alta y su contexto. */
export function SelectWithAction({
id,
label,
options,
placeholder,
value,
onValueChange,
disabled = false,
action,
required = false,
invalid = false,
describedBy,
}: {
id: string;
label: string;
options: readonly SelectOption[];
placeholder: string;
value: string;
onValueChange: (value: string) => void;
disabled?: boolean;
action?: SelectAction | undefined;
required?: boolean;
invalid?: boolean;
describedBy?: string | undefined;
}) {
const select = (
<Select
disabled={disabled}
items={[{ value: "", label: placeholder }, ...options]}
onValueChange={(selected) => {
if (typeof selected === "string") {
onValueChange(selected);
}
}}
required={required}
value={value}
>
<SelectTrigger
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
aria-label={label}
className="w-full min-w-0 flex-1"
id={id}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{placeholder}</SelectItem>
{options.map((option) => (
<SelectItem disabled={option.disabled} key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
if (!action) {
return select;
}
return (
<ButtonGroup aria-label={label} className="w-full min-w-0">
{select}
<Button
aria-label={action.label}
disabled={disabled || action.disabled}
onClick={action.onClick}
size="icon"
title={action.label}
type="button"
variant="outline"
>
<PlusIcon aria-hidden="true" />
</Button>
</ButtonGroup>
);
}

View File

@ -0,0 +1,18 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "cn";
function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
data-slot="separator"
orientation={orientation}
{...props}
/>
);
}
export { Separator };

View File

@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { SelectWithAction } from "./components/select-with-action";
test("el selector agrupa la acción etiquetada sin convertirla en envío de formulario", () => {
const markup = renderToStaticMarkup(
<SelectWithAction
action={{ label: "Añadir sistema", onClick: () => undefined }}
id="system"
label="Sistema"
onValueChange={() => undefined}
options={[{ value: "domain", label: "Dominio" }]}
placeholder="Selecciona"
value=""
/>
);
assert.ok(markup.includes('data-slot="button-group"'));
assert.ok(markup.includes('aria-label="Añadir sistema"'));
const button = markup.match(/<button[^>]*aria-label="Añadir sistema"[^>]*>/)?.[0];
assert.ok(button);
assert.ok(button.includes('type="button"'));
});
test("el selector deshabilitado protege ambas acciones y admite selección sin botón", () => {
const markup = renderToStaticMarkup(
<SelectWithAction
action={{ label: "Añadir sistema", onClick: () => undefined }}
disabled
id="system"
label="Sistema"
onValueChange={() => undefined}
options={[]}
placeholder="Selecciona"
value=""
/>
);
const button = markup.match(/<button[^>]*aria-label="Añadir sistema"[^>]*>/)?.[0];
assert.ok(button?.includes("disabled"));
const plain = renderToStaticMarkup(
<SelectWithAction
id="usage"
label="Uso"
onValueChange={() => undefined}
options={[]}
placeholder="Selecciona"
value=""
/>
);
assert.equal(plain.includes('data-slot="button-group"'), false);
});

View File

@ -2,7 +2,8 @@
"extends": "@repo/typescript-config/buildless.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node"]
},
"include": ["src"]
}