feat: implement resource lifecycle management with delete functionality
- Added `verifyResourceLifecycle` integration to handle resource lifecycle operations. - Introduced `remove` method in `IResourceRepository` and implemented it in `SequelizeResourceRepository`. - Created `DeleteResourceDialog` component for confirming resource deletion. - Updated `resource.use-cases` to include `remove` and `setActive` methods for managing resource states. - Enhanced API routes to support resource status updates and deletions. - Added tests for resource lifecycle operations, ensuring correct behavior for activation, deactivation, and deletion. - Updated UI components to integrate new deletion functionality and provide feedback on resource status changes. - Improved localization for resource lifecycle actions in both English and Spanish.
This commit is contained in:
parent
ceb4eb88c6
commit
72c2a5101d
103
apps/server/src/resource-lifecycle.integration.ts
Normal file
103
apps/server/src/resource-lifecycle.integration.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { ResourceInput } from "@metis/resources/common";
|
||||
import { ResourceResponseSchema } from "@metis/resources/common";
|
||||
import type { Sequelize } from "sequelize";
|
||||
import { DataTypes, QueryTypes } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { UniqueID } from "../../../packages/rdx-ddd/src/index";
|
||||
|
||||
export async function verifyResourceLifecycle(
|
||||
request: (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>,
|
||||
companyId: string,
|
||||
otherId: string,
|
||||
database: Sequelize,
|
||||
input: ResourceInput
|
||||
) {
|
||||
const base = `/resources/${companyId}`;
|
||||
const createdResponse = await request(base, {
|
||||
values: { ...input, name: "Recurso para ciclo de vida" },
|
||||
});
|
||||
|
||||
assert.equal(createdResponse.status, 201);
|
||||
const created = ResourceResponseSchema.parse(await createdResponse.json()).resource;
|
||||
const path = `${base}/${created.id}`;
|
||||
|
||||
for (const [active, revisions] of [
|
||||
[false, 2],
|
||||
[false, 2],
|
||||
[true, 3],
|
||||
] as const) {
|
||||
const response = await request(`${path}/status`, { active });
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const changed = ResourceResponseSchema.parse(await response.json()).resource;
|
||||
|
||||
assert.equal(changed.values.active, active);
|
||||
assert.equal(changed.history.length, revisions);
|
||||
assert.deepEqual(changed.values.characteristics, created.values.characteristics);
|
||||
assert.deepEqual(changed.specification, created.specification);
|
||||
}
|
||||
assert.equal((await request(`${path}/status`, { active: "false" })).status, 400);
|
||||
assert.equal((await request(`${path}/status`, { active: false, name: "Otra cosa" })).status, 400);
|
||||
assert.equal((await request(`${path}/delete`, { unexpected: true })).status, 400);
|
||||
assert.equal(
|
||||
(await request(`/resources/${otherId}/${created.id}/status`, { active: false })).status,
|
||||
404
|
||||
);
|
||||
assert.equal((await request(`/resources/${otherId}/${created.id}/delete`, {})).status, 404);
|
||||
assert.equal((await request(`${path}/delete`, {}, "https://other.example")).status, 403);
|
||||
assert.equal(
|
||||
(await request(`${path}/status`, { active: false }, undefined, "a".repeat(64))).status,
|
||||
403
|
||||
);
|
||||
const linksTable = `resource_lifecycle_${UniqueID.generateNewID().toPrimitive().replaceAll("-", "")}`;
|
||||
|
||||
await database.getQueryInterface().createTable(linksTable, {
|
||||
resourceId: {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
references: { model: "resources", key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await database.getQueryInterface().bulkInsert(linksTable, [{ resourceId: created.id }]);
|
||||
const blocked = await request(`${path}/delete`, {});
|
||||
|
||||
assert.equal(blocked.status, 409);
|
||||
assert.equal(
|
||||
z.object({ code: z.string() }).parse(await blocked.json()).code,
|
||||
"RESOURCE_IN_USE"
|
||||
);
|
||||
assert.equal((await request(`${path}/status`, { active: false })).status, 200);
|
||||
} finally {
|
||||
await database.getQueryInterface().dropTable(linksTable);
|
||||
}
|
||||
const removed = await request(`${path}/delete`, {});
|
||||
|
||||
assert.equal(removed.status, 200);
|
||||
assert.deepEqual(await removed.json(), { deleted: true });
|
||||
assert.equal((await request(`${path}/delete`, {})).status, 404);
|
||||
assert.equal((await request(`${path}/status`, { active: true })).status, 404);
|
||||
for (const table of [
|
||||
"resources",
|
||||
"resource_revisions",
|
||||
"resource_characteristics",
|
||||
"resource_revision_characteristics",
|
||||
"resource_specification_fields",
|
||||
"resource_specification_field_choices",
|
||||
"resource_specification_sections",
|
||||
]) {
|
||||
const rows = await database.query(
|
||||
`SELECT 1 FROM ${table} WHERE ${table === "resources" ? "id" : "resourceId"}=:id`,
|
||||
{
|
||||
replacements: { id: created.id },
|
||||
type: QueryTypes.SELECT,
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(rows.length, 0);
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,8 @@ import { z } from "zod/v4";
|
||||
|
||||
import { UniqueID } from "../../../packages/rdx-ddd/src/index";
|
||||
|
||||
import { verifyResourceLifecycle } from "./resource-lifecycle.integration";
|
||||
|
||||
export async function verifyResources(
|
||||
request: (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>,
|
||||
companyId: string,
|
||||
@ -462,4 +464,21 @@ export async function verifyResources(
|
||||
"INVALID_REQUEST",
|
||||
400
|
||||
);
|
||||
await verifyResourceLifecycle(request, companyId, otherId, database, first);
|
||||
const mailbox = await resource(inventory, {
|
||||
...concurrentInput,
|
||||
name: "Buzón para eliminar",
|
||||
characteristics: {
|
||||
...concurrentInput.characteristics,
|
||||
[stock.id]: "delete-mailbox@example.test",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal((await request(`${inventory}/${mailbox.id}/delete`, {})).status, 200);
|
||||
const projected = await database.query("SELECT id FROM mailboxes WHERE id=:id", {
|
||||
replacements: { id: mailbox.id },
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
|
||||
assert.equal(projected.length, 0);
|
||||
}
|
||||
|
||||
@ -632,7 +632,7 @@ El alta y la edición de plantillas de activos y recursos reutilizan `TemplateMe
|
||||
|
||||
## Títulos de grupos en diálogos
|
||||
|
||||
Todos los diálogos con grupos utilizan el estilo de «Datos de la plantilla»: texto pequeño, seminegrita, mayúsculas, espaciado entre letras y color atenuado (`text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-4`). `FormSectionTitle` centraliza esta presentación y recibe `children`, `id` opcional y `as="h3" | "h4" | "h5" | "legend" | "span"` para conservar la semántica del encabezado, leyenda o título dentro de un `summary`. El consumidor aporta el texto traducido; las mayúsculas se aplican mediante CSS. La variante `span` conserva la tipografía y deja el espaciado al `summary` que la contiene, sin margen inferior propio.
|
||||
Todos los diálogos con grupos utilizan el estilo de «Datos de la plantilla»: texto pequeño, seminegrita, mayúsculas, espaciado entre letras y color atenuado (`text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-4`). `FormSectionTitle` centraliza esta presentación y recibe `children`, `id` opcional y `as="h3" | "h4" | "h5" | "legend" | "span"` para conservar la semántica del encabezado, leyenda o título dentro de un `summary`. El consumidor aporta el texto traducido; las mayúsculas se aplican mediante CSS. La variante `span` conserva la tipografía y deja el espaciado al `summary` que la contiene, sin margen inferior propio. Para cabeceras que comparten fila con contadores o acciones, `layout="inline"` elimina también el margen sin cambiar el nivel semántico (`h3`, `h4` o `h5`). El contenedor alinea los elementos con `items-center` y agrupa título y contador para mantenerlos juntos al adaptar el ancho. El modo `standalone` mantiene la separación inferior habitual; `span` utiliza `inline` por defecto.
|
||||
|
||||
`FormSection`, `TemplateSectionsEditor` y `TemplateSectionFields` comparten este título, con independencia de sus bordes, acciones o contenido. La regla también se aplica a grupos de consulta, como los puestos del historial de perfiles. Los títulos principales del diálogo y los nombres editables de secciones mantienen su presentación propia.
|
||||
|
||||
@ -1026,3 +1026,21 @@ evitar un menú vacío. Mantener el lápiz para editar y el ojo para abrir o ver
|
||||
Los listados de configuraciones conservan deshabilitada la edición de registros
|
||||
retirados o nunca configurados. Las acciones de baja o eliminación siguen en
|
||||
el desplegable. Véase el [patrón de listados](patron-de-listados-de-entidades.md).
|
||||
|
||||
|
||||
### Ciclo de vida de recursos
|
||||
|
||||
El listado de Recursos mantiene Editar como acción principal de icono y ofrece
|
||||
Ver, Activar/Desactivar y Eliminar definitivamente en el menú de tres puntos.
|
||||
El cambio de estado conserva las asociaciones y añade una revisión; repetir el
|
||||
mismo estado no crea revisiones adicionales. La mutación de estado envía solo
|
||||
el estado solicitado para conservar posibles ediciones concurrentes.
|
||||
|
||||
`DeleteResourceDialog`, independiente del listado, reutiliza `ConfirmDeleteDialog`,
|
||||
identifica el recurso y advierte del borrado de su historial. Si el servidor devuelve
|
||||
`RESOURCE_IN_USE`, muestra el motivo dentro del modal y permite cancelar. No se
|
||||
eliminan asociaciones actuales ni históricas: solo se borran recursos sin referencias
|
||||
externas, sus datos propios y sus proyecciones de buzón o acceso dentro de una transacción.
|
||||
Tras borrar se limpia la selección afectada y se mantienen la búsqueda y la pestaña.
|
||||
Las altas, ediciones y cambios de estado o borrado reutilizan
|
||||
`resourceRelatedQueryKeys` para actualizar inventario y listados consumidores.
|
||||
|
||||
@ -42,6 +42,12 @@ estable y la ayuda inmediatamente encima del pie del modal.
|
||||
espaciado entre letras y color atenuado (`text-xs font-semibold uppercase tracking-wide
|
||||
text-muted-foreground mb-4`). Conservar el nivel semántico del encabezado; las mayúsculas
|
||||
pertenecen a la presentación CSS y los textos pasan por i18n.
|
||||
- Cuando el título comparte fila con contadores o acciones, usar
|
||||
`FormSectionTitle layout="inline"` y alinear la fila con `items-center`.
|
||||
El título no añade margen inferior en este modo: la separación respecto al
|
||||
contenido pertenece al contenedor. Mantener título y contador en un mismo
|
||||
grupo flex para que no se separen al ajustar el ancho. Conservar el nivel
|
||||
semántico del encabezado y reservar el margen habitual para títulos independientes.
|
||||
- El título principal del diálogo y los nombres editables de secciones tienen su propia
|
||||
función. El nombre editable de una sección va en su cabecera, junto a sus acciones.
|
||||
- Para los datos generales, preferir `FormSection variant="plain"`, sin borde ni relleno de
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/shared/resource-values.test.ts src/api/domain/template-sections.test.ts src/web/catalog-fields/select/catalog-field-selection.test.tsx"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/api/application/resource-lifecycle.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/shared/resource-values.test.ts src/api/domain/template-sections.test.ts src/web/catalog-fields/select/catalog-field-selection.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@metis/core": "workspace:*",
|
||||
|
||||
162
modules/resources/src/api/application/resource-lifecycle.test.ts
Normal file
162
modules/resources/src/api/application/resource-lifecycle.test.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { Resource } from "../domain/resource.aggregate";
|
||||
import { ResourceCatalog } from "../domain/resource-catalog.aggregate";
|
||||
import { ResourceCatalogError } from "../domain/resource-definition";
|
||||
import { ResourceSpecification, ResourceValues } from "../domain/value-objects/resource-values.vo";
|
||||
|
||||
import type { IResourceRepository, InventoryContext } from "./resource.repository";
|
||||
import { ResourceUseCases } from "./resource.use-cases";
|
||||
|
||||
function fixture() {
|
||||
const company = UniqueID.generateNewID();
|
||||
const actor = UniqueID.generateNewID();
|
||||
const templateId = UniqueID.generateNewID().toPrimitive();
|
||||
const fieldId = UniqueID.generateNewID().toPrimitive();
|
||||
const spec = ResourceSpecification.create({
|
||||
templateId,
|
||||
templateName: "Aplicación",
|
||||
fields: [{ fieldId, name: "Fabricante", type: "text", unit: "", choices: [], required: false }],
|
||||
});
|
||||
|
||||
assert.ok(spec.isSuccess);
|
||||
const values = ResourceValues.create(
|
||||
{
|
||||
name: "Office 2024",
|
||||
templateId,
|
||||
active: true,
|
||||
notes: "Notas conservadas",
|
||||
characteristics: { [fieldId]: "Microsoft" },
|
||||
},
|
||||
spec.data
|
||||
);
|
||||
|
||||
assert.ok(values.isSuccess);
|
||||
const created = Resource.create(
|
||||
company,
|
||||
spec.data,
|
||||
values.data,
|
||||
actor,
|
||||
"2026-01-01T12:00:00.000Z"
|
||||
);
|
||||
|
||||
assert.ok(created.isSuccess);
|
||||
let resources = [created.data];
|
||||
let inUse = false;
|
||||
|
||||
const context = (): InventoryContext => ({
|
||||
catalog: ResourceCatalog.create(),
|
||||
resources,
|
||||
references: [],
|
||||
actorNames: new Map([[actor.toPrimitive(), "Técnico"]]),
|
||||
});
|
||||
|
||||
const repository: IResourceRepository = {
|
||||
get: async () => Result.ok(context()),
|
||||
save: async (id, action) => {
|
||||
const changed = action({ ...context(), resources: id.equals(company) ? resources : [] });
|
||||
|
||||
if (!changed.isSuccess) {
|
||||
return Result.fail(changed.error);
|
||||
}
|
||||
resources = [changed.data];
|
||||
|
||||
return Result.ok({ resource: changed.data, actorNames: context().actorNames });
|
||||
},
|
||||
remove: async (id, identity) => {
|
||||
if (!(id.equals(company) && resources.some((resource) => resource.id.equals(identity)))) {
|
||||
return Result.fail(new ResourceCatalogError("RESOURCE_NOT_FOUND"));
|
||||
}
|
||||
if (inUse) {
|
||||
return Result.fail(new ResourceCatalogError("RESOURCE_IN_USE"));
|
||||
}
|
||||
resources = [];
|
||||
|
||||
return Result.ok(undefined);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
service: new ResourceUseCases(repository),
|
||||
created: created.data,
|
||||
company: company.toPrimitive(),
|
||||
actor: actor.toPrimitive(),
|
||||
setInUse: () => {
|
||||
inUse = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("recurso: activar/desactivar conserva datos e historial y repetir estado no añade revisiones", async () => {
|
||||
const { service, company, actor, created } = fixture();
|
||||
|
||||
for (const [active, revisions] of [
|
||||
[false, 2],
|
||||
[false, 2],
|
||||
[true, 3],
|
||||
] as const) {
|
||||
const result = await service.setActive(company, created.id.toPrimitive(), active, actor);
|
||||
|
||||
assert.ok(result.isSuccess);
|
||||
assert.equal(result.data.resource.values.toPrimitive().active, active);
|
||||
assert.equal(result.data.resource.history.length, revisions);
|
||||
assert.deepEqual(
|
||||
result.data.resource.values.toPrimitive().characteristics,
|
||||
created.values.toPrimitive().characteristics
|
||||
);
|
||||
assert.equal(
|
||||
result.data.resource.values.toPrimitive().notes,
|
||||
created.values.toPrimitive().notes
|
||||
);
|
||||
assert.equal(result.data.resource.history.at(-1)?.actorId.toPrimitive(), actor);
|
||||
}
|
||||
});
|
||||
|
||||
test("recurso: valida identidades y rechaza actuar sobre recursos ajenos", async () => {
|
||||
const { service, company, actor, created } = fixture();
|
||||
const id = created.id.toPrimitive();
|
||||
const invalid = await service.setActive("invalid", id, false, actor);
|
||||
|
||||
assert.ok(!invalid.isSuccess);
|
||||
assert.equal(invalid.error.code, "INVALID_REQUEST");
|
||||
const invalidActor = await service.setActive(company, id, false, "invalid");
|
||||
|
||||
assert.ok(!invalidActor.isSuccess);
|
||||
const invalidDelete = await service.remove(company, "invalid");
|
||||
|
||||
assert.ok(!invalidDelete.isSuccess);
|
||||
const other = UniqueID.generateNewID().toPrimitive();
|
||||
const foreign = await service.setActive(other, id, false, actor);
|
||||
|
||||
assert.ok(!foreign.isSuccess);
|
||||
assert.equal(foreign.error.code, "RESOURCE_NOT_FOUND");
|
||||
const foreignDelete = await service.remove(other, id);
|
||||
|
||||
assert.ok(!foreignDelete.isSuccess);
|
||||
assert.equal(foreignDelete.error.code, "RESOURCE_NOT_FOUND");
|
||||
});
|
||||
|
||||
test("recurso: propaga el bloqueo por asociaciones y permite desactivar", async () => {
|
||||
const { service, company, actor, created, setInUse } = fixture();
|
||||
|
||||
setInUse();
|
||||
const removed = await service.remove(company, created.id.toPrimitive());
|
||||
|
||||
assert.ok(!removed.isSuccess);
|
||||
assert.equal(removed.error.code, "RESOURCE_IN_USE");
|
||||
assert.ok((await service.setActive(company, created.id.toPrimitive(), false, actor)).isSuccess);
|
||||
});
|
||||
|
||||
test("recurso: elimina y la repetición devuelve ausencia sin efectos adicionales", async () => {
|
||||
const { service, company, created } = fixture();
|
||||
|
||||
assert.ok((await service.remove(company, created.id.toPrimitive())).isSuccess);
|
||||
const missing = await service.remove(company, created.id.toPrimitive());
|
||||
|
||||
assert.ok(!missing.isSuccess);
|
||||
assert.equal(missing.error.code, "RESOURCE_NOT_FOUND");
|
||||
});
|
||||
@ -14,6 +14,7 @@ export type InventoryContext = Readonly<{
|
||||
}>;
|
||||
|
||||
export interface IResourceRepository {
|
||||
remove(companyId: UniqueID, id: UniqueID): Promise<Result<void, ResourceCatalogError>>;
|
||||
get(companyId: UniqueID): Promise<Result<InventoryContext, ResourceCatalogError>>;
|
||||
save(
|
||||
companyId: UniqueID,
|
||||
|
||||
@ -4,6 +4,7 @@ import { Result } from "@repo/rdx-utils";
|
||||
import { Resource } from "../domain/resource.aggregate";
|
||||
import { ResourceCatalogError } from "../domain/resource-definition";
|
||||
import type { ResourceInput } from "../domain/resource-values";
|
||||
import { ResourceValues } from "../domain/value-objects/resource-values.vo";
|
||||
|
||||
import type { IResourceRepository } from "./resource.repository";
|
||||
import { mapResourceInput } from "./resource-input.mapper";
|
||||
@ -11,6 +12,45 @@ import { mapResourceInput } from "./resource-input.mapper";
|
||||
export class ResourceUseCases {
|
||||
constructor(private readonly repository: IResourceRepository) {}
|
||||
|
||||
remove(companyId: string, id: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
const identity = UniqueID.create(id);
|
||||
|
||||
if (!(company.isSuccess && identity.isSuccess)) {
|
||||
return Promise.resolve(Result.fail(new ResourceCatalogError("INVALID_REQUEST")));
|
||||
}
|
||||
|
||||
return this.repository.remove(company.data, identity.data);
|
||||
}
|
||||
|
||||
setActive(companyId: string, id: string, active: boolean, actorId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
const identity = UniqueID.create(id);
|
||||
const actor = UniqueID.create(actorId);
|
||||
|
||||
if (!(company.isSuccess && identity.isSuccess && actor.isSuccess)) {
|
||||
return Promise.resolve(Result.fail(new ResourceCatalogError("INVALID_REQUEST")));
|
||||
}
|
||||
|
||||
return this.repository.save(company.data, (context) => {
|
||||
const previous = context.resources.find((resource) => resource.id.equals(identity.data));
|
||||
|
||||
if (!previous) {
|
||||
return Result.fail(new ResourceCatalogError("RESOURCE_NOT_FOUND"));
|
||||
}
|
||||
const values = ResourceValues.create(
|
||||
{ ...previous.values.toPrimitive(), active },
|
||||
previous.specification
|
||||
);
|
||||
|
||||
if (!values.isSuccess) {
|
||||
return Result.fail(values.error);
|
||||
}
|
||||
|
||||
return previous.update(values.data, actor.data, new Date().toISOString());
|
||||
});
|
||||
}
|
||||
|
||||
get(companyId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
|
||||
|
||||
@ -6,16 +6,43 @@ import {
|
||||
InventoryParamsSchema,
|
||||
ResourceParamsSchema,
|
||||
SaveResourceRequestSchema,
|
||||
SetResourceActiveRequestSchema,
|
||||
} from "../../../common/inventory";
|
||||
import type { ResourceUseCases } from "../../application/resource.use-cases";
|
||||
import { buildResourceResponse } from "../../application/resource-response.builder";
|
||||
|
||||
const LifecycleRequestSchema = z.discriminatedUnion("action", [
|
||||
z.strictObject({ action: z.literal("status"), body: SetResourceActiveRequestSchema }),
|
||||
z.strictObject({ action: z.literal("delete"), body: z.strictObject({}) }),
|
||||
]);
|
||||
|
||||
function fail(response: Response, code: string) {
|
||||
response
|
||||
.status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409)
|
||||
.json({ code, requestId: response.getHeader("x-request-id") });
|
||||
}
|
||||
|
||||
function sendResource(
|
||||
response: Response,
|
||||
result: Awaited<ReturnType<ResourceUseCases["setActive"]>>
|
||||
) {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
|
||||
return;
|
||||
}
|
||||
response.json({ resource: buildResourceResponse(result.data.resource, result.data.actorNames) });
|
||||
}
|
||||
|
||||
function sendDeleted(response: Response, result: Awaited<ReturnType<ResourceUseCases["remove"]>>) {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
|
||||
return;
|
||||
}
|
||||
response.json({ deleted: true });
|
||||
}
|
||||
|
||||
export function registerResourceRoutes(
|
||||
app: Application,
|
||||
useCases: ResourceUseCases,
|
||||
@ -23,6 +50,41 @@ export function registerResourceRoutes(
|
||||
) {
|
||||
const router = Router();
|
||||
|
||||
for (const action of ["status", "delete"] as const) {
|
||||
router.post(`/:companyId/:id/${action}`, (request, response, next) => {
|
||||
const params = ResourceParamsSchema.safeParse(request.params);
|
||||
const actor = z
|
||||
.object({ userId: z.uuid() })
|
||||
.safeParse("session" in request ? request.session : undefined);
|
||||
const body = LifecycleRequestSchema.safeParse({ action, body: request.body });
|
||||
|
||||
if (
|
||||
!(
|
||||
params.success &&
|
||||
actor.success &&
|
||||
body.success &&
|
||||
z.strictObject({}).safeParse(request.query).success
|
||||
)
|
||||
) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
|
||||
return;
|
||||
}
|
||||
if (body.data.action === "delete") {
|
||||
useCases
|
||||
.remove(params.data.companyId, params.data.id)
|
||||
.then((result) => sendDeleted(response, result))
|
||||
.catch(next);
|
||||
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.setActive(params.data.companyId, params.data.id, body.data.body.active, actor.data.userId)
|
||||
.then((result) => sendResource(response, result))
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
|
||||
router.get("/:companyId", (request, response, next) => {
|
||||
const params = InventoryParamsSchema.safeParse(request.params);
|
||||
|
||||
|
||||
@ -0,0 +1,121 @@
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import type { Sequelize, Transaction } from "sequelize";
|
||||
import { QueryTypes } from "sequelize";
|
||||
|
||||
import type { Resource } from "../../domain/resource.aggregate";
|
||||
import { ResourceCatalogError } from "../../domain/resource-definition";
|
||||
|
||||
import { sqlIdentifier } from "./resource-relational-sql";
|
||||
|
||||
const OWNED_TABLES = [
|
||||
"resource_revision_characteristics",
|
||||
"resource_characteristics",
|
||||
"resource_specification_field_choices",
|
||||
"resource_specification_fields",
|
||||
"resource_specification_sections",
|
||||
"resource_revisions",
|
||||
];
|
||||
|
||||
const PROJECTION_COLUMNS = new Map([
|
||||
["mailboxes", new Set(["mailbox_revisions.itemId"])],
|
||||
[
|
||||
"access_items",
|
||||
new Set([
|
||||
"access_resource_values.ownerId",
|
||||
"access_revisions.itemId",
|
||||
"access_references.itemId",
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
/** Conserva cualquier asociación externa, incluso histórica; solo borra datos propios y proyecciones. */
|
||||
export async function removeResource(
|
||||
database: Sequelize,
|
||||
resource: Resource,
|
||||
transaction: Transaction
|
||||
) {
|
||||
const id = resource.id.toPrimitive();
|
||||
const companyId = resource.companyId.toPrimitive();
|
||||
const integration = resource.values.toPrimitive().integration;
|
||||
const roots = ["resources"];
|
||||
|
||||
if (integration) {
|
||||
roots.push(integration.kind === "mailbox" ? "mailboxes" : "access_items");
|
||||
}
|
||||
// El bloqueo de las raíces protege también frente a nuevas asociaciones concurrentes por FK.
|
||||
for (const table of roots) {
|
||||
await database.query(`SELECT id FROM ${sqlIdentifier(table)} WHERE id=:id FOR UPDATE`, {
|
||||
replacements: { id },
|
||||
type: QueryTypes.SELECT,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
const references = await database.query<{ tableName: string; columnName: string; root: string }>(
|
||||
"SELECT TABLE_NAME AS tableName,COLUMN_NAME AS columnName,REFERENCED_TABLE_NAME AS root FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME IN (:roots) AND REFERENCED_COLUMN_NAME='id'",
|
||||
{ replacements: { roots }, type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
|
||||
for (const reference of references) {
|
||||
const owned =
|
||||
reference.root === "resources"
|
||||
? OWNED_TABLES.includes(reference.tableName) && reference.columnName === "resourceId"
|
||||
: PROJECTION_COLUMNS.get(reference.root)?.has(
|
||||
`${reference.tableName}.${reference.columnName}`
|
||||
);
|
||||
|
||||
if (owned) {
|
||||
continue;
|
||||
}
|
||||
const rows = await database.query<{ value: number }>(
|
||||
`SELECT 1 AS value FROM ${sqlIdentifier(reference.tableName)} WHERE ${sqlIdentifier(reference.columnName)}=:id LIMIT 1`,
|
||||
{ replacements: { id }, type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
|
||||
if (rows.length) {
|
||||
return Result.fail(new ResourceCatalogError("RESOURCE_IN_USE"));
|
||||
}
|
||||
}
|
||||
for (const table of OWNED_TABLES) {
|
||||
await database.query(`DELETE FROM ${sqlIdentifier(table)} WHERE resourceId=:id`, {
|
||||
replacements: { id },
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
await database.query("DELETE FROM resources WHERE id=:id AND companyId=:companyId", {
|
||||
replacements: { id, companyId },
|
||||
transaction,
|
||||
});
|
||||
if (integration?.kind === "mailbox") {
|
||||
await database.query("DELETE FROM mailbox_revisions WHERE itemId=:id", {
|
||||
replacements: { id },
|
||||
transaction,
|
||||
});
|
||||
await database.query("DELETE FROM mailboxes WHERE id=:id AND companyId=:companyId", {
|
||||
replacements: { id, companyId },
|
||||
transaction,
|
||||
});
|
||||
} else if (integration?.kind === "access") {
|
||||
await database.query("DELETE FROM access_references WHERE itemId=:id", {
|
||||
replacements: { id },
|
||||
transaction,
|
||||
});
|
||||
await database.query(
|
||||
"DELETE FROM access_resource_revision_values WHERE ownerId IN (SELECT id FROM access_revisions WHERE itemId=:id)",
|
||||
{ replacements: { id }, transaction }
|
||||
);
|
||||
await database.query("DELETE FROM access_revisions WHERE itemId=:id", {
|
||||
replacements: { id },
|
||||
transaction,
|
||||
});
|
||||
await database.query("DELETE FROM access_resource_values WHERE ownerId=:id", {
|
||||
replacements: { id },
|
||||
transaction,
|
||||
});
|
||||
await database.query("DELETE FROM access_items WHERE id=:id", {
|
||||
replacements: { id },
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
@ -8,6 +8,7 @@ import { ResourceReferenceSchema } from "../../../common/inventory";
|
||||
import type { IResourceRepository, InventoryContext } from "../../application/resource.repository";
|
||||
import { ResourceCatalogError } from "../../domain/resource-definition";
|
||||
|
||||
import { removeResource } from "./remove-resource";
|
||||
import {
|
||||
ResourceAvailabilityModel,
|
||||
ResourceBindingModel,
|
||||
@ -24,6 +25,23 @@ export class SequelizeResourceRepository implements IResourceRepository {
|
||||
|
||||
constructor(private readonly database: Sequelize) {}
|
||||
|
||||
remove(companyId: UniqueID, id: UniqueID) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const loaded = await this.load(companyId, transaction, true);
|
||||
|
||||
if (!loaded.isSuccess) {
|
||||
return Result.fail(loaded.error);
|
||||
}
|
||||
const resource = loaded.data.resources.find((item) => item.id.equals(id));
|
||||
|
||||
if (!resource) {
|
||||
return Result.fail(new ResourceCatalogError("RESOURCE_NOT_FOUND"));
|
||||
}
|
||||
|
||||
return removeResource(this.database, resource, transaction);
|
||||
});
|
||||
}
|
||||
|
||||
private async load(
|
||||
companyId: UniqueID,
|
||||
transaction: Transaction,
|
||||
|
||||
@ -16,6 +16,10 @@ export const ResourceParamsSchema = InventoryParamsSchema.extend({ id: z.uuid()
|
||||
|
||||
export const SaveResourceRequestSchema = z.strictObject({ values: ResourceInputSchema });
|
||||
|
||||
export const SetResourceActiveRequestSchema = z.strictObject({ active: z.boolean() });
|
||||
|
||||
export const DeleteResourceResponseSchema = z.strictObject({ deleted: z.literal(true) });
|
||||
|
||||
export const ResourceRevisionSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
values: ResourceInputSchema,
|
||||
|
||||
@ -65,7 +65,8 @@
|
||||
"COMPANY_NOT_FOUND": "Company not found.",
|
||||
"FORBIDDEN": "Your session does not allow this operation. Sign in again.",
|
||||
"UNAUTHENTICATED": "Sign in again.",
|
||||
"INTERNAL_ERROR": "The operation could not be completed."
|
||||
"INTERNAL_ERROR": "The operation could not be completed.",
|
||||
"RESOURCE_IN_USE": "The resource has current or historical associations. Deactivate it to preserve them or remove its associations before deleting it."
|
||||
},
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
@ -214,5 +215,14 @@
|
||||
"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."
|
||||
},
|
||||
"resourceLifecycle": {
|
||||
"activate": "Activate resource",
|
||||
"deactivate": "Deactivate resource",
|
||||
"delete": "Delete permanently",
|
||||
"description": "Delete “{{name}}”? The resource and its history will be deleted. This action cannot be undone.",
|
||||
"pending": "Deleting…",
|
||||
"error": "The resource could not be deleted.",
|
||||
"inUse": "The resource has current or historical associations. Deactivate it to preserve them or remove its associations before deleting it."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,7 +65,8 @@
|
||||
"COMPANY_NOT_FOUND": "No se ha encontrado la compañía.",
|
||||
"FORBIDDEN": "La sesión no permite esta operación. Vuelve a iniciar sesión.",
|
||||
"UNAUTHENTICATED": "Vuelve a iniciar sesión.",
|
||||
"INTERNAL_ERROR": "No se ha podido completar la operación."
|
||||
"INTERNAL_ERROR": "No se ha podido completar la operación.",
|
||||
"RESOURCE_IN_USE": "El recurso tiene asociaciones actuales o históricas. Desactívalo para conservarlas o retira sus asociaciones antes de eliminarlo."
|
||||
},
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
@ -214,5 +215,14 @@
|
||||
"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."
|
||||
},
|
||||
"resourceLifecycle": {
|
||||
"activate": "Activar recurso",
|
||||
"deactivate": "Desactivar recurso",
|
||||
"delete": "Eliminar definitivamente",
|
||||
"description": "¿Quieres eliminar «{{name}}»? Se borrarán el recurso y su historial. Esta acción no se puede deshacer.",
|
||||
"pending": "Eliminando…",
|
||||
"error": "No se ha podido eliminar el recurso.",
|
||||
"inUse": "El recurso tiene asociaciones actuales o históricas. Desactívalo para conservarlas o retira sus asociaciones antes de eliminarlo."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
import { ConfirmDeleteDialog } from "@repo/rdx-ui/components/confirm-delete-dialog";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { ResourceDTO } from "../../../../common/inventory";
|
||||
import { InventoryApiError, inventoryApi } from "../../shared/api";
|
||||
import { resourceRelatedQueryKeys } from "../../shared/api/resource-related-query-keys";
|
||||
|
||||
export function DeleteResourceDialog({
|
||||
resource,
|
||||
onClose,
|
||||
onDeleted,
|
||||
}: {
|
||||
resource: ResourceDTO;
|
||||
onClose: () => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("resources");
|
||||
const client = useQueryClient();
|
||||
const [errorCode, setErrorCode] = useState<string>();
|
||||
|
||||
return (
|
||||
<ConfirmDeleteDialog
|
||||
cancelLabel={t("cancel")}
|
||||
confirmLabel={t("resourceLifecycle.delete")}
|
||||
description={t("resourceLifecycle.description", { name: resource.values.name })}
|
||||
errorLabel={t(
|
||||
errorCode === "RESOURCE_IN_USE" ? "resourceLifecycle.inUse" : "resourceLifecycle.error"
|
||||
)}
|
||||
onConfirm={async () => {
|
||||
setErrorCode(undefined);
|
||||
try {
|
||||
await inventoryApi.remove(resource.companyId, resource.id);
|
||||
onDeleted();
|
||||
await Promise.all(
|
||||
resourceRelatedQueryKeys(resource.companyId).map((queryKey) =>
|
||||
client.invalidateQueries({ queryKey })
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
setErrorCode(error instanceof InventoryApiError ? error.code : "INTERNAL_ERROR");
|
||||
throw error;
|
||||
}
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
open
|
||||
pendingLabel={t("resourceLifecycle.pending")}
|
||||
title={t("resourceLifecycle.delete")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -5,10 +5,13 @@ import {
|
||||
useListQuery,
|
||||
} from "@metis/core/client";
|
||||
import { useCompactDetail } from "@repo/rdx-ui/hooks/use-compact-detail";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { ResourceDTO } from "../../../../common/inventory";
|
||||
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
|
||||
import { inventoryApi, inventoryKeys } from "../../shared/api";
|
||||
import { resourceRelatedQueryKeys } from "../../shared/api/resource-related-query-keys";
|
||||
import type { ResourcesPageProps } from "../types";
|
||||
|
||||
function initialEditor(create: boolean) {
|
||||
@ -33,6 +36,19 @@ export function useResourcesPageController({
|
||||
});
|
||||
|
||||
const [editor, setEditor] = useState<string | undefined>(() => initialEditor(initialCreate));
|
||||
const [deleteId, setDeleteId] = useState<string>();
|
||||
const client = useQueryClient();
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (resource: ResourceDTO) =>
|
||||
inventoryApi.setActive(companyId, resource.id, !resource.values.active),
|
||||
onSuccess: async () => {
|
||||
await Promise.all(
|
||||
resourceRelatedQueryKeys(companyId).map((queryKey) =>
|
||||
client.invalidateQueries({ queryKey })
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
const {
|
||||
selectedId,
|
||||
selectEntity,
|
||||
@ -111,6 +127,23 @@ export function useResourcesPageController({
|
||||
}) ?? [];
|
||||
|
||||
return {
|
||||
deleteId,
|
||||
setDeleteId,
|
||||
statusMutation,
|
||||
resourceDeleted: (id: string) => {
|
||||
if (selectedId === id) {
|
||||
if (routed) {
|
||||
const search = searchParams.toString();
|
||||
|
||||
navigateTo(`/companies/${companyId}/resources${search ? `?${search}` : ""}`);
|
||||
} else {
|
||||
selectEntity(undefined);
|
||||
}
|
||||
}
|
||||
if (viewId === id) {
|
||||
setLocalViewId(undefined);
|
||||
}
|
||||
},
|
||||
search,
|
||||
setSearch,
|
||||
editor,
|
||||
|
||||
@ -16,6 +16,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ResourceTemplateIcon } from "../../../catalog-templates/shared/ui/resource-template-icon";
|
||||
import { InventoryFeedback } from "../../../shared";
|
||||
import { ResourceDetailDialog, ResourceSelectionPanel } from "../../detail";
|
||||
import { DeleteResourceDialog } from "../../lifecycle/ui/delete-resource-dialog";
|
||||
import { ResourceEditorDialog } from "../../shared";
|
||||
import { useResourcesPageController } from "../controllers";
|
||||
import type { ResourcesPageProps } from "../types";
|
||||
@ -45,9 +46,14 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
||||
selectResource,
|
||||
detailOpen,
|
||||
setDetailOpen,
|
||||
deleteId,
|
||||
setDeleteId,
|
||||
statusMutation,
|
||||
resourceDeleted,
|
||||
} = useResourcesPageController(props);
|
||||
const { t, i18n } = useTranslation("resources");
|
||||
const tabValue = templateId ?? "all";
|
||||
const deleting = query.data?.resources.find((resource) => resource.id === deleteId);
|
||||
|
||||
if (missingView) {
|
||||
return <EntityNotFound />;
|
||||
@ -87,6 +93,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
||||
|
||||
{loading && <p role="status">{t("loading")}</p>}
|
||||
<InventoryFeedback error={query.error ?? catalog.error} />
|
||||
<InventoryFeedback error={statusMutation.error} />
|
||||
{(query.isError || catalog.isError) && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
@ -190,6 +197,23 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
||||
<EntityAction onClick={() => setViewId(item.id)}>
|
||||
{t("viewResource")}
|
||||
</EntityAction>
|
||||
<EntityAction
|
||||
disabled={statusMutation.isPending}
|
||||
onClick={() => statusMutation.mutate(item)}
|
||||
>
|
||||
{t(
|
||||
item.values.active
|
||||
? "resourceLifecycle.deactivate"
|
||||
: "resourceLifecycle.activate"
|
||||
)}
|
||||
</EntityAction>
|
||||
<EntityAction
|
||||
className="text-destructive"
|
||||
disabled={statusMutation.isPending}
|
||||
onClick={() => setDeleteId(item.id)}
|
||||
>
|
||||
{t("resourceLifecycle.delete")}
|
||||
</EntityAction>
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
@ -251,6 +275,17 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
||||
resource={viewed}
|
||||
/>
|
||||
)}
|
||||
{deleting && (
|
||||
<DeleteResourceDialog
|
||||
onClose={() => setDeleteId(undefined)}
|
||||
onDeleted={() => {
|
||||
if (deleteId) {
|
||||
resourceDeleted(deleteId);
|
||||
}
|
||||
}}
|
||||
resource={deleting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,7 +2,11 @@ import { csrfFetch } from "@metis/core/client";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { ResourceInput } from "../../../../common/inventory";
|
||||
import { InventoryResponseSchema, ResourceResponseSchema } from "../../../../common/inventory";
|
||||
import {
|
||||
DeleteResourceResponseSchema,
|
||||
InventoryResponseSchema,
|
||||
ResourceResponseSchema,
|
||||
} from "../../../../common/inventory";
|
||||
|
||||
export const inventoryKeys = { company: (id: string) => ["resource-inventory", id] };
|
||||
|
||||
@ -31,6 +35,12 @@ async function request(path: string, body?: unknown) {
|
||||
}
|
||||
|
||||
export const inventoryApi = {
|
||||
async setActive(companyId: string, id: string, active: boolean) {
|
||||
return ResourceResponseSchema.parse(await request(`${companyId}/${id}/status`, { active }));
|
||||
},
|
||||
async remove(companyId: string, id: string) {
|
||||
return DeleteResourceResponseSchema.parse(await request(`${companyId}/${id}/delete`, {}));
|
||||
},
|
||||
async get(companyId: string) {
|
||||
return InventoryResponseSchema.parse(await request(companyId));
|
||||
},
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
import { inventoryKeys } from "./inventory-api";
|
||||
|
||||
/** Listados que consumen valores o proyecciones de un recurso. */
|
||||
export function resourceRelatedQueryKeys(companyId: string) {
|
||||
return [
|
||||
inventoryKeys.company(companyId),
|
||||
["mailboxes"],
|
||||
["mail-configurations"],
|
||||
["access"],
|
||||
["folder-mappings"],
|
||||
["application-configurations"],
|
||||
["certificate-configurations"],
|
||||
["remote-connections", companyId],
|
||||
["asset-support", companyId],
|
||||
["software-installations", companyId],
|
||||
];
|
||||
}
|
||||
@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { ResourceInput } from "../../../../common/inventory";
|
||||
import { ResourceInputSchema } from "../../../../common/inventory";
|
||||
import { inventoryApi, inventoryKeys } from "../api";
|
||||
import { resourceRelatedQueryKeys } from "../api/resource-related-query-keys";
|
||||
import { mailboxIntegration, resourceDefaults, resourceFields } from "../helpers";
|
||||
import { hasMissingResourceFields, suggestedResourceName } from "../helpers/resource-form-values";
|
||||
import type { ResourceFormProps } from "../types";
|
||||
@ -75,17 +76,9 @@ export function useResourceFormController({
|
||||
mutationFn: (input: ResourceInput) => inventoryApi.save(companyId, input, resource?.id),
|
||||
onSuccess: async (response) => {
|
||||
await Promise.all(
|
||||
[
|
||||
inventoryKeys.company(companyId),
|
||||
["mailboxes"],
|
||||
["mail-configurations"],
|
||||
["access"],
|
||||
["folder-mappings"],
|
||||
["application-configurations"],
|
||||
["certificate-configurations"],
|
||||
["remote-connections", companyId],
|
||||
["asset-support", companyId],
|
||||
].map((queryKey) => client.invalidateQueries({ queryKey }))
|
||||
resourceRelatedQueryKeys(companyId).map((queryKey) =>
|
||||
client.invalidateQueries({ queryKey })
|
||||
)
|
||||
);
|
||||
if (mounted.current) {
|
||||
onSaved(response.resource.id);
|
||||
|
||||
@ -5,14 +5,16 @@ export function FormSectionTitle({
|
||||
children,
|
||||
id,
|
||||
as: Heading = "h3",
|
||||
layout = Heading === "span" ? "inline" : "standalone",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
id?: string;
|
||||
as?: "h3" | "h4" | "h5" | "legend" | "span";
|
||||
layout?: "standalone" | "inline";
|
||||
}) {
|
||||
return (
|
||||
<Heading
|
||||
className={`text-xs font-semibold uppercase tracking-wide text-muted-foreground ${Heading === "span" ? "" : "mb-4"}`}
|
||||
className={`text-xs font-semibold uppercase tracking-wide text-muted-foreground ${layout === "inline" ? "" : "mb-4"}`}
|
||||
id={id}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -86,12 +86,14 @@ export function TemplateSectionFields({
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FormSectionTitle as="h5" id={`${prefix}-title`}>
|
||||
{sectionId || !sections.length ? labels.fields : labels.ungrouped}
|
||||
</FormSectionTitle>
|
||||
<span className="rounded-full border bg-background px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{grouped.length}
|
||||
</span>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FormSectionTitle as="h5" id={`${prefix}-title`} layout="inline">
|
||||
{sectionId || !sections.length ? labels.fields : labels.ungrouped}
|
||||
</FormSectionTitle>
|
||||
<span className="shrink-0 rounded-full border bg-background px-2 py-0.5 text-xs text-muted-foreground tabular-nums">
|
||||
{grouped.length}
|
||||
</span>
|
||||
</div>
|
||||
{addAction && (
|
||||
<Button
|
||||
aria-haspopup="dialog"
|
||||
|
||||
@ -110,7 +110,9 @@ export function TemplateSectionsEditor({
|
||||
return (
|
||||
<section aria-labelledby={`${prefix}-title`} className="@container/template-sections space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<FormSectionTitle id={`${prefix}-title`}>{labels.title}</FormSectionTitle>
|
||||
<FormSectionTitle id={`${prefix}-title`} layout="inline">
|
||||
{labels.title}
|
||||
</FormSectionTitle>
|
||||
<Button
|
||||
disabled={disabled || sections.length >= 30}
|
||||
onClick={() => saveSections([...sections, { id: createId(), name: "", description: "" }])}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user