Añadir icono a las plantillas de activos
This commit is contained in:
parent
8c5b7b4df0
commit
108e810c3b
@ -71,10 +71,18 @@ export async function verifyAssets(
|
|||||||
403
|
403
|
||||||
);
|
);
|
||||||
assert.equal((await request(`${global}/${stock.id}/status`, { active: false })).status, 200);
|
assert.equal((await request(`${global}/${stock.id}/status`, { active: false })).status, 200);
|
||||||
|
// Reproduce una BD existente anterior a la columna de icono.
|
||||||
|
await database.query("ALTER TABLE asset_definitions DROP COLUMN icon");
|
||||||
|
await createAssetModule(database, (_request, _response, next) => next());
|
||||||
await createAssetModule(database, (_request, _response, next) => next());
|
await createAssetModule(database, (_request, _response, next) => next());
|
||||||
const restarted = await read(global);
|
const restarted = await read(global);
|
||||||
|
|
||||||
assert.equal(restarted.items.find((item) => item.id === stock.id)?.effectiveActive, false);
|
assert.equal(restarted.items.find((item) => item.id === stock.id)?.effectiveActive, false);
|
||||||
|
assert.ok(
|
||||||
|
restarted.items
|
||||||
|
.filter((item) => item.values.kind === "template")
|
||||||
|
.every((item) => item.values.kind === "template" && item.values.icon)
|
||||||
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
restarted.items.map((item) => item.id).sort(),
|
restarted.items.map((item) => item.id).sort(),
|
||||||
initial.items.map((item) => item.id).sort()
|
initial.items.map((item) => item.id).sort()
|
||||||
@ -123,6 +131,7 @@ export async function verifyAssets(
|
|||||||
const templateValues = {
|
const templateValues = {
|
||||||
kind: "template",
|
kind: "template",
|
||||||
name: "Equipo especial",
|
name: "Equipo especial",
|
||||||
|
icon: "laptop",
|
||||||
sections: [section],
|
sections: [section],
|
||||||
description: "",
|
description: "",
|
||||||
fields: [
|
fields: [
|
||||||
@ -138,12 +147,32 @@ export async function verifyAssets(
|
|||||||
|
|
||||||
assert.ok(saved && saved.values.kind === "template");
|
assert.ok(saved && saved.values.kind === "template");
|
||||||
assert.deepEqual(saved.values.sections, [section]);
|
assert.deepEqual(saved.values.sections, [section]);
|
||||||
|
assert.equal(saved.values.icon, "laptop");
|
||||||
const edited = await save(`${company}/${saved.id}/update`, {
|
const edited = await save(`${company}/${saved.id}/update`, {
|
||||||
...templateValues,
|
...templateValues,
|
||||||
name: "Equipo especial actualizado",
|
name: "Equipo especial actualizado",
|
||||||
|
icon: "printer",
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(edited.savedId, saved.id);
|
assert.equal(edited.savedId, saved.id);
|
||||||
|
const storedIcon = (await read(company)).items.find((item) => item.id === saved.id);
|
||||||
|
|
||||||
|
assert.ok(storedIcon?.values.kind === "template");
|
||||||
|
assert.equal(storedIcon.values.icon, "printer");
|
||||||
|
const { icon: removedIcon, ...withoutIcon } = templateValues;
|
||||||
|
|
||||||
|
assert.equal(removedIcon, "laptop");
|
||||||
|
await save(`${company}/${saved.id}/update`, withoutIcon);
|
||||||
|
const clearedIcon = (await read(company)).items.find((item) => item.id === saved.id);
|
||||||
|
|
||||||
|
assert.ok(clearedIcon?.values.kind === "template");
|
||||||
|
assert.equal(clearedIcon.values.icon, undefined);
|
||||||
|
await error(
|
||||||
|
company,
|
||||||
|
{ values: { ...templateValues, icon: "unknown-icon" } },
|
||||||
|
"INVALID_REQUEST",
|
||||||
|
400
|
||||||
|
);
|
||||||
assert.deepEqual(edited.items.find((item) => item.id === saved.id)?.bindings, saved.bindings);
|
assert.deepEqual(edited.items.find((item) => item.id === saved.id)?.bindings, saved.bindings);
|
||||||
await error(
|
await error(
|
||||||
company,
|
company,
|
||||||
@ -236,6 +265,7 @@ export async function verifyAssets(
|
|||||||
const groupedGlobal = await save(global, {
|
const groupedGlobal = await save(global, {
|
||||||
kind: "template",
|
kind: "template",
|
||||||
name: "Plantilla global agrupada assets",
|
name: "Plantilla global agrupada assets",
|
||||||
|
icon: "server",
|
||||||
description: "",
|
description: "",
|
||||||
sections: [globalSection],
|
sections: [globalSection],
|
||||||
fields: [{ fieldId: stock.id, required: false, sectionId: globalSection.id }],
|
fields: [{ fieldId: stock.id, required: false, sectionId: globalSection.id }],
|
||||||
@ -246,6 +276,7 @@ export async function verifyAssets(
|
|||||||
|
|
||||||
assert.ok(groupedStored?.values.kind === "template");
|
assert.ok(groupedStored?.values.kind === "template");
|
||||||
assert.deepEqual(groupedStored.values.sections, [globalSection]);
|
assert.deepEqual(groupedStored.values.sections, [globalSection]);
|
||||||
|
assert.equal(groupedStored.values.icon, "server");
|
||||||
await error(
|
await error(
|
||||||
global,
|
global,
|
||||||
{
|
{
|
||||||
|
|||||||
@ -48,3 +48,13 @@ El [inventario de activos](inventario-de-activos.md) utiliza estas plantillas. E
|
|||||||
## Organización de campos
|
## Organización de campos
|
||||||
|
|
||||||
Las plantillas permiten organizar campos en secciones ordenadas, globales o propias de compañía. Consultar [Secciones de campos en plantillas](secciones-de-campos-en-plantillas.md) para las reglas, presentación y conservación de datos existentes.
|
Las plantillas permiten organizar campos en secciones ordenadas, globales o propias de compañía. Consultar [Secciones de campos en plantillas](secciones-de-campos-en-plantillas.md) para las reglas, presentación y conservación de datos existentes.
|
||||||
|
|
||||||
|
## Iconos de plantilla
|
||||||
|
|
||||||
|
Cada plantilla global o propia de compañía admite un icono opcional de la biblioteca Lucide existente. El formulario incluye un selector visual con nombres traducidos, selección marcada y opción «Sin icono». Las copias conservan el icono; las plantillas personalizadas permiten cambiarlo y retirarlo.
|
||||||
|
|
||||||
|
Las doce plantillas de serie incluyen iconos adecuados a cada equipo. El arranque completa los iconos ausentes de instalaciones existentes de forma idempotente, conservando identidades, campos, secciones y disponibilidad. Se mantiene la protección de las definiciones de serie.
|
||||||
|
|
||||||
|
El catálogo, su consulta de detalle, la columna Plantilla del inventario y la cabecera del panel del activo muestran el icono. Los activos toman el icono actual de su plantilla: es metadato de presentación y no modifica características ni historial técnico.
|
||||||
|
|
||||||
|
Se persiste la clave validada del icono en la columna nullable `asset_definitions.icon`, sin JSON ni archivos de imagen. La migración añade la columna en instalaciones existentes. Los campos y las relaciones no admiten iconos en este contrato.
|
||||||
|
|||||||
@ -328,3 +328,7 @@ Los listados comparten la preferencia entity-page-size en localStorage: el núme
|
|||||||
`ConfirmDeleteDialog` de `@repo/rdx-ui` presenta una confirmación modal con descripción de consecuencias, Cancelar y Eliminar. El consumidor aporta los textos traducidos y una operación asíncrona. Mientras se ejecuta, evita envíos repetidos y el cierre; si falla, mantiene el diálogo abierto y muestra un error. Solo se cierra tras completar la operación.
|
`ConfirmDeleteDialog` de `@repo/rdx-ui` presenta una confirmación modal con descripción de consecuencias, Cancelar y Eliminar. El consumidor aporta los textos traducidos y una operación asíncrona. Mientras se ejecuta, evita envíos repetidos y el cierre; si falla, mantiene el diálogo abierto y muestra un error. Solo se cierra tras completar la operación.
|
||||||
|
|
||||||
Los módulos conectan este componente mediante `DeleteRecordDialog`, separado de los menús de fila para que la confirmación permanezca montada cuando se cierre el menú. La operación invalida la consulta de la entidad y las revisiones de procesos de la compañía.
|
Los módulos conectan este componente mediante `DeleteRecordDialog`, separado de los menús de fila para que la confirmación permanezca montada cuando se cierre el menú. La operación invalida la consulta de la entidad y las revisiones de procesos de la compañía.
|
||||||
|
|
||||||
|
## IconPicker
|
||||||
|
|
||||||
|
`@repo/rdx-ui/components/icon-picker` presenta opciones visuales mediante botones con nombre accesible, tooltip nativo y `aria-pressed`. Recibe opciones (valor, etiqueta traducida e icono), valor actual, callback y estado deshabilitado. No conoce entidades ni endpoints. El catálogo de plantillas de activos aporta los iconos Lucide y valida sus claves; el componente admite teclado y distribución responsive.
|
||||||
|
|||||||
@ -10,7 +10,7 @@ La persistencia de activos no utiliza JSON para definiciones, características n
|
|||||||
|
|
||||||
- un campo usa `fieldType` y `unit`;
|
- un campo usa `fieldType` y `unit`;
|
||||||
- una relación usa `inverseName` y `symmetric`;
|
- una relación usa `inverseName` y `symmetric`;
|
||||||
- una plantilla se compone mediante sus tablas relacionadas.
|
- una plantilla admite una clave de icono opcional en la columna nullable `icon` y se compone mediante sus tablas relacionadas.
|
||||||
|
|
||||||
Las opciones de un campo de lista se guardan en `asset_field_choices`. Las secciones de una plantilla se guardan en `asset_template_sections`. `asset_template_fields` une plantilla y campo, e incluye obligatoriedad, posición y sección opcional.
|
Las opciones de un campo de lista se guardan en `asset_field_choices`. Las secciones de una plantilla se guardan en `asset_template_sections`. `asset_template_fields` une plantilla y campo, e incluye obligatoriedad, posición y sección opcional.
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { UniqueID } from "@repo/rdx-ddd";
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
|
|
||||||
|
import type { AssetTemplateIconKey } from "../../common/asset-template-icons";
|
||||||
|
|
||||||
import type { AssetCatalog } from "./asset-catalog.aggregate";
|
import type { AssetCatalog } from "./asset-catalog.aggregate";
|
||||||
import type { AssetDefinitionSpec, AssetFieldType } from "./asset-definition";
|
import type { AssetDefinitionSpec, AssetFieldType } from "./asset-definition";
|
||||||
import { AssetDefinitionValues } from "./value-objects/asset-definition-values.vo";
|
import { AssetDefinitionValues } from "./value-objects/asset-definition-values.vo";
|
||||||
@ -74,6 +76,21 @@ const TEMPLATES: readonly [string, string, readonly string[]][] = [
|
|||||||
["smartphone", "Smartphone", ["os", "storage", "screen_size", "imei", "phone", "carrier"]],
|
["smartphone", "Smartphone", ["os", "storage", "screen_size", "imei", "phone", "carrier"]],
|
||||||
["ups", "SAI", ["power", "battery", "connections"]],
|
["ups", "SAI", ["power", "battery", "connections"]],
|
||||||
];
|
];
|
||||||
|
const TEMPLATE_ICONS: Readonly<Record<string, AssetTemplateIconKey>> = {
|
||||||
|
laptop: "laptop",
|
||||||
|
desktop: "desktop",
|
||||||
|
monitor: "monitor",
|
||||||
|
printer: "printer",
|
||||||
|
router: "router",
|
||||||
|
firewall: "shield",
|
||||||
|
switch: "network",
|
||||||
|
access_point: "wifi",
|
||||||
|
nas: "hard-drive",
|
||||||
|
server: "server",
|
||||||
|
smartphone: "smartphone",
|
||||||
|
ups: "battery",
|
||||||
|
};
|
||||||
|
|
||||||
const SECTION_FIELDS: readonly [string, readonly string[]][] = [
|
const SECTION_FIELDS: readonly [string, readonly string[]][] = [
|
||||||
["Identificación", ["brand", "model", "serial"]],
|
["Identificación", ["brand", "model", "serial"]],
|
||||||
["Hardware", ["processor", "memory", "storage", "capacity", "disks", "raid"]],
|
["Hardware", ["processor", "memory", "storage", "capacity", "disks", "raid"]],
|
||||||
@ -131,6 +148,7 @@ export function seedAssetCatalog(catalog: AssetCatalog) {
|
|||||||
|
|
||||||
add(`template:${key}`, {
|
add(`template:${key}`, {
|
||||||
kind: "template",
|
kind: "template",
|
||||||
|
icon: TEMPLATE_ICONS[key],
|
||||||
name,
|
name,
|
||||||
description: "",
|
description: "",
|
||||||
fields: references,
|
fields: references,
|
||||||
|
|||||||
@ -141,7 +141,7 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
|
|||||||
const previous = this.definitions.find((item) => item.stockKey.getOrUndefined() === stockKey);
|
const previous = this.definitions.find((item) => item.stockKey.getOrUndefined() === stockKey);
|
||||||
|
|
||||||
if (previous) {
|
if (previous) {
|
||||||
const upgraded = previous.initializeSections(values);
|
const upgraded = previous.initializeSections(values).initializeIcon(values);
|
||||||
|
|
||||||
if (upgraded === previous) {
|
if (upgraded === previous) {
|
||||||
return Result.ok(this);
|
return Result.ok(this);
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { Maybe } from "@repo/rdx-utils";
|
|||||||
import { AssetCatalog } from "./asset-catalog.aggregate";
|
import { AssetCatalog } from "./asset-catalog.aggregate";
|
||||||
import { seedAssetCatalog } from "./asset-catalog-seed";
|
import { seedAssetCatalog } from "./asset-catalog-seed";
|
||||||
import type { AssetDefinitionSpec } from "./asset-definition";
|
import type { AssetDefinitionSpec } from "./asset-definition";
|
||||||
|
import { AssetDefinitionSchema } from "./asset-definition";
|
||||||
|
import { AssetDefinition } from "./asset-definition.entity";
|
||||||
import { AssetDefinitionValues } from "./value-objects/asset-definition-values.vo";
|
import { AssetDefinitionValues } from "./value-objects/asset-definition-values.vo";
|
||||||
|
|
||||||
const global = Maybe.none<UniqueID>();
|
const global = Maybe.none<UniqueID>();
|
||||||
@ -160,3 +162,111 @@ test("los campos validan listas, igualdad semántica, duplicados y tipo fijo", (
|
|||||||
).isSuccess
|
).isSuccess
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("iconos opcionales: cambio y retirada conservan identidad y asociaciones", () => {
|
||||||
|
let catalog = seedAssetCatalog(AssetCatalog.create());
|
||||||
|
const fieldId = catalog.definitions[0]?.id.toPrimitive();
|
||||||
|
|
||||||
|
assert.ok(fieldId);
|
||||||
|
const base = {
|
||||||
|
kind: "template",
|
||||||
|
name: "Equipo con icono",
|
||||||
|
description: "",
|
||||||
|
fields: [{ fieldId, required: false }],
|
||||||
|
} satisfies AssetDefinitionSpec;
|
||||||
|
const company = Maybe.some(UniqueID.generateNewID());
|
||||||
|
|
||||||
|
for (const scope of [global, company]) {
|
||||||
|
const input = {
|
||||||
|
...base,
|
||||||
|
name: scope === global ? "Plantilla global con icono" : "Plantilla local con icono",
|
||||||
|
};
|
||||||
|
|
||||||
|
catalog = changed(catalog.save(scope, Maybe.none(), values(input)));
|
||||||
|
const original = catalog.definitions.at(-1);
|
||||||
|
|
||||||
|
assert.ok(original);
|
||||||
|
catalog = changed(
|
||||||
|
catalog.save(scope, Maybe.some(original.id), values({ ...input, icon: "laptop" }))
|
||||||
|
);
|
||||||
|
const updated = catalog.definitions.find((item) => item.id.equals(original.id));
|
||||||
|
|
||||||
|
assert.ok(updated?.values.data.kind === "template");
|
||||||
|
assert.equal(updated.values.data.icon, "laptop");
|
||||||
|
assert.ok(updated.bindings[0]?.id.equals(original.bindings[0]?.id));
|
||||||
|
assert.equal(changed(catalog.save(scope, Maybe.some(original.id), updated.values)), catalog);
|
||||||
|
catalog = changed(catalog.save(scope, Maybe.some(original.id), values(input)));
|
||||||
|
const cleared = catalog.definitions.find((item) => item.id.equals(original.id));
|
||||||
|
|
||||||
|
assert.ok(cleared?.values.data.kind === "template");
|
||||||
|
assert.equal(cleared.values.data.icon, undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el arranque completa iconos de serie sin cambiar UUID, campos o disponibilidad", () => {
|
||||||
|
const initial = seedAssetCatalog(AssetCatalog.create());
|
||||||
|
const template = initial.definitions.find(
|
||||||
|
(item) => item.stockKey.getOrUndefined() === "template:laptop"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(template?.values.data.kind === "template");
|
||||||
|
assert.equal(template.values.data.icon, "laptop");
|
||||||
|
const { icon, ...withoutIcon } = template.values.data;
|
||||||
|
|
||||||
|
assert.ok(icon);
|
||||||
|
const previous = AssetDefinition.rehydrate(
|
||||||
|
{
|
||||||
|
companyId: template.companyId,
|
||||||
|
stockKey: template.stockKey,
|
||||||
|
values: values(withoutIcon),
|
||||||
|
active: false,
|
||||||
|
bindings: template.bindings,
|
||||||
|
},
|
||||||
|
template.id
|
||||||
|
);
|
||||||
|
const upgraded = seedAssetCatalog(
|
||||||
|
AssetCatalog.rehydrate(
|
||||||
|
{
|
||||||
|
definitions: initial.definitions.map((item) =>
|
||||||
|
item.id.equals(template.id) ? previous : item
|
||||||
|
),
|
||||||
|
availability: initial.availability,
|
||||||
|
},
|
||||||
|
initial.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const saved = upgraded.definitions.find((item) => item.id.equals(template.id));
|
||||||
|
|
||||||
|
assert.ok(saved?.values.data.kind === "template");
|
||||||
|
assert.equal(saved.values.data.icon, "laptop");
|
||||||
|
assert.equal(saved.active, false);
|
||||||
|
assert.deepEqual(saved.values.data.fields, template.values.data.fields);
|
||||||
|
assert.deepEqual(saved.bindings, template.bindings);
|
||||||
|
assert.equal(seedAssetCatalog(upgraded), upgraded);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("los iconos desconocidos o asociados a campos se rechazan", () => {
|
||||||
|
const template = {
|
||||||
|
kind: "template",
|
||||||
|
name: "Equipo",
|
||||||
|
description: "",
|
||||||
|
fields: [{ fieldId: UniqueID.generateNewID().toPrimitive(), required: false }],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
AssetDefinitionSchema.safeParse({ ...template, icon: "unknown-icon" }).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
AssetDefinitionSchema.safeParse({
|
||||||
|
kind: "field",
|
||||||
|
name: "Campo",
|
||||||
|
description: "",
|
||||||
|
type: "text",
|
||||||
|
unit: "",
|
||||||
|
choices: [],
|
||||||
|
icon: "laptop",
|
||||||
|
}).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@ -154,6 +154,29 @@ export class AssetDefinition extends DomainEntity<DefinitionProps> {
|
|||||||
return new AssetDefinition({ ...this.props, values: values.data }, this.id);
|
return new AssetDefinition({ ...this.props, values: values.data }, this.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Completa el icono de serie sin sobrescribir configuración existente. */
|
||||||
|
initializeIcon(source: AssetDefinitionValues) {
|
||||||
|
const previous = this.values.data;
|
||||||
|
const next = source.data;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.stockKey.getOrUndefined() ||
|
||||||
|
previous.kind !== "template" ||
|
||||||
|
next.kind !== "template" ||
|
||||||
|
previous.icon !== undefined ||
|
||||||
|
!next.icon
|
||||||
|
) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
const values = AssetDefinitionValues.create({ ...previous, icon: next.icon });
|
||||||
|
|
||||||
|
if (!values.isSuccess) {
|
||||||
|
throw new Error("Icono inicial de plantilla inconsistente.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AssetDefinition({ ...this.props, values: values.data }, this.id);
|
||||||
|
}
|
||||||
|
|
||||||
setActive(active: boolean) {
|
setActive(active: boolean) {
|
||||||
return active === this.active ? this : new AssetDefinition({ ...this.props, active }, this.id);
|
return active === this.active ? this : new AssetDefinition({ ...this.props, active }, this.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { AssetTemplateIconSchema } from "../../common/asset-template-icons";
|
||||||
|
|
||||||
import { validTemplateSections } from "./template-sections";
|
import { validTemplateSections } from "./template-sections";
|
||||||
|
|
||||||
const title = z.string().min(1).max(100);
|
const title = z.string().min(1).max(100);
|
||||||
@ -30,6 +32,7 @@ export const AssetDefinitionSchema = z.discriminatedUnion("kind", [
|
|||||||
z
|
z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
kind: z.literal("template"),
|
kind: z.literal("template"),
|
||||||
|
icon: AssetTemplateIconSchema.optional(),
|
||||||
name: title,
|
name: title,
|
||||||
description: z.string().max(1000),
|
description: z.string().max(1000),
|
||||||
fields: z.array(TemplateFieldSchema).min(1).max(100),
|
fields: z.array(TemplateFieldSchema).min(1).max(100),
|
||||||
|
|||||||
@ -128,6 +128,7 @@ export class AssetDefinitionValues {
|
|||||||
return (
|
return (
|
||||||
a.kind === "template" &&
|
a.kind === "template" &&
|
||||||
b.kind === "template" &&
|
b.kind === "template" &&
|
||||||
|
a.icon === b.icon &&
|
||||||
sameTemplateSections(a.sections ?? [], b.sections ?? []) &&
|
sameTemplateSections(a.sections ?? [], b.sections ?? []) &&
|
||||||
a.fields.length === b.fields.length &&
|
a.fields.length === b.fields.length &&
|
||||||
a.fields.every(
|
a.fields.every(
|
||||||
|
|||||||
@ -19,6 +19,7 @@ type RelationalDefinition = Omit<DefinitionRow, "values"> & {
|
|||||||
kind: string;
|
kind: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
icon: string | null;
|
||||||
fieldType: string | null;
|
fieldType: string | null;
|
||||||
unit: string | null;
|
unit: string | null;
|
||||||
inverseName: string | null;
|
inverseName: string | null;
|
||||||
@ -101,6 +102,7 @@ export async function readDefinitions(
|
|||||||
break;
|
break;
|
||||||
case "template":
|
case "template":
|
||||||
values = {
|
values = {
|
||||||
|
...(row.icon ? { icon: row.icon } : {}),
|
||||||
...common,
|
...common,
|
||||||
kind: row.kind,
|
kind: row.kind,
|
||||||
fields: bindings
|
fields: bindings
|
||||||
@ -155,6 +157,7 @@ export async function writeDefinition(
|
|||||||
kind: values.kind,
|
kind: values.kind,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
description: values.description,
|
description: values.description,
|
||||||
|
icon: values.kind === "template" ? (values.icon ?? null) : null,
|
||||||
fieldType: values.kind === "field" ? values.type : null,
|
fieldType: values.kind === "field" ? values.type : null,
|
||||||
unit: values.kind === "field" ? values.unit : null,
|
unit: values.kind === "field" ? values.unit : null,
|
||||||
inverseName: values.kind === "relation" ? values.inverseName : null,
|
inverseName: values.kind === "relation" ? values.inverseName : null,
|
||||||
|
|||||||
@ -85,6 +85,11 @@ export async function migrateAssetCatalog(database: Sequelize) {
|
|||||||
|
|
||||||
await create("asset_catalogs", catalog);
|
await create("asset_catalogs", catalog);
|
||||||
await create("asset_definitions", definition);
|
await create("asset_definitions", definition);
|
||||||
|
const columns = await query.describeTable("asset_definitions");
|
||||||
|
|
||||||
|
if (!columns.icon) {
|
||||||
|
await query.addColumn("asset_definitions", "icon", definitionColumns.icon);
|
||||||
|
}
|
||||||
await create("asset_template_fields", binding);
|
await create("asset_template_fields", binding);
|
||||||
await create("asset_availability", availability);
|
await create("asset_availability", availability);
|
||||||
for (const [table, fields, name] of [
|
for (const [table, fields, name] of [
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export const definitionColumns = {
|
|||||||
kind: { type: DataTypes.STRING(12), allowNull: false },
|
kind: { type: DataTypes.STRING(12), allowNull: false },
|
||||||
name: title,
|
name: title,
|
||||||
description: { type: DataTypes.TEXT, allowNull: false },
|
description: { type: DataTypes.TEXT, allowNull: false },
|
||||||
|
icon: { type: DataTypes.STRING(30), allowNull: true },
|
||||||
fieldType: { type: DataTypes.STRING(12), allowNull: true },
|
fieldType: { type: DataTypes.STRING(12), allowNull: true },
|
||||||
unit: { type: DataTypes.STRING(30), allowNull: true },
|
unit: { type: DataTypes.STRING(30), allowNull: true },
|
||||||
inverseName: { ...title, allowNull: true },
|
inverseName: { ...title, allowNull: true },
|
||||||
|
|||||||
24
modules/assets/src/common/asset-template-icons.ts
Normal file
24
modules/assets/src/common/asset-template-icons.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
export const AssetTemplateIconSchema = z.enum([
|
||||||
|
"laptop",
|
||||||
|
"desktop",
|
||||||
|
"monitor",
|
||||||
|
"printer",
|
||||||
|
"router",
|
||||||
|
"shield",
|
||||||
|
"network",
|
||||||
|
"wifi",
|
||||||
|
"hard-drive",
|
||||||
|
"server",
|
||||||
|
"smartphone",
|
||||||
|
"battery",
|
||||||
|
"tablet",
|
||||||
|
"keyboard",
|
||||||
|
"mouse",
|
||||||
|
"headphones",
|
||||||
|
"camera",
|
||||||
|
"box",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type AssetTemplateIconKey = z.infer<typeof AssetTemplateIconSchema>;
|
||||||
@ -301,5 +301,30 @@
|
|||||||
"description": "This record and its history will be deleted. Related assets, resources, accounts and people will be preserved. This action cannot be undone.",
|
"description": "This record and its history will be deleted. Related assets, resources, accounts and people will be preserved. This action cannot be undone.",
|
||||||
"pending": "Deleting…",
|
"pending": "Deleting…",
|
||||||
"error": "The record could not be deleted. Please try again."
|
"error": "The record could not be deleted. Please try again."
|
||||||
|
},
|
||||||
|
"templateIcon": {
|
||||||
|
"label": "Icon (optional)",
|
||||||
|
"none": "No icon",
|
||||||
|
"help": "Visually identifies the template and its assets. You can change or remove it on custom templates.",
|
||||||
|
"options": {
|
||||||
|
"laptop": "Laptop",
|
||||||
|
"desktop": "Desktop computer",
|
||||||
|
"monitor": "Monitor",
|
||||||
|
"printer": "Printer",
|
||||||
|
"router": "Router",
|
||||||
|
"shield": "Security / firewall",
|
||||||
|
"network": "Switch / network",
|
||||||
|
"wifi": "Wi-Fi",
|
||||||
|
"hard-drive": "NAS / storage",
|
||||||
|
"server": "Server",
|
||||||
|
"smartphone": "Smartphone",
|
||||||
|
"battery": "UPS / battery",
|
||||||
|
"tablet": "Tablet",
|
||||||
|
"keyboard": "Keyboard",
|
||||||
|
"mouse": "Mouse",
|
||||||
|
"headphones": "Headphones",
|
||||||
|
"camera": "Camera",
|
||||||
|
"box": "Other asset"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -301,5 +301,30 @@
|
|||||||
"description": "Se eliminarán este registro y su historial. Los activos, recursos, cuentas y personas relacionados se conservarán. Esta acción no se puede deshacer.",
|
"description": "Se eliminarán este registro y su historial. Los activos, recursos, cuentas y personas relacionados se conservarán. Esta acción no se puede deshacer.",
|
||||||
"pending": "Eliminando…",
|
"pending": "Eliminando…",
|
||||||
"error": "No se ha podido eliminar el registro. Inténtalo de nuevo."
|
"error": "No se ha podido eliminar el registro. Inténtalo de nuevo."
|
||||||
|
},
|
||||||
|
"templateIcon": {
|
||||||
|
"label": "Icono (opcional)",
|
||||||
|
"none": "Sin icono",
|
||||||
|
"help": "Identifica visualmente la plantilla y sus activos. Puedes cambiarlo o quitarlo en las plantillas personalizadas.",
|
||||||
|
"options": {
|
||||||
|
"laptop": "Portátil",
|
||||||
|
"desktop": "Ordenador de sobremesa",
|
||||||
|
"monitor": "Monitor",
|
||||||
|
"printer": "Impresora",
|
||||||
|
"router": "Router",
|
||||||
|
"shield": "Seguridad / firewall",
|
||||||
|
"network": "Switch / red",
|
||||||
|
"wifi": "Wi-Fi",
|
||||||
|
"hard-drive": "NAS / almacenamiento",
|
||||||
|
"server": "Servidor",
|
||||||
|
"smartphone": "Smartphone",
|
||||||
|
"battery": "SAI / batería",
|
||||||
|
"tablet": "Tablet",
|
||||||
|
"keyboard": "Teclado",
|
||||||
|
"mouse": "Ratón",
|
||||||
|
"headphones": "Auriculares",
|
||||||
|
"camera": "Cámara",
|
||||||
|
"box": "Otro activo"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,10 @@ import { XIcon } from "@repo/rdx-ui/lib/icons";
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { CatalogItemDTO } from "../../../../common";
|
||||||
import type { AssetDTO } from "../../../../common/inventory";
|
import type { AssetDTO } from "../../../../common/inventory";
|
||||||
import { AssetRelationships } from "../../../asset-relationships";
|
import { AssetRelationships } from "../../../asset-relationships";
|
||||||
|
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
|
||||||
import { AssetSupport } from "../../../support-connections";
|
import { AssetSupport } from "../../../support-connections";
|
||||||
import type { InventoryLookups } from "../../shared/types";
|
import type { InventoryLookups } from "../../shared/types";
|
||||||
|
|
||||||
@ -16,6 +18,7 @@ export function AssetSelectionPanel({
|
|||||||
open,
|
open,
|
||||||
lookups,
|
lookups,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
template,
|
||||||
onClose,
|
onClose,
|
||||||
onView,
|
onView,
|
||||||
onEdit,
|
onEdit,
|
||||||
@ -25,6 +28,7 @@ export function AssetSelectionPanel({
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
lookups: InventoryLookups;
|
lookups: InventoryLookups;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
|
template?: CatalogItemDTO | undefined;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onView: (id: string) => void;
|
onView: (id: string) => void;
|
||||||
onEdit: (id: string) => void;
|
onEdit: (id: string) => void;
|
||||||
@ -41,7 +45,13 @@ export function AssetSelectionPanel({
|
|||||||
className="@container hidden min-w-0 grid-cols-[minmax(0,1fr)] gap-4 rounded-panel border border-border bg-card p-4 [overflow-wrap:anywhere] inventory:grid"
|
className="@container hidden min-w-0 grid-cols-[minmax(0,1fr)] gap-4 rounded-panel border border-border bg-card p-4 [overflow-wrap:anywhere] inventory:grid"
|
||||||
>
|
>
|
||||||
<header className="flex items-start justify-between gap-3">
|
<header className="flex items-start justify-between gap-3">
|
||||||
<h2 className="min-w-0 break-words text-lg font-semibold">{asset.values.name}</h2>
|
<h2 className="flex min-w-0 items-center gap-2 break-words text-lg font-semibold">
|
||||||
|
<AssetTemplateIcon
|
||||||
|
className="size-5 shrink-0 text-muted-foreground"
|
||||||
|
icon={template?.values.kind === "template" ? template.values.icon : undefined}
|
||||||
|
/>
|
||||||
|
{asset.values.name}
|
||||||
|
</h2>
|
||||||
<Button aria-label={t("closeAssetPanel")} onClick={onClose} size="icon-sm" variant="ghost">
|
<Button aria-label={t("closeAssetPanel")} onClick={onClose} size="icon-sm" variant="ghost">
|
||||||
<XIcon aria-hidden="true" className="size-4" />
|
<XIcon aria-hidden="true" className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { Button } from "@repo/shadcn-ui/components/button";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { CatalogError } from "../../../catalog";
|
import { CatalogError } from "../../../catalog";
|
||||||
|
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
|
||||||
import { InventoryFeedback } from "../../../shared";
|
import { InventoryFeedback } from "../../../shared";
|
||||||
import { AssetDetailDialog, AssetSelectionPanel } from "../../detail";
|
import { AssetDetailDialog, AssetSelectionPanel } from "../../detail";
|
||||||
import { AssetEditorDialog } from "../../shared";
|
import { AssetEditorDialog } from "../../shared";
|
||||||
@ -115,7 +116,22 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
|||||||
id: "template",
|
id: "template",
|
||||||
label: t("template"),
|
label: t("template"),
|
||||||
value: (asset) => asset.specification.templateName,
|
value: (asset) => asset.specification.templateName,
|
||||||
render: (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",
|
id: "state",
|
||||||
@ -182,6 +198,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
|||||||
onEdit={setEditor}
|
onEdit={setEditor}
|
||||||
onView={setViewId}
|
onView={setViewId}
|
||||||
open={detailOpen}
|
open={detailOpen}
|
||||||
|
template={catalog.data?.items.find((item) => item.id === selected?.values.templateId)}
|
||||||
/>
|
/>
|
||||||
{editor && catalog.data && (
|
{editor && catalog.data && (
|
||||||
<AssetEditorDialog
|
<AssetEditorDialog
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export function useCatalogTemplateFormController({
|
|||||||
resolver: zodResolver(TemplateFormSchema),
|
resolver: zodResolver(TemplateFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
kind: "template",
|
kind: "template",
|
||||||
|
icon: current?.icon,
|
||||||
name: copy ? t("copyName", { name: copy.values.name }) : (current?.name ?? ""),
|
name: copy ? t("copyName", { name: copy.values.name }) : (current?.name ?? ""),
|
||||||
description: current?.description ?? "",
|
description: current?.description ?? "",
|
||||||
fields: layout.fields,
|
fields: layout.fields,
|
||||||
|
|||||||
@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
BatteryChargingIcon,
|
||||||
|
BoxIcon,
|
||||||
|
CameraIcon,
|
||||||
|
HardDriveIcon,
|
||||||
|
HeadphonesIcon,
|
||||||
|
KeyboardIcon,
|
||||||
|
LaptopIcon,
|
||||||
|
MonitorCogIcon,
|
||||||
|
MonitorIcon,
|
||||||
|
MouseIcon,
|
||||||
|
NetworkIcon,
|
||||||
|
PrinterIcon,
|
||||||
|
RouterIcon,
|
||||||
|
ServerIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
SmartphoneIcon,
|
||||||
|
TabletIcon,
|
||||||
|
WifiIcon,
|
||||||
|
} from "@repo/rdx-ui/lib/icons";
|
||||||
|
|
||||||
|
import type { AssetTemplateIconKey } from "../../../../common/asset-template-icons";
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
laptop: LaptopIcon,
|
||||||
|
desktop: MonitorCogIcon,
|
||||||
|
monitor: MonitorIcon,
|
||||||
|
printer: PrinterIcon,
|
||||||
|
router: RouterIcon,
|
||||||
|
shield: ShieldCheckIcon,
|
||||||
|
network: NetworkIcon,
|
||||||
|
wifi: WifiIcon,
|
||||||
|
"hard-drive": HardDriveIcon,
|
||||||
|
server: ServerIcon,
|
||||||
|
smartphone: SmartphoneIcon,
|
||||||
|
battery: BatteryChargingIcon,
|
||||||
|
tablet: TabletIcon,
|
||||||
|
keyboard: KeyboardIcon,
|
||||||
|
mouse: MouseIcon,
|
||||||
|
headphones: HeadphonesIcon,
|
||||||
|
camera: CameraIcon,
|
||||||
|
box: BoxIcon,
|
||||||
|
} satisfies Record<AssetTemplateIconKey, typeof LaptopIcon>;
|
||||||
|
|
||||||
|
export function AssetTemplateIcon({
|
||||||
|
icon,
|
||||||
|
className = "size-4 shrink-0",
|
||||||
|
}: {
|
||||||
|
icon: AssetTemplateIconKey | undefined;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (!icon) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const Icon = ICONS[icon];
|
||||||
|
|
||||||
|
return <Icon aria-hidden="true" className={className} />;
|
||||||
|
}
|
||||||
@ -1,8 +1,10 @@
|
|||||||
import { UniqueID } from "@repo/rdx-ddd";
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||||
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
||||||
|
import { IconPicker } from "@repo/rdx-ui/components/icon-picker";
|
||||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||||
import { TemplateSectionsEditor } from "@repo/rdx-ui/components/template-sections-editor";
|
import { TemplateSectionsEditor } from "@repo/rdx-ui/components/template-sections-editor";
|
||||||
|
import { XIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
import { Input } from "@repo/shadcn-ui/components/input";
|
||||||
import { Label } from "@repo/shadcn-ui/components/label";
|
import { Label } from "@repo/shadcn-ui/components/label";
|
||||||
@ -10,12 +12,15 @@ import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
|||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { AssetTemplateIconSchema } from "../../../../common/asset-template-icons";
|
||||||
import { CatalogError } from "../../../catalog/shared/ui/catalog-error";
|
import { CatalogError } from "../../../catalog/shared/ui/catalog-error";
|
||||||
import { CatalogHelp } from "../../../catalog/shared/ui/catalog-help";
|
import { CatalogHelp } from "../../../catalog/shared/ui/catalog-help";
|
||||||
import { CatalogFieldCreateDialog } from "../../../catalog-fields";
|
import { CatalogFieldCreateDialog } from "../../../catalog-fields";
|
||||||
import { useCatalogTemplateFormController } from "../controllers";
|
import { useCatalogTemplateFormController } from "../controllers";
|
||||||
import type { CatalogTemplateFormProps } from "../types";
|
import type { CatalogTemplateFormProps } from "../types";
|
||||||
|
|
||||||
|
import { AssetTemplateIcon } from "./asset-template-icon";
|
||||||
|
|
||||||
export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
||||||
const { scope, allowQuickCreate = true } = props;
|
const { scope, allowQuickCreate = true } = props;
|
||||||
const { creatingField, setCreatingField, form, mutation, selected, fields, add, submit } =
|
const { creatingField, setCreatingField, form, mutation, selected, fields, add, submit } =
|
||||||
@ -43,6 +48,29 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<IconPicker
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
label={t("templateIcon.label")}
|
||||||
|
onChange={(value) => {
|
||||||
|
const icon = AssetTemplateIconSchema.safeParse(value);
|
||||||
|
|
||||||
|
form.setValue("icon", icon.success ? icon.data : undefined, { shouldDirty: true });
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: "",
|
||||||
|
label: t("templateIcon.none"),
|
||||||
|
icon: <XIcon aria-hidden="true" className="size-4" />,
|
||||||
|
},
|
||||||
|
...AssetTemplateIconSchema.options.map((icon) => ({
|
||||||
|
value: icon,
|
||||||
|
label: t(`templateIcon.options.${icon}`),
|
||||||
|
icon: <AssetTemplateIcon icon={icon} />,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
value={form.watch("icon") ?? ""}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("templateIcon.help")}</p>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${id}-field`}>{t("selectField")}</Label>
|
<Label htmlFor={`${id}-field`}>{t("selectField")}</Label>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { CatalogItemDTO } from "../../../../common";
|
import type { CatalogItemDTO } from "../../../../common";
|
||||||
|
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
|
||||||
import { CatalogHelp } from "../../shared";
|
import { CatalogHelp } from "../../shared";
|
||||||
|
|
||||||
export function CatalogDefinitionDialog({
|
export function CatalogDefinitionDialog({
|
||||||
@ -34,6 +35,12 @@ export function CatalogDefinitionDialog({
|
|||||||
wide={values.kind === "template"}
|
wide={values.kind === "template"}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{values.kind === "template" && values.icon && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<AssetTemplateIcon icon={values.icon} />
|
||||||
|
{t(`templateIcon.options.${values.icon}`)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{values.description && (
|
{values.description && (
|
||||||
<p className="text-sm text-muted-foreground">{values.description}</p>
|
<p className="text-sm text-muted-foreground">{values.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -72,3 +72,35 @@ test("los formularios tienen ayuda inferior y selector de campo con alta integra
|
|||||||
assert.ok(template.includes("Todos son opcionales inicialmente"));
|
assert.ok(template.includes("Todos son opcionales inicialmente"));
|
||||||
assert.ok(template.indexOf("<aside") < template.indexOf('type="submit"'));
|
assert.ok(template.indexOf("<aside") < template.indexOf('type="submit"'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("las plantillas ofrecen selección visual traducida y conservan el icono al copiar", async () => {
|
||||||
|
const catalog = buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none());
|
||||||
|
const laptop = catalog.items.find(
|
||||||
|
(item) => item.values.kind === "template" && item.values.icon === "laptop"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(laptop);
|
||||||
|
for (const [language, label, none] of [
|
||||||
|
["es", "Icono (opcional)", "Sin icono"],
|
||||||
|
["en", "Icon (optional)", "No icon"],
|
||||||
|
]) {
|
||||||
|
assert.ok(language && label && none);
|
||||||
|
const html = await render(
|
||||||
|
language,
|
||||||
|
<CatalogTemplateForm
|
||||||
|
copy={laptop}
|
||||||
|
items={catalog.items}
|
||||||
|
onSaved={() => undefined}
|
||||||
|
scope="global"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(html.includes(label));
|
||||||
|
assert.ok(html.includes(none));
|
||||||
|
assert.match(
|
||||||
|
html,
|
||||||
|
/aria-label="Laptop" aria-pressed="true"|aria-label="Portátil" aria-pressed="true"/
|
||||||
|
);
|
||||||
|
assert.ok(html.includes("lucide-laptop"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { CatalogItemDTO } from "../../../../common";
|
import type { CatalogItemDTO } from "../../../../common";
|
||||||
|
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
|
||||||
|
|
||||||
export function CatalogName({
|
export function CatalogName({
|
||||||
item,
|
item,
|
||||||
@ -22,10 +23,11 @@ export function CatalogName({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
className="inline-flex items-center gap-2 font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
onClick={onView}
|
onClick={onView}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
{values.kind === "template" && <AssetTemplateIcon icon={values.icon} />}
|
||||||
{values.name}
|
{values.name}
|
||||||
</button>
|
</button>
|
||||||
{values.description && <p className="text-xs text-muted-foreground">{values.description}</p>}
|
{values.description && <p className="text-xs text-muted-foreground">{values.description}</p>}
|
||||||
|
|||||||
41
packages/rdx-ui/src/components/icon-picker.tsx
Normal file
41
packages/rdx-ui/src/components/icon-picker.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function IconPicker({
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
options: readonly { value: string; label: string; icon: ReactNode }[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<fieldset className="space-y-2" disabled={disabled}>
|
||||||
|
<legend className="text-sm font-medium">{label}</legend>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{options.map((option) => (
|
||||||
|
<Button
|
||||||
|
aria-label={option.label}
|
||||||
|
aria-pressed={value === option.value}
|
||||||
|
className={
|
||||||
|
value === option.value ? "border-primary text-primary ring-1 ring-primary" : ""
|
||||||
|
}
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
size="icon"
|
||||||
|
title={option.label}
|
||||||
|
type="button"
|
||||||
|
variant={value === option.value ? "secondary" : "outline"}
|
||||||
|
>
|
||||||
|
{option.icon}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,29 +1,44 @@
|
|||||||
export {
|
export {
|
||||||
|
BatteryChargingIcon,
|
||||||
BellIcon,
|
BellIcon,
|
||||||
BoxIcon,
|
BoxIcon,
|
||||||
BriefcaseIcon,
|
BriefcaseIcon,
|
||||||
Building2Icon,
|
Building2Icon,
|
||||||
|
CameraIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
FileKeyIcon,
|
FileKeyIcon,
|
||||||
|
HardDriveIcon,
|
||||||
|
HeadphonesIcon,
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
KeyRoundIcon,
|
KeyRoundIcon,
|
||||||
|
KeyboardIcon,
|
||||||
LaptopIcon,
|
LaptopIcon,
|
||||||
LayersIcon,
|
LayersIcon,
|
||||||
LayoutTemplateIcon,
|
LayoutTemplateIcon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
|
MonitorCogIcon,
|
||||||
|
MonitorIcon,
|
||||||
|
MouseIcon,
|
||||||
|
NetworkIcon,
|
||||||
PhoneIcon,
|
PhoneIcon,
|
||||||
|
PrinterIcon,
|
||||||
|
RouterIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
|
ServerIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
|
SmartphoneIcon,
|
||||||
|
TabletIcon,
|
||||||
TicketIcon,
|
TicketIcon,
|
||||||
TriangleAlertIcon,
|
TriangleAlertIcon,
|
||||||
UserRoundXIcon,
|
UserRoundXIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
|
WifiIcon,
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user