feat: add template filtering and multi-step resource creation process
- Extend RouteQuerySchema to include optional template UUID for asset filtering. - Update queryMatchesLocation to validate template presence based on route context. - Enhance workspace routes tests to cover template filtering scenarios and invalid cases. - Introduce new localization strings for resource creation steps in English and Spanish. - Refactor resource form controller to support a multi-step creation layout. - Implement resource creation progress component to guide users through the steps. - Create separate UI components for resource fields and identity fields to streamline form structure. - Add validation for required characteristics based on selected templates. - Update resource form actions to handle navigation between steps in the creation process.
This commit is contained in:
parent
002781b56d
commit
2728692b98
@ -153,6 +153,8 @@ EntityTable recibe datos y columnas tipadas; utiliza TanStack Table para búsque
|
||||
|
||||
La paginación es local sobre los conjuntos que las API actuales ya devuelven. No simula paginación de servidor. La selección del activo para el panel sigue perteneciendo a Inventario, fuera de la tabla compartida.
|
||||
|
||||
El inventario de activos compone las primitivas Tabs de shadcn/ui con EntityTable para filtrar por plantilla. La barra admite desplazamiento horizontal y navegación por teclado; incluye «Todo» y las plantillas del catálogo de la compañía. Un único panel conserva la tabla, su ordenación y sus columnas al cambiar de pestaña. `filterKey` reinicia la página; `filtersApplied` y `onClearFilters` integran el filtro con los estados vacíos y la limpieza común del listado.
|
||||
|
||||
EntityTable separa internamente la configuración de columnas/ordenación, el contenido semántico y la paginación; useEntityTable concentra el estado de representación. entityLabel permite contar «plantillas», «campos» o «tickets» aunque el título del espacio sea más amplio. Los tamaños disponibles son 10, 25, 50 y 100; una opción vacía nunca cambia el tamaño a cero.
|
||||
|
||||
Se declara @tanstack/react-table en el paquete compartido porque es quien consume ahora la librería; se reutiliza la versión 8.21.3 que ya utilizaba Compañías. No se introduce un segundo sistema de tablas.
|
||||
@ -388,3 +390,35 @@ Los perfiles se deducen por los activos del puesto, incluidos los compartidos; n
|
||||
## Gestión de usuarios
|
||||
|
||||
`/settings/users` muestra el listado de usuarios como pantalla dentro del espacio global, con acceso directo desde la navegación para el administrador inicial. Identity aporta el contenido administrativo al shell mediante composición; Companies no importa el módulo Identity. Se reutiliza `UserManagement`, su API y los formularios modales existentes para crear, editar y restablecer contraseñas. La activación/desactivación se mantiene en la edición; las personas de las compañías son independientes de estas cuentas. El administrador inicial sigue protegido y la autorización del backend permanece obligatoria.
|
||||
|
||||
## Alta de recursos por pasos
|
||||
|
||||
El diálogo «Nuevo recurso» reutiliza `FormDialog`, `FormBody`, `FormActions`, `FormSection` y
|
||||
los controles actuales. Su indicador muestra los tres pasos, identifica el actual mediante
|
||||
`aria-current="step"` y anuncia el avance con un texto traducido al que se lleva el foco.
|
||||
Las acciones conservan Cancelar y añaden Anterior/Siguiente; Guardar aparece en el último
|
||||
paso. La ayuda permanece al final del cuerpo, inmediatamente antes del pie.
|
||||
|
||||
`ResourceIdentityFields` y `ResourceFields` comparten identificación, integración y
|
||||
características entre alta y edición. `ResourceFormActions` encapsula las acciones de ambos
|
||||
modos. Estos componentes pertenecen a resources porque conocen sus contratos y textos; no
|
||||
se añade una primitiva genérica de wizard sin otros consumidores. Véase
|
||||
[el comportamiento del alta](recursos-campos-y-plantillas.md#alta-de-recursos-en-tres-pasos).
|
||||
|
||||
|
||||
### Presentación compacta del alta por pasos
|
||||
|
||||
El alta de recursos usa `FormDialog size="medium"` para limitar el ancho a 768 px;
|
||||
la edición completa conserva el tamaño grande. La cabecera aporta un icono decorativo de
|
||||
recurso. El indicador numera los pasos, distingue el actual y marca los completados con
|
||||
un símbolo y texto accesible; las etiquetas permanecen visibles también en móvil.
|
||||
Cada paso incluye una instrucción breve y, tras elegir la plantilla, muestra su nombre.
|
||||
|
||||
Los formularios de recursos reutilizan el patrón compacto de activos:
|
||||
`FormSection variant="plain"`, títulos pequeños en mayúsculas y color
|
||||
`muted-foreground`, `FieldSections layout="form"` para las características y
|
||||
`ContextHelp variant="plain"` al final. Se evitan tarjetas anidadas y títulos grandes
|
||||
entre controles. Las columnas responden al ancho del contenedor; nombre y estado
|
||||
comparten fila cuando hay espacio. Se conservan los márgenes del anillo de foco de
|
||||
`FormBody`, la ayuda de campo, el desplazamiento del cuerpo y las acciones fijas.
|
||||
Se emplean los tokens semánticos del tema, sin estilos ni cambios en vendor.
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
|
||||
La ficha de compañía incorpora la sección Activos. El listado permite buscar por nombre, plantilla, estado o sede. Nuevo activo abre un modal; Editar y Ficha e historial son acciones explícitas. No se muestran formularios junto al listado.
|
||||
|
||||
El listado ofrece una pestaña «Todo» y una por cada plantilla global o propia disponible en el catálogo de la compañía, incluidas las inactivas y las que todavía no tienen activos. Las pestañas filtran por identidad de plantilla y se combinan con la búsqueda; cambiar de pestaña vuelve a la primera página sin perder la ordenación ni las columnas configuradas. La URL conserva el filtro en `template` y la búsqueda en `q`. Limpiar filtros recupera «Todo» y vacía la búsqueda. Si una plantilla deja de estar disponible, el listado vuelve a «Todo».
|
||||
|
||||
Un activo representa un elemento concreto, identificado mediante UUID v7. Su nombre sirve para reconocerlo (por ejemplo PORT-ANA-01), pero no sustituye al UUID y puede repetirse. La compañía y la plantilla son obligatorias. Sede, notas y características son opcionales salvo los campos marcados obligatorios en la plantilla. La persona se relaciona mediante un puesto de trabajo o un perfil de usuario.
|
||||
|
||||
Estados técnicos manuales: Operativo, Averiado, En mantenimiento y Retirado. Un activo operativo se muestra Disponible si ningún puesto activo lo utiliza, o En uso si figura como exclusivo o compartido en uno o varios puestos activos. La asignación se calcula desde la composición, independientemente del responsable. Los puestos inactivos y las revisiones históricas no cuentan como uso vigente. Para retirarlo se exige desvincularlo primero de todos los puestos activos, conservando sede e historial. La eliminación definitiva existente sigue protegida por referencias; no hay transferencia entre compañías.
|
||||
|
||||
@ -99,3 +99,22 @@ Las plantillas permiten organizar campos en secciones ordenadas, globales o prop
|
||||
El catálogo, las especificaciones congeladas y los valores actuales e históricos de recursos se almacenan en columnas y tablas relacionadas. Véase [normalización de la persistencia](normalizacion-de-la-persistencia.md).
|
||||
|
||||
El arranque añade únicamente las definiciones ausentes y no sobrescribe las plantillas existentes. Las ediciones conservan UUID y clave de serie; el uso especializado de la plantilla permanece fijo. Los recursos ya creados conservan su especificación capturada, incluso si posteriormente se retiran campos de la plantilla.
|
||||
|
||||
## Alta de recursos en tres pasos
|
||||
|
||||
El diálogo «Nuevo recurso» del listado utiliza un único formulario React Hook Form repartido
|
||||
entre tres pasos: elegir una plantilla activa, completar sus características e integración y
|
||||
revisar el nombre, el estado y las notas opcionales. «Siguiente» valida el paso actual;
|
||||
«Anterior» conserva los datos. El recurso se guarda únicamente desde el último paso.
|
||||
Cambiar a otra plantilla descarta las características y la integración anteriores para no
|
||||
mezclar contratos; elegir de nuevo la misma plantilla conserva sus datos.
|
||||
|
||||
Al entrar en el último paso se propone un nombre de hasta 100 caracteres. Se prioriza la
|
||||
dirección de correo, ruta UNC, producto de software, referencia, titular, URL o proveedor,
|
||||
siempre que el campo pertenezca a la plantilla. Para plantillas propias se utiliza el primer
|
||||
campo de texto informado y, si no hay ninguno, el nombre de la plantilla. Una vez editado
|
||||
por el usuario, el nombre se conserva al retroceder y avanzar. Esta propuesta no rellena las
|
||||
características ni las notas.
|
||||
|
||||
La edición y las altas especializadas integradas en otros formularios mantienen su
|
||||
presentación completa. No cambia el contrato HTTP, la persistencia ni la validación de dominio.
|
||||
|
||||
@ -102,7 +102,8 @@ La consulta conserva los filtros que ya están conectados a la URL:
|
||||
- Dashboard y directorio: `companyId`; directorio también `activeOnly` y `q`.
|
||||
- Tickets: `companyId`, `pendingOnly`, `q`, `state`, `priority` y `assignee`.
|
||||
- Intervenciones: `companyId`, `pendingOnly`, `q`, `state`, `assignee`, `dateFrom` y `dateTo`.
|
||||
- Activos y recursos: `q`.
|
||||
- Activos: `q` y `template` (UUID de plantilla), tanto en el listado como en la ficha seleccionada.
|
||||
- Recursos: `q`.
|
||||
- Perfiles y procesos: `q`, con `archived` o `closed`, respectivamente.
|
||||
- Catálogos de activos y recursos: `q` y `tab`; `relations` solo en activos.
|
||||
- Cuentas y accesos: `q` y `kind`, respetando los tipos disponibles en cada ámbito.
|
||||
|
||||
@ -86,6 +86,8 @@
|
||||
"closeAssetPanel": "Close details panel",
|
||||
"showAssetPanel": "Show details panel",
|
||||
"inventory": "Assets",
|
||||
"allAssets": "All",
|
||||
"filterByTemplate": "Filter assets by template",
|
||||
"chooseValue": "Choose an option",
|
||||
"relatedNotice": "This item is saved separately and remains available if you later cancel the main form.",
|
||||
"newAsset": "New asset",
|
||||
|
||||
@ -86,6 +86,8 @@
|
||||
"closeAssetPanel": "Cerrar ficha lateral",
|
||||
"showAssetPanel": "Mostrar ficha lateral",
|
||||
"inventory": "Activos",
|
||||
"allAssets": "Todo",
|
||||
"filterByTemplate": "Filtrar activos por plantilla",
|
||||
"chooseValue": "Selecciona una opción",
|
||||
"relatedNotice": "El elemento se guarda por separado y se conserva aunque después canceles el formulario principal.",
|
||||
"newAsset": "Nuevo activo",
|
||||
|
||||
@ -29,6 +29,26 @@ export function useAssetInventoryPageController({
|
||||
});
|
||||
const { searchParams, setSearchParams } = useBrowserSearchParams();
|
||||
const search = searchParams.get("q") ?? "";
|
||||
const templates = (catalog.data?.items ?? []).filter((item) => item.values.kind === "template");
|
||||
const requestedTemplateId = searchParams.get("template");
|
||||
const templateId = templates.some((item) => item.id === requestedTemplateId)
|
||||
? requestedTemplateId
|
||||
: null;
|
||||
|
||||
const setTemplateId = (value: string) =>
|
||||
setSearchParams((params) => {
|
||||
if (value === "all") {
|
||||
params.delete("template");
|
||||
} else {
|
||||
params.set("template", value);
|
||||
}
|
||||
});
|
||||
|
||||
const clearFilters = () =>
|
||||
setSearchParams((params) => {
|
||||
params.delete("q");
|
||||
params.delete("template");
|
||||
});
|
||||
|
||||
const setSearch = (value: string) =>
|
||||
setSearchParams((params) => {
|
||||
@ -65,6 +85,9 @@ export function useAssetInventoryPageController({
|
||||
const viewed = assets.find((asset) => asset.id === viewId);
|
||||
const deleted = assets.find((asset) => asset.id === deleteId);
|
||||
const filtered = assets.filter((asset) => {
|
||||
if (templateId && asset.values.templateId !== templateId) {
|
||||
return false;
|
||||
}
|
||||
const site = lookups.sites.find((site) => site.id === asset.values.siteId);
|
||||
|
||||
return [
|
||||
@ -80,6 +103,10 @@ export function useAssetInventoryPageController({
|
||||
entityHref,
|
||||
missingView: Boolean(selectedId && query.isSuccess && !selected),
|
||||
catalog,
|
||||
templates,
|
||||
templateId,
|
||||
setTemplateId,
|
||||
clearFilters,
|
||||
search,
|
||||
setSearch,
|
||||
editor,
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/shadcn-ui/components/tabs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CatalogError } from "../../../catalog";
|
||||
@ -28,6 +29,10 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
||||
const {
|
||||
query,
|
||||
catalog,
|
||||
templates,
|
||||
templateId,
|
||||
setTemplateId,
|
||||
clearFilters,
|
||||
search,
|
||||
setSearch,
|
||||
editor,
|
||||
@ -98,105 +103,127 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
||||
</Button>
|
||||
)}
|
||||
{query.data && (
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (asset) => asset.values.name,
|
||||
hideable: false,
|
||||
render: (asset) => (
|
||||
<BrowserLink
|
||||
aria-pressed={selected?.id === asset.id && detailOpen}
|
||||
className="cursor-pointer text-left font-medium [overflow-wrap:anywhere] app-link text-primary hover:text-primary focus-visible:rounded-[4px] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring"
|
||||
href={entityHref(asset.id)}
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
{asset.values.name}
|
||||
</BrowserLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "template",
|
||||
label: t("template"),
|
||||
value: (asset) => asset.specification.templateName,
|
||||
render: (asset) => {
|
||||
const template = catalog.data?.items.find(
|
||||
(item) => item.id === asset.values.templateId
|
||||
);
|
||||
<Tabs onValueChange={setTemplateId} value={templateId ?? "all"}>
|
||||
<div className="min-w-0 overflow-x-auto">
|
||||
<TabsList aria-label={t("filterByTemplate")} variant="line">
|
||||
<TabsTrigger value="all">{t("allAssets")}</TabsTrigger>
|
||||
{templates.map((template) => (
|
||||
<TabsTrigger key={template.id} value={template.id}>
|
||||
{template.values.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent keepMounted value={templateId ?? "all"}>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (asset) => asset.values.name,
|
||||
hideable: false,
|
||||
render: (asset) => (
|
||||
<BrowserLink
|
||||
aria-pressed={selected?.id === asset.id && detailOpen}
|
||||
className="cursor-pointer text-left font-medium [overflow-wrap:anywhere] app-link text-primary hover:text-primary focus-visible:rounded-[4px] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring"
|
||||
href={entityHref(asset.id)}
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
{asset.values.name}
|
||||
</BrowserLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "template",
|
||||
label: t("template"),
|
||||
value: (asset) => asset.specification.templateName,
|
||||
render: (asset) => {
|
||||
const template = catalog.data?.items.find(
|
||||
(item) => item.id === asset.values.templateId
|
||||
);
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<AssetTemplateIcon
|
||||
icon={
|
||||
template?.values.kind === "template" ? template.values.icon : undefined
|
||||
}
|
||||
/>
|
||||
{asset.specification.templateName}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (asset) => t(asset.displayStatus),
|
||||
render: (asset) => (
|
||||
<EntityStatus active={asset.values.status === "operational"}>
|
||||
{t(asset.displayStatus)}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "site",
|
||||
label: t("site"),
|
||||
value: (asset) =>
|
||||
lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
|
||||
t("noAssignment"),
|
||||
render: (asset) => (
|
||||
<>
|
||||
{lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
|
||||
t("noAssignment")}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (asset) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction
|
||||
className="inline-flex inventory:hidden"
|
||||
onClick={() => setViewId(asset.id)}
|
||||
>
|
||||
{t("viewAsset")}
|
||||
</EntityAction>
|
||||
<EntityAction disabled={!catalog.data} onClick={() => setEditor(asset.id)}>
|
||||
{t("edit")}
|
||||
</EntityAction>
|
||||
<EntityAction
|
||||
className="text-destructive data-highlighted:text-destructive"
|
||||
onClick={() => setDeleteId(asset.id)}
|
||||
>
|
||||
{t("deleteAsset")}
|
||||
</EntityAction>
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("noAssets")}
|
||||
items={filtered}
|
||||
locale={i18n.language}
|
||||
onSearchChange={setSearch}
|
||||
rowClassName={(asset) => selectedRowClass(asset.id, selected?.id, detailOpen)}
|
||||
search={search}
|
||||
searchPlaceholder={t("search")}
|
||||
title={t("inventory")}
|
||||
/>
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<AssetTemplateIcon
|
||||
icon={
|
||||
template?.values.kind === "template"
|
||||
? template.values.icon
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{asset.specification.templateName}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (asset) => t(asset.displayStatus),
|
||||
render: (asset) => (
|
||||
<EntityStatus active={asset.values.status === "operational"}>
|
||||
{t(asset.displayStatus)}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "site",
|
||||
label: t("site"),
|
||||
value: (asset) =>
|
||||
lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
|
||||
t("noAssignment"),
|
||||
render: (asset) => (
|
||||
<>
|
||||
{lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
|
||||
t("noAssignment")}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (asset) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction
|
||||
className="inline-flex inventory:hidden"
|
||||
onClick={() => setViewId(asset.id)}
|
||||
>
|
||||
{t("viewAsset")}
|
||||
</EntityAction>
|
||||
<EntityAction
|
||||
disabled={!catalog.data}
|
||||
onClick={() => setEditor(asset.id)}
|
||||
>
|
||||
{t("edit")}
|
||||
</EntityAction>
|
||||
<EntityAction
|
||||
className="text-destructive data-highlighted:text-destructive"
|
||||
onClick={() => setDeleteId(asset.id)}
|
||||
>
|
||||
{t("deleteAsset")}
|
||||
</EntityAction>
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("noAssets")}
|
||||
filterKey={templateId ?? "all"}
|
||||
filtersApplied={templateId !== null}
|
||||
items={filtered}
|
||||
locale={i18n.language}
|
||||
onClearFilters={clearFilters}
|
||||
onSearchChange={setSearch}
|
||||
rowClassName={(asset) => selectedRowClass(asset.id, selected?.id, detailOpen)}
|
||||
search={search}
|
||||
searchPlaceholder={t("search")}
|
||||
title={t("inventory")}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
<AssetSelectionPanel
|
||||
|
||||
@ -12,7 +12,12 @@ import { I18nextProvider } from "react-i18next";
|
||||
import { buildCatalogResponse } from "../../../api/application/asset-catalog-response.builder";
|
||||
import { AssetCatalog } from "../../../api/domain/asset-catalog.aggregate";
|
||||
import { seedAssetCatalog } from "../../../api/domain/asset-catalog-seed";
|
||||
import type { RelationshipDTO, WorkstationDTO } from "../../../common";
|
||||
import type {
|
||||
AssetDTO,
|
||||
CatalogResponseDTO,
|
||||
RelationshipDTO,
|
||||
WorkstationDTO,
|
||||
} from "../../../common";
|
||||
import { AssetSchema } from "../../../common/inventory";
|
||||
import { WorkstationSchema } from "../../../common/workstations";
|
||||
import { AssetRelationships } from "../../asset-relationships";
|
||||
@ -40,7 +45,8 @@ async function render(
|
||||
language: string,
|
||||
node: ReactNode,
|
||||
relationships: RelationshipDTO[] = [],
|
||||
workstations: WorkstationDTO[] = []
|
||||
workstations: WorkstationDTO[] = [],
|
||||
inventory: { assets: AssetDTO[]; catalog: CatalogResponseDTO } | undefined = undefined
|
||||
) {
|
||||
const i18n = createInstance();
|
||||
|
||||
@ -51,13 +57,14 @@ async function render(
|
||||
const client = new QueryClient();
|
||||
|
||||
client.setQueryData(inventoryKeys.company(companyId), {
|
||||
assets: [],
|
||||
assets: inventory?.assets ?? [],
|
||||
relationships,
|
||||
workstations,
|
||||
});
|
||||
client.setQueryData(
|
||||
catalogKeys.scope(companyId),
|
||||
buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none())
|
||||
inventory?.catalog ??
|
||||
buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none())
|
||||
);
|
||||
try {
|
||||
return renderToStaticMarkup(
|
||||
@ -78,6 +85,8 @@ test("Inventario traducido con alta de cabecera y sin formulario junto al listad
|
||||
);
|
||||
|
||||
assert.equal(html.includes("<form"), false);
|
||||
assert.ok(html.includes('role="tablist"'));
|
||||
assert.ok(html.includes(language === "es" ? ">Todo<" : ">All<"));
|
||||
assert.ok(html.includes(language === "es" ? "Nuevo activo" : "New asset"));
|
||||
assert.ok(
|
||||
html.includes(
|
||||
@ -88,6 +97,103 @@ test("Inventario traducido con alta de cabecera y sin formulario junto al listad
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Las pestañas incluyen plantillas vacías e inactivas y combinan identidad y búsqueda", async () => {
|
||||
const catalog = structuredClone(
|
||||
buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none())
|
||||
);
|
||||
const templates = catalog.items.filter((item) => item.values.kind === "template");
|
||||
const [first, second, empty] = templates;
|
||||
|
||||
assert.ok(first && second && empty);
|
||||
// Dos nombres iguales siguen representando filtros distintos por identidad.
|
||||
second.values.name = first.values.name;
|
||||
empty.active = false;
|
||||
empty.effectiveActive = false;
|
||||
const assets = [first, second].flatMap((template, index) =>
|
||||
["Alpha", "Beta"].map((name) => {
|
||||
const values = {
|
||||
name: `${name}-${index}`,
|
||||
templateId: template.id,
|
||||
siteId: null,
|
||||
status: "operational",
|
||||
notes: "",
|
||||
characteristics: {},
|
||||
};
|
||||
|
||||
return AssetSchema.parse({
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
companyId,
|
||||
specification: {
|
||||
templateId: template.id,
|
||||
templateName: template.values.name,
|
||||
fields: [
|
||||
{
|
||||
fieldId: UniqueID.generateNewID().toPrimitive(),
|
||||
name: "Número de serie",
|
||||
type: "text",
|
||||
unit: "",
|
||||
choices: [],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
values,
|
||||
displayStatus: "available",
|
||||
assigned: false,
|
||||
history: [
|
||||
{
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
values,
|
||||
actorId: UniqueID.generateNewID().toPrimitive(),
|
||||
actorName: "Técnico",
|
||||
recordedAt: "2026-10-14T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
|
||||
try {
|
||||
for (const [search, expected] of [
|
||||
["", ["Alpha-0", "Beta-0", "Alpha-1", "Beta-1"]],
|
||||
[`?template=${first.id}`, ["Alpha-0", "Beta-0"]],
|
||||
[`?template=${second.id}&q=Alpha`, ["Alpha-1"]],
|
||||
[`?template=${empty.id}`, []],
|
||||
["?template=missing", ["Alpha-0", "Beta-0", "Alpha-1", "Beta-1"]],
|
||||
] satisfies [string, string[]][]) {
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { location: { pathname: `/companies/${companyId}/assets`, search } },
|
||||
});
|
||||
const html = await render(
|
||||
"es",
|
||||
<AssetInventoryPage companyId={companyId} lookups={lookups} />,
|
||||
[],
|
||||
[],
|
||||
{ assets, catalog }
|
||||
);
|
||||
const table = html.match(/<table[\s\S]*?<\/table>/)?.[0];
|
||||
|
||||
assert.ok(table);
|
||||
for (const asset of assets) {
|
||||
assert.equal(table.includes(asset.values.name), expected.includes(asset.values.name));
|
||||
}
|
||||
assert.equal((html.match(/role="tab"/g) ?? []).length, templates.length + 1);
|
||||
assert.ok(html.includes(empty.values.name));
|
||||
if (expected.length === 0) {
|
||||
assert.ok(html.includes("No hay resultados para estos criterios."));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, "window", descriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, "window");
|
||||
}
|
||||
}
|
||||
});
|
||||
test("El activo ofrece altas relacionadas, ayuda inferior y no encadena una tercera alta", async () => {
|
||||
const html = await render(
|
||||
"es",
|
||||
|
||||
@ -69,6 +69,7 @@ const RouteQuerySchema = z.strictObject({
|
||||
closed: z.enum(["true", "false"]).optional(),
|
||||
contactsOnly: z.enum(["true", "false"]).optional(),
|
||||
q: z.string().max(200).optional(),
|
||||
template: z.uuid().optional(),
|
||||
tab: z.enum(["templates", "fields", "relations"]).optional(),
|
||||
kind: AccessKindSchema.optional(),
|
||||
state: z.union([TicketStateSchema, InterventionStateSchema]).optional(),
|
||||
@ -181,6 +182,9 @@ function queryMatchesLocation(
|
||||
if (location.area === "company" && query.companyId && query.companyId !== location.id) {
|
||||
return false;
|
||||
}
|
||||
if (query.template && !(location.area === "company" && location.section === "inventory")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
stateMatchesLocation(location, query.state) &&
|
||||
|
||||
@ -10,6 +10,46 @@ import {
|
||||
|
||||
const COMPANY_ID = "019b8838-44af-776d-a406-af74452b6f26";
|
||||
const ENTITY_ID = "019b8838-44af-776d-a406-af74452b6f27";
|
||||
const TEMPLATE_ID = "01a1276d-2138-711e-b3d2-99258a02a80f";
|
||||
|
||||
test("el filtro de plantilla permite abrir el listado y las fichas de activos", () => {
|
||||
for (const suffix of ["", `/${ENTITY_ID}`]) {
|
||||
assert.deepEqual(
|
||||
parseWorkspaceUrl(`/companies/${COMPANY_ID}/assets${suffix}?template=${TEMPLATE_ID}&q=Alpha`),
|
||||
{
|
||||
area: "company",
|
||||
id: COMPANY_ID,
|
||||
section: "inventory",
|
||||
...(suffix ? { entityId: ENTITY_ID } : {}),
|
||||
}
|
||||
);
|
||||
}
|
||||
assert.deepEqual(
|
||||
parseWorkspaceUrl(
|
||||
"/companies/01a1016a-289c-761d-9d11-bf10359c0b4e/assets/01a127a0-190b-70fc-bf70-4e41990367b8?template=01a1276d-2138-711e-b3d2-99258a02a80f"
|
||||
),
|
||||
{
|
||||
area: "company",
|
||||
id: "01a1016a-289c-761d-9d11-bf10359c0b4e",
|
||||
section: "inventory",
|
||||
entityId: "01a127a0-190b-70fc-bf70-4e41990367b8",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("el filtro de plantilla rechaza UUID inválidos, parámetros repetidos y otros ámbitos", () => {
|
||||
for (const href of [
|
||||
`/companies/${COMPANY_ID}/assets?template=invalid`,
|
||||
`/companies/${COMPANY_ID}/assets?template=`,
|
||||
`/companies/${COMPANY_ID}/assets?template=${TEMPLATE_ID}&template=${TEMPLATE_ID}`,
|
||||
`/companies/${COMPANY_ID}/resources?template=${TEMPLATE_ID}`,
|
||||
`/companies/${COMPANY_ID}/catalogs/assets?template=${TEMPLATE_ID}`,
|
||||
`/catalogs/assets?template=${TEMPLATE_ID}`,
|
||||
`/tickets?template=${TEMPLATE_ID}`,
|
||||
]) {
|
||||
assert.deepEqual(parseWorkspaceUrl(href), { area: "invalid-route" });
|
||||
}
|
||||
});
|
||||
|
||||
test("todas las secciones de compañía tienen una ruta propia y el índice es el resumen", () => {
|
||||
for (const section of [
|
||||
@ -124,6 +164,7 @@ test("las rutas desconocidas y los parámetros inválidos no abren una sección
|
||||
|
||||
test("la ficha de puesto tiene ruta propia, conserva el UUID y rechaza identificadores inválidos", () => {
|
||||
const href = `/companies/${COMPANY_ID}/workstations/${ENTITY_ID}`;
|
||||
|
||||
assert.deepEqual(parseWorkspaceUrl(href), {
|
||||
area: "company",
|
||||
id: COMPANY_ID,
|
||||
|
||||
@ -161,5 +161,19 @@
|
||||
"formLayout": {
|
||||
"identity": "Resource identification",
|
||||
"template": "Template details"
|
||||
},
|
||||
"creationSteps": {
|
||||
"label": "New resource steps",
|
||||
"progress": "Step {{step}} of {{total}}",
|
||||
"template": "Choose template",
|
||||
"fields": "Complete fields",
|
||||
"name": "Name and notes",
|
||||
"next": "Next",
|
||||
"previous": "Back",
|
||||
"nameHelp": "Name suggested from the resource fields. You can edit it.",
|
||||
"completed": " (completed)",
|
||||
"templateDescription": "Select the template that defines the resource fields and configuration.",
|
||||
"fieldsDescription": "Complete the template details. Fields marked with * are required.",
|
||||
"nameDescription": "Review the suggested name and add notes if you need to provide more context."
|
||||
}
|
||||
}
|
||||
|
||||
@ -161,5 +161,19 @@
|
||||
"formLayout": {
|
||||
"identity": "Identificación del recurso",
|
||||
"template": "Datos de la plantilla"
|
||||
},
|
||||
"creationSteps": {
|
||||
"label": "Pasos del nuevo recurso",
|
||||
"progress": "Paso {{step}} de {{total}}",
|
||||
"template": "Elegir plantilla",
|
||||
"fields": "Completar campos",
|
||||
"name": "Nombre y notas",
|
||||
"next": "Siguiente",
|
||||
"previous": "Anterior",
|
||||
"nameHelp": "Nombre propuesto a partir de los campos del recurso. Puedes modificarlo.",
|
||||
"completed": " (completado)",
|
||||
"templateDescription": "Selecciona la plantilla que define los campos y la configuración del recurso.",
|
||||
"fieldsDescription": "Completa los datos de la plantilla. Los campos marcados con * son obligatorios.",
|
||||
"nameDescription": "Revisa el nombre propuesto y añade notas si necesitas aportar más contexto."
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function ResourceCreateProgress({
|
||||
step,
|
||||
templateName,
|
||||
}: {
|
||||
step: 1 | 2 | 3;
|
||||
templateName?: string | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation("resources");
|
||||
const currentStep = STEP_KEYS[step - 1];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<ol
|
||||
aria-label={t("creationSteps.label")}
|
||||
className="grid grid-cols-3 gap-3 border-b border-border/60 pb-5"
|
||||
>
|
||||
{STEP_KEYS.map((key, index) => {
|
||||
const current = step === index + 1;
|
||||
const completed = step > index + 1;
|
||||
|
||||
return (
|
||||
<li
|
||||
aria-current={current ? "step" : undefined}
|
||||
className="flex min-w-0 flex-col items-start gap-2 sm:flex-row sm:items-center sm:gap-3"
|
||||
key={key}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`flex size-8 shrink-0 items-center justify-center rounded-full border text-sm font-semibold ${current ? "border-primary bg-primary text-primary-foreground" : completed ? "border-primary/30 bg-primary/10 text-primary" : "border-border bg-muted/50 text-muted-foreground"}`}
|
||||
>
|
||||
{completed ? "✓" : index + 1}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs leading-snug sm:text-sm ${current ? "font-semibold text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{t(`creationSteps.${key}`)}
|
||||
{completed && <span className="sr-only">{t("creationSteps.completed")}</span>}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
{t(`creationSteps.${currentStep}`)}
|
||||
</h2>
|
||||
<p
|
||||
aria-live="polite"
|
||||
className="text-xs text-muted-foreground focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-ring"
|
||||
key={step}
|
||||
ref={focusStep}
|
||||
role="status"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{t("creationSteps.progress", { step, total: 3 })}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{t(`creationSteps.${currentStep}Description`)}
|
||||
</p>
|
||||
{templateName && step > 1 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("template")}: <span className="font-medium text-foreground">{templateName}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STEP_KEYS = ["template", "fields", "name"] as const;
|
||||
|
||||
function focusStep(element: HTMLParagraphElement | null) {
|
||||
element?.focus();
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { FormEvent } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -8,6 +9,7 @@ import type { ResourceInput } from "../../../../common/inventory";
|
||||
import { ResourceInputSchema } from "../../../../common/inventory";
|
||||
import { inventoryApi, inventoryKeys } from "../api";
|
||||
import { mailboxIntegration, resourceDefaults, resourceFields } from "../helpers";
|
||||
import { hasMissingResourceFields, suggestedResourceName } from "../helpers/resource-form-values";
|
||||
import type { ResourceFormProps } from "../types";
|
||||
|
||||
export function useResourceFormController({
|
||||
@ -18,10 +20,13 @@ export function useResourceFormController({
|
||||
initialKind,
|
||||
initialAddress = "",
|
||||
initialIntegration,
|
||||
creationLayout = "single-page",
|
||||
}: ResourceFormProps) {
|
||||
const { t } = useTranslation("resources");
|
||||
const client = useQueryClient();
|
||||
const [creatingTemplate, setCreatingTemplate] = useState(false);
|
||||
const isWizard = !resource && creationLayout === "steps";
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||
const mounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
@ -100,9 +105,10 @@ export function useResourceFormController({
|
||||
characteristics[fieldId] = value;
|
||||
}
|
||||
form.setValue("characteristics", characteristics, { shouldDirty: true });
|
||||
form.clearErrors("characteristics");
|
||||
};
|
||||
|
||||
const submit = form.handleSubmit((input) => {
|
||||
const save = form.handleSubmit((input) => {
|
||||
if (template?.builtinKey === "template:access" && input.integration?.kind !== "access") {
|
||||
form.setError("integration", { message: t("inventoryErrors.REFERENCE_UNAVAILABLE") });
|
||||
|
||||
@ -116,7 +122,66 @@ export function useResourceFormController({
|
||||
mutation.mutate(integration ? { ...input, integration } : input);
|
||||
});
|
||||
|
||||
const validateFields = async () => {
|
||||
const current = form.getValues();
|
||||
|
||||
if (hasMissingResourceFields(fields, items, current, template)) {
|
||||
form.setError("characteristics", { message: t("inventoryErrors.REQUIRED_CHARACTERISTICS") });
|
||||
|
||||
return false;
|
||||
}
|
||||
if (template?.builtinKey === "template:access" && current.integration?.kind !== "access") {
|
||||
form.setError("integration", { message: t("inventoryErrors.REFERENCE_UNAVAILABLE") });
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return form.trigger(["characteristics", "integration"]);
|
||||
};
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
if (mutation.isPending) {
|
||||
event.preventDefault();
|
||||
|
||||
return;
|
||||
}
|
||||
if (!isWizard || step === 3) {
|
||||
await save(event);
|
||||
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (step === 1) {
|
||||
if (template && (await form.trigger("templateId"))) {
|
||||
setStep(2);
|
||||
} else {
|
||||
form.setError("templateId", { message: t("inventoryErrors.TEMPLATE_UNAVAILABLE") });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
if (!(await validateFields())) {
|
||||
return;
|
||||
}
|
||||
if (!form.formState.dirtyFields.name) {
|
||||
form.setValue(
|
||||
"name",
|
||||
suggestedResourceName(template, fields, items, form.getValues("characteristics"))
|
||||
);
|
||||
}
|
||||
setStep(3);
|
||||
};
|
||||
|
||||
const previousStep = () => {
|
||||
form.clearErrors();
|
||||
setStep((current) => (current === 3 ? 2 : 1));
|
||||
};
|
||||
|
||||
const selectTemplate = (templateId: string) => {
|
||||
if (templateId === form.getValues("templateId")) {
|
||||
return;
|
||||
}
|
||||
form.clearErrors();
|
||||
form.setValue("templateId", templateId);
|
||||
form.setValue("characteristics", {});
|
||||
form.setValue("integration", undefined);
|
||||
@ -129,12 +194,14 @@ export function useResourceFormController({
|
||||
};
|
||||
|
||||
const templateCreated = (id: string) => {
|
||||
form.setValue("templateId", id);
|
||||
form.setValue("characteristics", {});
|
||||
selectTemplate(id);
|
||||
setCreatingTemplate(false);
|
||||
};
|
||||
|
||||
return {
|
||||
isWizard,
|
||||
step,
|
||||
previousStep,
|
||||
creatingTemplate,
|
||||
setCreatingTemplate,
|
||||
referenceQuery,
|
||||
|
||||
@ -71,3 +71,56 @@ export function resourceDefaults(
|
||||
characteristics: addressField && initialAddress ? { [addressField.id]: initialAddress } : {},
|
||||
};
|
||||
}
|
||||
|
||||
export function hasMissingResourceFields(
|
||||
fields: ReturnType<typeof resourceFields>,
|
||||
items: readonly CatalogItemDTO[],
|
||||
values: ResourceInput,
|
||||
template: CatalogItemDTO | undefined
|
||||
) {
|
||||
return fields.some((field) => {
|
||||
const value = values.characteristics[field.fieldId];
|
||||
|
||||
return (
|
||||
requireMailboxAddress(field, items, values, template).required &&
|
||||
(value === undefined || (typeof value === "string" && !value.trim()))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Prioriza identificadores documentales; las plantillas propias usan su primer campo de texto. */
|
||||
export function suggestedResourceName(
|
||||
template: CatalogItemDTO | undefined,
|
||||
fields: ReturnType<typeof resourceFields>,
|
||||
items: readonly CatalogItemDTO[],
|
||||
characteristics: ResourceInput["characteristics"]
|
||||
) {
|
||||
const preferredKeys = [
|
||||
"field:email",
|
||||
"field:unc",
|
||||
"field:software_product",
|
||||
"field:reference",
|
||||
"field:holder",
|
||||
"field:url",
|
||||
"field:provider",
|
||||
];
|
||||
const preferredIds = preferredKeys.flatMap((key) => {
|
||||
const item = items.find((candidate) => candidate.builtinKey === key);
|
||||
|
||||
return item ? [item.id] : [];
|
||||
});
|
||||
const candidates = [
|
||||
...preferredIds,
|
||||
...fields.filter((field) => field.type === "text").map((field) => field.fieldId),
|
||||
];
|
||||
|
||||
for (const id of candidates) {
|
||||
const value = characteristics[id];
|
||||
|
||||
if (fields.some((field) => field.fieldId === id) && typeof value === "string" && value.trim()) {
|
||||
return value.trim().slice(0, 100);
|
||||
}
|
||||
}
|
||||
|
||||
return template?.values.name.slice(0, 100) ?? "";
|
||||
}
|
||||
|
||||
@ -1,7 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { Maybe } from "@repo/rdx-utils";
|
||||
|
||||
import { buildCatalogResponse } from "../../../api/application/resource-catalog-response.builder";
|
||||
import { ResourceCatalog } from "../../../api/domain/resource-catalog.aggregate";
|
||||
import { seedResourceCatalog } from "../../../api/domain/resource-catalog-seed";
|
||||
import type { ResourceCharacteristic } from "../../../common/inventory";
|
||||
|
||||
import { mailboxIntegration, resourceDefaults, resourceFields } from "./helpers";
|
||||
import { hasMissingResourceFields, suggestedResourceName } from "./helpers/resource-form-values";
|
||||
|
||||
const FIELD_ID = "e0420eb9-2aab-4368-96a0-2ce83dd23eb4";
|
||||
|
||||
@ -29,3 +37,113 @@ test("los valores iniciales toleran catálogo pendiente y limitan el nombre sin
|
||||
assert.deepEqual(resourceFields(undefined, undefined, []), []);
|
||||
assert.equal(name.length, 120);
|
||||
});
|
||||
|
||||
test("nombre propuesto: prioriza los identificadores de cada plantilla y limita su longitud", () => {
|
||||
const catalog = buildCatalogResponse(seedResourceCatalog(ResourceCatalog.create()), Maybe.none());
|
||||
|
||||
for (const [templateKey, fieldKey, input] of [
|
||||
["mailbox", "email", " soporte@empresa.es "],
|
||||
["shared_folder", "unc", "\\\\servidor\\contabilidad"],
|
||||
["software_license", "software_product", "Microsoft 365 Business Premium"],
|
||||
["certificate", "holder", "Empresa S. L."],
|
||||
["application", "url", "https://portal.empresa.es"],
|
||||
]) {
|
||||
const template = catalog.items.find((item) => item.builtinKey === `template:${templateKey}`);
|
||||
const field = catalog.items.find((item) => item.builtinKey === `field:${fieldKey}`);
|
||||
|
||||
assert.ok(template && field && input);
|
||||
const fields = resourceFields(undefined, template, catalog.items);
|
||||
|
||||
assert.equal(
|
||||
suggestedResourceName(template, fields, catalog.items, { [field.id]: input }),
|
||||
input.trim()
|
||||
);
|
||||
assert.equal(
|
||||
suggestedResourceName(template, fields, catalog.items, { [field.id]: "x".repeat(120) })
|
||||
.length,
|
||||
100
|
||||
);
|
||||
assert.equal(
|
||||
suggestedResourceName(template, fields, catalog.items, { [field.id]: " " }),
|
||||
template.values.name
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("nombre propuesto: contempla campos personalizados y descarta valores no identificativos", () => {
|
||||
const field: ResourceCharacteristic = {
|
||||
fieldId: FIELD_ID,
|
||||
name: "Identificador",
|
||||
type: "text",
|
||||
unit: "",
|
||||
choices: [],
|
||||
required: false,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
suggestedResourceName(undefined, [field], [], { [FIELD_ID]: " Recurso propio " }),
|
||||
"Recurso propio"
|
||||
);
|
||||
for (const value of [false, 0, " "]) {
|
||||
assert.equal(suggestedResourceName(undefined, [field], [], { [FIELD_ID]: value }), "");
|
||||
}
|
||||
assert.equal(suggestedResourceName(undefined, [], [], { [FIELD_ID]: "Campo ajeno" }), "");
|
||||
});
|
||||
|
||||
test("paso de campos: exige valores obligatorios y acepta cero y respuestas negativas", () => {
|
||||
const field: ResourceCharacteristic = {
|
||||
fieldId: FIELD_ID,
|
||||
name: "Dato obligatorio",
|
||||
type: "text",
|
||||
unit: "",
|
||||
choices: [],
|
||||
required: true,
|
||||
};
|
||||
const values = resourceDefaults([], undefined, undefined, "", undefined);
|
||||
|
||||
for (const characteristics of [{}, { [FIELD_ID]: "" }, { [FIELD_ID]: " " }]) {
|
||||
assert.equal(
|
||||
hasMissingResourceFields([field], [], { ...values, characteristics }, undefined),
|
||||
true
|
||||
);
|
||||
}
|
||||
for (const value of [0, false, "Dato informado"]) {
|
||||
assert.equal(
|
||||
hasMissingResourceFields(
|
||||
[field],
|
||||
[],
|
||||
{ ...values, characteristics: { [FIELD_ID]: value } },
|
||||
undefined
|
||||
),
|
||||
false
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
hasMissingResourceFields([{ ...field, required: false }], [], values, undefined),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("paso de campos: requiere la dirección de buzón aunque la plantilla la marque opcional", () => {
|
||||
const catalog = buildCatalogResponse(seedResourceCatalog(ResourceCatalog.create()), Maybe.none());
|
||||
const template = catalog.items.find((item) => item.builtinKey === "template:mailbox");
|
||||
const address = catalog.items.find((item) => item.builtinKey === "field:email");
|
||||
|
||||
assert.ok(template && address);
|
||||
const fields = resourceFields(undefined, template, catalog.items).map((field) => ({
|
||||
...field,
|
||||
required: false,
|
||||
}));
|
||||
const values = resourceDefaults(catalog.items, undefined, "mailbox", "", undefined);
|
||||
|
||||
assert.equal(hasMissingResourceFields(fields, catalog.items, values, template), true);
|
||||
assert.equal(
|
||||
hasMissingResourceFields(
|
||||
fields,
|
||||
catalog.items,
|
||||
{ ...values, characteristics: { [address.id]: "soporte@empresa.es" } },
|
||||
template
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
@ -14,6 +14,7 @@ import { ResourceCatalog } from "../../../api/domain/resource-catalog.aggregate"
|
||||
import { seedResourceCatalog } from "../../../api/domain/resource-catalog-seed";
|
||||
import { catalogKeys } from "../../catalog/shared/api";
|
||||
import { resourcesEnglish, resourcesSpanish } from "../../shared";
|
||||
import { ResourceCreateProgress } from "../create/ui/resource-create-progress";
|
||||
import { ResourcesPage } from "../list";
|
||||
|
||||
import { inventoryKeys } from "./api";
|
||||
@ -124,6 +125,7 @@ test("licencia: formulario reutilizable con campos opcionales y ayuda antes de g
|
||||
language,
|
||||
<ResourceForm
|
||||
companyId={companyId}
|
||||
creationLayout="steps"
|
||||
items={catalog.items}
|
||||
onSaved={() => undefined}
|
||||
resource={resource}
|
||||
@ -163,3 +165,58 @@ test("asistencia: alta de recurso con plantilla específica y características o
|
||||
assert.ok(html.includes("URL"));
|
||||
assert.equal(html.includes('aria-label="Nueva plantilla"'), false);
|
||||
});
|
||||
|
||||
test("alta por pasos: solo muestra la plantilla inicialmente y traduce su navegación", async () => {
|
||||
const catalog = buildCatalogResponse(seedResourceCatalog(ResourceCatalog.create()), Maybe.none());
|
||||
|
||||
for (const [language, progress, next] of [
|
||||
["es", "Paso 1 de 3", "Siguiente"],
|
||||
["en", "Step 1 of 3", "Next"],
|
||||
]) {
|
||||
assert.ok(language && progress && next);
|
||||
const html = await render(
|
||||
language,
|
||||
<ResourceForm
|
||||
companyId={companyId}
|
||||
creationLayout="steps"
|
||||
items={catalog.items}
|
||||
onSaved={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
assert.ok(html.includes(progress));
|
||||
assert.ok(html.includes('aria-current="step"'));
|
||||
assert.ok(html.includes(next));
|
||||
assert.equal(html.includes('name="name"'), false);
|
||||
assert.equal(html.includes('name="notes"'), false);
|
||||
assert.equal(html.includes("Cantidad contratada"), false);
|
||||
assert.match(html, /<button(?=[^>]*type="submit")(?=[^>]*disabled="")[^>]*>/);
|
||||
assert.ok(html.indexOf("<aside") < html.indexOf('type="submit"'));
|
||||
}
|
||||
});
|
||||
|
||||
test("indicador de alta: conserva la plantilla elegida y explica el paso actual en ambos idiomas", async () => {
|
||||
for (const [language, completed, fieldsDescription, nameDescription] of [
|
||||
[
|
||||
"es",
|
||||
"(completado)",
|
||||
"Los campos marcados con * son obligatorios.",
|
||||
"Revisa el nombre propuesto",
|
||||
],
|
||||
["en", "(completed)", "Fields marked with * are required.", "Review the suggested name"],
|
||||
]) {
|
||||
assert.ok(language && completed && fieldsDescription && nameDescription);
|
||||
for (const step of [2, 3] as const) {
|
||||
const html = await render(
|
||||
language,
|
||||
<ResourceCreateProgress step={step} templateName="Buzón de correo" />
|
||||
);
|
||||
|
||||
assert.ok(html.includes("Buzón de correo"));
|
||||
assert.ok(html.includes(completed));
|
||||
assert.ok(html.includes(step === 2 ? fieldsDescription : nameDescription));
|
||||
assert.equal((html.match(/aria-current="step"/g) ?? []).length, 1);
|
||||
assert.equal(html.includes("creationSteps."), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -7,6 +7,7 @@ export type ResourceFormProps = {
|
||||
items: readonly CatalogItemDTO[];
|
||||
onSaved: (id: string) => void;
|
||||
allowQuickCreate?: boolean;
|
||||
creationLayout?: "single-page" | "steps";
|
||||
initialKind?: "mailbox" | "access" | "application" | "certificate" | "support" | "os" | undefined;
|
||||
initialAddress?: string;
|
||||
initialIntegration?: ResourceInput["integration"];
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { LayersIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -29,10 +30,21 @@ export function ResourceEditorDialog({
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t(resource ? "editResource" : "newResource")}
|
||||
wide
|
||||
size={resource ? "large" : "medium"}
|
||||
title={
|
||||
<span className="flex items-center gap-3">
|
||||
<LayersIcon aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
||||
{t(resource ? "editResource" : "newResource")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<ResourceForm companyId={companyId} items={items} onSaved={onSaved} resource={resource} />
|
||||
<ResourceForm
|
||||
companyId={companyId}
|
||||
creationLayout="steps"
|
||||
items={items}
|
||||
onSaved={onSaved}
|
||||
resource={resource}
|
||||
/>
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
import { FieldSections } from "@repo/rdx-ui/components/field-sections";
|
||||
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
||||
import { fieldHelpById } from "@repo/rdx-ui/lib/field-help";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type {
|
||||
CatalogItemDTO,
|
||||
ResourceCharacteristic,
|
||||
ResourceDTO,
|
||||
ResourceInput,
|
||||
ResourceReferenceDTO,
|
||||
} from "../../../../common";
|
||||
import { requireMailboxAddress } from "../helpers";
|
||||
|
||||
import { ResourceCharacteristicControl } from "./resource-characteristic-control";
|
||||
import { ResourceIntegrationFields } from "./resource-integration-fields";
|
||||
|
||||
export function ResourceFields({
|
||||
fields,
|
||||
sections,
|
||||
items,
|
||||
values,
|
||||
template,
|
||||
references,
|
||||
prefix,
|
||||
disabled,
|
||||
fixed,
|
||||
hasErrors,
|
||||
onCharacteristicChange,
|
||||
onIntegrationChange,
|
||||
}: {
|
||||
fields: readonly ResourceCharacteristic[];
|
||||
sections: ResourceDTO["specification"]["sections"];
|
||||
items: readonly CatalogItemDTO[];
|
||||
values: ResourceInput;
|
||||
template: CatalogItemDTO | undefined;
|
||||
references: readonly ResourceReferenceDTO[];
|
||||
prefix: string;
|
||||
disabled: boolean;
|
||||
fixed: boolean;
|
||||
hasErrors: boolean;
|
||||
onCharacteristicChange: (
|
||||
fieldId: string,
|
||||
value: ResourceInput["characteristics"][string] | undefined
|
||||
) => void;
|
||||
onIntegrationChange: (value: ResourceInput["integration"]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("resources");
|
||||
const fieldHelp = fieldHelpById(items);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResourceIntegrationFields
|
||||
fixed={fixed}
|
||||
integration={values.integration}
|
||||
items={items}
|
||||
onChange={onIntegrationChange}
|
||||
references={references}
|
||||
templateKey={template?.builtinKey}
|
||||
/>
|
||||
{fields.length > 0 && (
|
||||
<FormSection title={t("characteristics")} variant="plain">
|
||||
<FieldSections
|
||||
hasErrors={hasErrors}
|
||||
items={fields}
|
||||
layout="form"
|
||||
renderItem={(field) => (
|
||||
<ResourceCharacteristicControl
|
||||
disabled={disabled}
|
||||
field={requireMailboxAddress(field, items, values, template)}
|
||||
helpText={fieldHelp.get(field.fieldId)}
|
||||
key={field.fieldId}
|
||||
onChange={(value) => onCharacteristicChange(field.fieldId, value)}
|
||||
prefix={prefix}
|
||||
value={values.characteristics[field.fieldId]}
|
||||
/>
|
||||
)}
|
||||
sections={sections}
|
||||
ungroupedLabel={t("sections.ungrouped")}
|
||||
/>
|
||||
</FormSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
import { FormActions } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function ResourceFormActions({
|
||||
step,
|
||||
pending,
|
||||
canContinue,
|
||||
onPrevious,
|
||||
}: {
|
||||
step: 1 | 2 | 3 | undefined;
|
||||
pending: boolean;
|
||||
canContinue: boolean;
|
||||
onPrevious: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("resources");
|
||||
const label = step && step < 3 ? "creationSteps.next" : pending ? "saving" : "save";
|
||||
|
||||
return (
|
||||
<FormActions>
|
||||
{step && step > 1 && (
|
||||
<Button disabled={pending} onClick={onPrevious} type="button" variant="outline">
|
||||
<ChevronLeftIcon aria-hidden="true" className="size-4" />
|
||||
{t("creationSteps.previous")}
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={pending || (step === 1 && !canContinue)} type="submit">
|
||||
{t(label)}
|
||||
{step && step < 3 && <ChevronRightIcon aria-hidden="true" className="size-4" />}
|
||||
</Button>
|
||||
</FormActions>
|
||||
);
|
||||
}
|
||||
@ -1,11 +1,5 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FieldSections } from "@repo/rdx-ui/components/field-sections";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { fieldHelpById } from "@repo/rdx-ui/lib/field-help";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
@ -13,16 +7,20 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CatalogTemplateCreateDialog } from "../../../catalog-templates";
|
||||
import { InventoryFeedback } from "../../../shared";
|
||||
import { ResourceCreateProgress } from "../../create/ui/resource-create-progress";
|
||||
import { useResourceFormController } from "../controllers";
|
||||
import { requireMailboxAddress } from "../helpers";
|
||||
import type { ResourceFormProps } from "../types";
|
||||
|
||||
import { ResourceCharacteristicControl } from "./resource-characteristic-control";
|
||||
import { ResourceIntegrationFields } from "./resource-integration-fields";
|
||||
import { ResourceFields } from "./resource-fields";
|
||||
import { ResourceFormActions } from "./resource-form-actions";
|
||||
import { ResourceIdentityFields } from "./resource-identity-fields";
|
||||
|
||||
export function ResourceForm(props: ResourceFormProps) {
|
||||
const { companyId, resource, items, allowQuickCreate = true } = props;
|
||||
const {
|
||||
isWizard,
|
||||
step,
|
||||
previousStep,
|
||||
creatingTemplate,
|
||||
setCreatingTemplate,
|
||||
referenceQuery,
|
||||
@ -42,109 +40,71 @@ export function ResourceForm(props: ResourceFormProps) {
|
||||
} = useResourceFormController(props);
|
||||
const { t } = useTranslation("resources");
|
||||
const prefix = useId();
|
||||
const errorMessage = [
|
||||
form.formState.errors.templateId?.message,
|
||||
form.formState.errors.characteristics?.message,
|
||||
form.formState.errors.integration?.message,
|
||||
].find((message) => typeof message === "string");
|
||||
|
||||
const fieldHelp = fieldHelpById(items);
|
||||
const layout = isWizard ? CREATION_STEP_LAYOUTS[step] : FULL_FORM_LAYOUT;
|
||||
const notesMaxLength =
|
||||
values.integration || template?.builtinKey === "template:mailbox" ? 4000 : 10000;
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
|
||||
<FormBody>
|
||||
{isWizard && <ResourceCreateProgress step={step} templateName={template?.values.name} />}
|
||||
<fieldset className="space-y-5" disabled={mutation.isPending}>
|
||||
<FormSection title={t("formLayout.identity")}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
|
||||
<Input id={`${prefix}-name`} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-template`}>{t("template")}</Label>
|
||||
<SelectWithAction
|
||||
action={
|
||||
resource || !allowQuickCreate
|
||||
? undefined
|
||||
: { label: t("newTemplate"), onClick: () => setCreatingTemplate(true) }
|
||||
}
|
||||
disabled={!!resource || mutation.isPending}
|
||||
id={`${prefix}-template`}
|
||||
label={t("template")}
|
||||
onValueChange={selectTemplate}
|
||||
options={
|
||||
resource
|
||||
? [
|
||||
{
|
||||
value: resource.values.templateId,
|
||||
label: resource.specification.templateName,
|
||||
},
|
||||
]
|
||||
: templates.map((item) => ({ value: item.id, label: item.values.name }))
|
||||
}
|
||||
placeholder={t("chooseValue")}
|
||||
required
|
||||
value={values.templateId}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-status`}>{t("status")}</Label>
|
||||
<SelectWithAction
|
||||
disabled={mutation.isPending}
|
||||
id={`${prefix}-status`}
|
||||
label={t("status")}
|
||||
onValueChange={changeStatus}
|
||||
options={["active", "inactive"].map((value) => ({ value, label: t(value) }))}
|
||||
placeholder={t("chooseValue")}
|
||||
required
|
||||
value={values.active ? "active" : "inactive"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
<ResourceIntegrationFields
|
||||
fixed={!!resource}
|
||||
integration={values.integration}
|
||||
items={items}
|
||||
onChange={(integration) =>
|
||||
form.setValue("integration", integration, { shouldDirty: true })
|
||||
}
|
||||
references={referenceQuery.data?.references ?? []}
|
||||
templateKey={template?.builtinKey}
|
||||
/>
|
||||
{fields.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-medium">{t("characteristics")}</h3>
|
||||
<FieldSections
|
||||
hasErrors={!!mutation.error || Object.keys(form.formState.errors).length > 0}
|
||||
items={fields}
|
||||
renderItem={(field) => (
|
||||
<ResourceCharacteristicControl
|
||||
disabled={mutation.isPending}
|
||||
field={requireMailboxAddress(field, items, values, template)}
|
||||
helpText={fieldHelp.get(field.fieldId)}
|
||||
key={field.fieldId}
|
||||
onChange={(value) => setCharacteristic(field.fieldId, value)}
|
||||
prefix={prefix}
|
||||
value={values.characteristics[field.fieldId]}
|
||||
/>
|
||||
)}
|
||||
sections={sections}
|
||||
ungroupedLabel={t("sections.ungrouped")}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("notes")}</Label>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={
|
||||
values.integration || template?.builtinKey === "template:mailbox" ? 4000 : 10000
|
||||
}
|
||||
rows={3}
|
||||
{...form.register("notes")}
|
||||
{layout.showIdentity && (
|
||||
<ResourceIdentityFields
|
||||
allowQuickCreate={allowQuickCreate}
|
||||
disabled={mutation.isPending}
|
||||
form={form}
|
||||
mode={layout.identityMode}
|
||||
onCreateTemplate={() => setCreatingTemplate(true)}
|
||||
onStatusChange={changeStatus}
|
||||
onTemplateChange={selectTemplate}
|
||||
prefix={prefix}
|
||||
resource={resource}
|
||||
templates={templates}
|
||||
values={values}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{layout.showFields && (
|
||||
<ResourceFields
|
||||
disabled={mutation.isPending}
|
||||
fields={fields}
|
||||
fixed={!!resource}
|
||||
hasErrors={!!mutation.error || Object.keys(form.formState.errors).length > 0}
|
||||
items={items}
|
||||
onCharacteristicChange={setCharacteristic}
|
||||
onIntegrationChange={(integration) => {
|
||||
form.setValue("integration", integration, { shouldDirty: true });
|
||||
form.clearErrors("integration");
|
||||
}}
|
||||
prefix={prefix}
|
||||
references={referenceQuery.data?.references ?? []}
|
||||
sections={sections}
|
||||
template={template}
|
||||
values={values}
|
||||
/>
|
||||
)}
|
||||
{layout.showNotes && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("notes")}</Label>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={notesMaxLength}
|
||||
rows={3}
|
||||
{...form.register("notes")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
{Object.keys(form.formState.errors).length > 0 && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t("inventoryErrors.INVALID_REQUEST")}
|
||||
{errorMessage ?? t("inventoryErrors.INVALID_REQUEST")}
|
||||
</p>
|
||||
)}
|
||||
<InventoryFeedback error={mutation.error ?? referenceQuery.error} />
|
||||
@ -155,13 +115,15 @@ export function ResourceForm(props: ResourceFormProps) {
|
||||
guidance={t(`${helpPrefix}Guidance`)}
|
||||
scopeNote={t(`${helpPrefix}Scope`)}
|
||||
title={t("helpTitle")}
|
||||
variant="plain"
|
||||
/>
|
||||
</FormBody>
|
||||
<FormActions>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
<ResourceFormActions
|
||||
canContinue={!!template}
|
||||
onPrevious={previousStep}
|
||||
pending={mutation.isPending}
|
||||
step={isWizard ? step : undefined}
|
||||
/>
|
||||
</form>
|
||||
{creatingTemplate && (
|
||||
<CatalogTemplateCreateDialog
|
||||
@ -178,3 +140,31 @@ export function ResourceForm(props: ResourceFormProps) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const FULL_FORM_LAYOUT = {
|
||||
showIdentity: true,
|
||||
showFields: true,
|
||||
showNotes: true,
|
||||
identityMode: "all",
|
||||
} as const;
|
||||
|
||||
const CREATION_STEP_LAYOUTS = {
|
||||
1: {
|
||||
showIdentity: true,
|
||||
showFields: false,
|
||||
showNotes: false,
|
||||
identityMode: "template",
|
||||
},
|
||||
2: {
|
||||
showIdentity: false,
|
||||
showFields: true,
|
||||
showNotes: false,
|
||||
identityMode: "template",
|
||||
},
|
||||
3: {
|
||||
showIdentity: true,
|
||||
showFields: false,
|
||||
showNotes: true,
|
||||
identityMode: "identity",
|
||||
},
|
||||
} as const;
|
||||
|
||||
@ -0,0 +1,107 @@
|
||||
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CatalogItemDTO, ResourceDTO, ResourceInput } from "../../../../common";
|
||||
|
||||
export function ResourceIdentityFields({
|
||||
resource,
|
||||
templates,
|
||||
values,
|
||||
form,
|
||||
prefix,
|
||||
mode,
|
||||
disabled,
|
||||
allowQuickCreate,
|
||||
onCreateTemplate,
|
||||
onTemplateChange,
|
||||
onStatusChange,
|
||||
}: {
|
||||
resource: ResourceDTO | undefined;
|
||||
templates: readonly CatalogItemDTO[];
|
||||
values: ResourceInput;
|
||||
form: UseFormReturn<ResourceInput>;
|
||||
prefix: string;
|
||||
mode: "all" | "template" | "identity";
|
||||
disabled: boolean;
|
||||
allowQuickCreate: boolean;
|
||||
onCreateTemplate: () => void;
|
||||
onTemplateChange: (id: string) => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("resources");
|
||||
const options = resource
|
||||
? [{ value: resource.values.templateId, label: resource.specification.templateName }]
|
||||
: templates.map((item) => ({ value: item.id, label: item.values.name }));
|
||||
const templateAction =
|
||||
resource || !allowQuickCreate
|
||||
? undefined
|
||||
: { label: t("newTemplate"), onClick: onCreateTemplate };
|
||||
|
||||
return (
|
||||
<FormSection
|
||||
title={t(mode === "template" ? "formLayout.template" : "formLayout.identity")}
|
||||
variant="plain"
|
||||
>
|
||||
<div className={`grid gap-x-5 gap-y-4 ${IDENTITY_LAYOUTS[mode]}`}>
|
||||
{mode !== "template" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
|
||||
<Input
|
||||
aria-describedby={mode === "identity" ? `${prefix}-name-help` : undefined}
|
||||
id={`${prefix}-name`}
|
||||
maxLength={100}
|
||||
required
|
||||
{...form.register("name")}
|
||||
/>
|
||||
{mode === "identity" && (
|
||||
<p className="text-sm text-muted-foreground" id={`${prefix}-name-help`}>
|
||||
{t("creationSteps.nameHelp")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{mode !== "identity" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-template`}>{t("template")}</Label>
|
||||
<SelectWithAction
|
||||
action={templateAction}
|
||||
disabled={!!resource || disabled}
|
||||
id={`${prefix}-template`}
|
||||
label={t("template")}
|
||||
onValueChange={onTemplateChange}
|
||||
options={options}
|
||||
placeholder={t("chooseValue")}
|
||||
required
|
||||
value={values.templateId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{mode !== "template" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-status`}>{t("status")}</Label>
|
||||
<SelectWithAction
|
||||
disabled={disabled}
|
||||
id={`${prefix}-status`}
|
||||
label={t("status")}
|
||||
onValueChange={onStatusChange}
|
||||
options={["active", "inactive"].map((value) => ({ value, label: t(value) }))}
|
||||
placeholder={t("chooseValue")}
|
||||
required
|
||||
value={values.active ? "active" : "inactive"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
||||
|
||||
const IDENTITY_LAYOUTS = {
|
||||
all: "@min-[560px]:grid-cols-2 @min-[900px]:grid-cols-3",
|
||||
template: "grid-cols-1",
|
||||
identity: "@min-[560px]:grid-cols-[minmax(0,1fr)_180px]",
|
||||
} as const;
|
||||
Loading…
Reference in New Issue
Block a user