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:
parent
8386679181
commit
5adbe81364
@ -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.
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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.",
|
||||
|
||||
151
modules/companies/src/web/access/quick-create-dialog.tsx
Normal file
151
modules/companies/src/web/access/quick-create-dialog.tsx
Normal 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;
|
||||
}
|
||||
120
modules/companies/src/web/access/quick-create-plan.ts
Normal file
120
modules/companies/src/web/access/quick-create-plan.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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",
|
||||
|
||||
|
||||
@ -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:*",
|
||||
|
||||
77
packages/shadcn-ui/src/components/button-group.tsx
Normal file
77
packages/shadcn-ui/src/components/button-group.tsx
Normal 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 };
|
||||
86
packages/shadcn-ui/src/components/select-with-action.tsx
Normal file
86
packages/shadcn-ui/src/components/select-with-action.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
packages/shadcn-ui/src/components/separator.tsx
Normal file
18
packages/shadcn-ui/src/components/separator.tsx
Normal 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 };
|
||||
52
packages/shadcn-ui/src/select-with-action.test.tsx
Normal file
52
packages/shadcn-ui/src/select-with-action.test.tsx
Normal 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);
|
||||
});
|
||||
@ -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"]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user