Compare commits

..

3 Commits

8 changed files with 216 additions and 6 deletions

View File

@@ -3,7 +3,7 @@ title: "Campos editables del builder"
tags: [builder, twig, html, modules]
load_priority: 80
load_when: [always]
summary: "Atributos data-field-* (textfield, headfield, link, upload, list, multiv2, checkbox), c-if/c-for/c-class, c-form, componentes built-in del builder Acai."
summary: "Atributos data-field-* (textfield, headfield, link, upload, list, multiv2, checkbox), agrupar campos en pestañas con data-field-group, c-if/c-for/c-class, c-form, componentes built-in del builder Acai."
---
# Builder Fields — Campos editables del index-base.tpl
@@ -108,7 +108,25 @@ Devuelve un array. Acceso en Twig: `{{ imagen[0].urlPath }}`.
Atributos disponibles:
- `data-lazy="true"` — carga perezosa
- `data-field-width="1400"` — ancho máximo sugerido
- `data-field-info1="titulo"` campo de información adicional (típicamente alt)
- `data-field-info1``data-field-info5` — labels de los campos de información por imagen
Los `data-field-infoN` (hasta 5) definen los labels que el builder muestra como campos editables **en cada imagen subida** (van a `infoLabels` en el `builder.json`). Sus valores se leen luego como `info1``info4` dentro del array del var.
```html
<img data-field-type="upload"
data-field-label="Imagen Principal"
data-field-info1="Texto alternativo"
data-field-info2="Pie de foto"
data-field-width="1400"
alt="">
```
```twig
<img src="{{ imagenprincipal[0].urlPath }}" alt="{{ imagenprincipal[0].info1 }}">
<figcaption>{{ imagenprincipal[0].info2 }}</figcaption>
```
Los `data-field-infoN` funcionan igual en `uploadMulti`.
### uploadMulti
@@ -200,6 +218,41 @@ Devuelve `1` o `0` (número), nunca `true`/`false`.
Devuelve un string hexadecimal (`#ff0000`). Almacenado en config-vars (no en `builder_custom`).
## Agrupar campos en pestañas (`data-field-group`)
Los campos de un módulo se reparten en pestañas dentro del panel de configuración añadiendo `data-field-group="Nombre del grupo"` al mismo elemento que ya lleva `data-field-type`.
Mecanismo: cualquier atributo `data-field-*` extra —aparte de `data-field-type`, `data-field-label` y `data-field-value`— se recoge en el objeto `customDataField` del var, recortando el prefijo `data-field-`. Es decir, `data-field-group="Estilos"` acaba como `customDataField: { group: "Estilos" }` en el `builder.json`.
Comportamiento del panel:
- Los vars se agrupan por `customDataField.group` y se muestran en pestañas laterales.
- Los vars sin `data-field-group` caen en la pestaña **Principal**.
- El orden de las pestañas es el de primera aparición de cada grupo en el template.
- Con un solo grupo no se muestran pestañas.
- Funciona igual en vars de primer nivel y dentro de `multiv2`.
```html
<div data-field-type="textfield" data-field-label="Título" data-field-group="Contenido">
Título de la sección
</div>
<div data-field-type="textbox" data-field-label="Descripción" data-field-group="Contenido">
Texto de apoyo
</div>
<div c-hidden="true">
<div data-field-type="colorpicker" data-field-label="Color de fondo" data-field-group="Estilos"></div>
<div data-field-type="list"
data-field-label="Alineación"
data-list-options="|Izquierda,1|Centro,2|Derecha"
data-field-group="Estilos"></div>
</div>
```
Recomendación de uso:
- Agrupa cuando el módulo pase de ~6-8 vars; por debajo, una sola lista se lee mejor.
- Reparto habitual: **Contenido** (textos e imágenes), **Estilos** (colores, alineación, bordes), **Ajustes** (opciones de comportamiento, límites, enlaces).
- Mantén los nombres de grupo consistentes entre módulos y en español. Son literales: `Estilos` y `estilos` generan dos pestañas distintas.
## Atributos Acai
### `c-if` — Renderizado condicional

View File

@@ -45,6 +45,8 @@ Resumen ejecutable de reglas críticas, tipos de campo, filtros y formatos de da
| `checkbox` | `<input>` o `<div>` | `1` / `0` |
| `colorpicker` | `<div>` | Hex color |
Pestañas en el panel del módulo: `data-field-group="Estilos"` en el elemento del campo (sin group → pestaña "Principal").
## Atributos Acai
| Atributo | Uso | Ejemplo |

View File

@@ -1,7 +1,12 @@
import { z } from "zod";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
import { isProtectedLayoutPath, buildProtectedLayoutPathError } from "./protectedPaths.js";
import {
isProtectedLayoutPath,
buildProtectedLayoutPathError,
isProtectedSchemaPath,
buildProtectedSchemaPathError,
} from "./protectedPaths.js";
export function registerAcaiDeleteTool(server) {
server.tool(
@@ -21,6 +26,10 @@ export function registerAcaiDeleteTool(server) {
return buildProtectedLayoutPathError(file_path);
}
if (isProtectedSchemaPath(file_path)) {
return buildProtectedSchemaPathError(file_path);
}
const { projectSlug, projectDir } = getCurrentProjectInfo();
const result = await callLocalFileEndpoint("POST", "/api/files/delete", {
project: projectSlug,

View File

@@ -1,7 +1,12 @@
import { z } from "zod";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
import { isProtectedLayoutPath, buildProtectedLayoutPathError } from "./protectedPaths.js";
import {
isProtectedLayoutPath,
buildProtectedLayoutPathError,
isProtectedSchemaPath,
buildProtectedSchemaPathError,
} from "./protectedPaths.js";
export function registerAcaiLineReplaceTool(server) {
server.tool(
@@ -29,6 +34,10 @@ export function registerAcaiLineReplaceTool(server) {
return buildProtectedLayoutPathError(file_path);
}
if (isProtectedSchemaPath(file_path)) {
return buildProtectedSchemaPathError(file_path);
}
const { projectSlug, projectDir } = getCurrentProjectInfo();
const result = await callLocalFileEndpoint("POST", "/api/files/line-replace", {
project: projectSlug,

View File

@@ -11,11 +11,26 @@ const PROTECTED_LAYOUT_PATHS = [
"template/estandar/modulos/custom-footer/",
];
// Table schemas live here. They are the .ini.php mirror of the real MySQL
// structure: editing the file by hand does not run any DDL, so the schema and
// the database drift apart (and the CMS keeps serving stale cached metadata).
const SCHEMA_DIR_PREFIX = "cms/data/schema/";
// Normalizes a relative path: drops leading slashes and "./" segments so
// "/cms/...", "./cms/..." and "cms/..." all compare equal.
function normalizeRelPath(relPath) {
let norm = String(relPath).replace(/^\/+/, "");
while (norm.startsWith("./")) {
norm = norm.slice(2).replace(/^\/+/, "");
}
return norm;
}
// Returns true when `relPath` points at the layout.json or any of the
// generated custom-{header,footer}[-twig] module folders.
export function isProtectedLayoutPath(relPath) {
if (!relPath) return false;
const norm = String(relPath).replace(/^\/+/, "");
const norm = normalizeRelPath(relPath);
return PROTECTED_LAYOUT_PATHS.some(p => {
// Folder entries end with "/" -> prefix match on the normalized path.
// File entries (no trailing slash) -> exact match only.
@@ -37,3 +52,26 @@ export function buildProtectedLayoutPathError(relPath) {
isError: true,
};
}
// Returns true when `relPath` points inside cms/data/schema/ (the table schema
// directory), so file tools can bail out before hitting the Python endpoint.
export function isProtectedSchemaPath(relPath) {
if (!relPath) return false;
const norm = normalizeRelPath(relPath);
if (!norm) return false;
return norm === SCHEMA_DIR_PREFIX.slice(0, -1) || norm.startsWith(SCHEMA_DIR_PREFIX);
}
// Builds a consistent MCP error response pointing the agent to the table tools.
export function buildProtectedSchemaPathError(relPath) {
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: `Forbidden path: ${relPath} lives in ${SCHEMA_DIR_PREFIX} and table schemas are read-only through the file tools (acai-write, acai-line-replace, acai-delete). To change the structure use the table tools instead - create_table, create_field, update_field, delete_field, update_table_metadata, delete_table - which run the real DDL in MySQL and refresh the caches. To read a schema use get_table_schema or acai-view.`,
}, null, 2),
}],
isError: true,
};
}

View File

@@ -1,7 +1,12 @@
import { z } from "zod";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
import { isProtectedLayoutPath, buildProtectedLayoutPathError } from "./protectedPaths.js";
import {
isProtectedLayoutPath,
buildProtectedLayoutPathError,
isProtectedSchemaPath,
buildProtectedSchemaPathError,
} from "./protectedPaths.js";
export function registerAcaiWriteTool(server) {
server.tool(
@@ -28,6 +33,10 @@ Before writing, check the matching documentation for the file type:
return buildProtectedLayoutPathError(file_path);
}
if (isProtectedSchemaPath(file_path)) {
return buildProtectedSchemaPathError(file_path);
}
const { projectSlug, projectDir } = getCurrentProjectInfo();
const result = await callLocalFileEndpoint("POST", "/api/files/write", {
project: projectSlug,

View File

@@ -2,6 +2,7 @@ import { registerCheckModuleTool } from './check.js';
import { registerCheckModuleUsageTool } from './checkUsage.js';
import { registerCompileModuleTool } from './compile.js';
import { registerDeleteModuleTool } from './delete.js';
import { registerUpdateModuleMetadataTool } from './updateMetadata.js';
import { canEditCode } from '../helpers/roleCheck.js';
export function registerModuleTools(server) {
@@ -10,5 +11,6 @@ export function registerModuleTools(server) {
if (canEditCode()) {
registerCompileModuleTool(server);
registerDeleteModuleTool(server);
registerUpdateModuleMetadataTool(server);
}
}

View File

@@ -0,0 +1,88 @@
import { z } from "zod";
import { withAuth } from "../../auth/index.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { handleToolError } from "../helpers/errorHandler.js";
import { pythonPost } from "../helpers/pythonServerClient.js";
import { getCurrentProjectInfo } from "../files/helpers.js";
// Tool: update_module_metadata
// Edita la metadata de builder.json de un modulo (label, description,
// onlyAdminModule, MJMLModule) delegando en /api/modules/update-metadata.
// El id/carpeta del modulo NO se puede renombrar y el endpoint rechaza los
// modulos generados del layout (custom-header/footer[-twig]).
// Claves editables del builder.json. Se envian solo las que llegan definidas,
// de modo que el endpoint hace un merge parcial y no pisa el resto.
const EDITABLE_KEYS = ["label", "description", "onlyAdminModule", "MJMLModule"];
export function registerUpdateModuleMetadataTool(server) {
server.tool(
"update_module_metadata",
`Edit a module's metadata in its builder.json: 'label' (display name shown in the visual builder), 'description' (short help text for editors), 'onlyAdminModule' (true = the module is only visible to admin users in the builder), 'MJMLModule' (true = the module is an email/MJML module).
The module id (its folder name under template/estandar/modulos/) CANNOT be renamed with this tool — there is no rename option at all. Only the fields above change; everything else in builder.json is preserved.
At least one of label, description, onlyAdminModule or MJMLModule is required. Fields you omit are left untouched.
Not applicable to the generated layout modules (custom-header, custom-footer, custom-header-twig, custom-footer-twig): those are artifacts of the global layout and the request will be rejected — use set_layout_field for them.`,
withAuthParams({
module: z.string().min(1).describe("Module id (the folder name under template/estandar/modulos/). Cannot be renamed."),
label: z.string().optional().describe("Display name of the module in the visual builder"),
description: z.string().optional().describe("Short description shown to editors in the builder"),
onlyAdminModule: z.boolean().optional().describe("If true, the module is only visible to admin users in the builder"),
MJMLModule: z.boolean().optional().describe("If true, the module is treated as an email (MJML) module"),
}),
{ readOnlyHint: false, destructiveHint: false },
withAuth(async ({ module, label, description, onlyAdminModule, MJMLModule }, _extra) => {
try {
const provided = { label, description, onlyAdminModule, MJMLModule };
// Validacion temprana: sin ninguna clave editable no tiene
// sentido llamar al endpoint. Ojo con los booleanos false —
// se comprueba `undefined`, no truthiness.
const changedKeys = EDITABLE_KEYS.filter((key) => provided[key] !== undefined);
if (changedKeys.length === 0) {
return handleToolError(
`Nothing to update: provide at least one of ${EDITABLE_KEYS.join(", ")}. The module id cannot be renamed with this tool.`,
"update_module_metadata",
{ module, editableFields: EDITABLE_KEYS }
);
}
const { projectSlug } = getCurrentProjectInfo();
const body = { project: projectSlug, module };
for (const key of changedKeys) body[key] = provided[key];
const result = await pythonPost("/api/modules/update-metadata", body);
if (!result?.success) {
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: result?.error || "Could not update module metadata",
}),
}],
isError: true,
};
}
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
module: result.module || module,
updatedFields: changedKeys,
metadata: result.metadata || {},
}, null, 2),
}],
};
} catch (error) {
return handleToolError(error, "update_module_metadata", { module });
}
})
);
}