From 53d40518317c00950e23576cd47885b100154aa3 Mon Sep 17 00:00:00 2001 From: JoseSerpaCode Date: Mon, 24 Aug 2026 18:43:24 -0500 Subject: [PATCH] =?UTF-8?q?fix(tipos):=20renombrar=20uno=20de=20f=C3=A1bri?= =?UTF-8?q?ca=20ahora=20se=20ve=20en=20toda=20la=20aplicaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La regla que decide qué nombre enseñar estaba copiada cinco veces, y las cinco ignoraban el nombre escrito cuando el tipo era de fábrica: ganaba siempre la traducción de la clave. Renombrar «Task» a «Incidencia» solo se veía al reabrir el diálogo de renombrar, que lo lee de un `data-name`. Se traduce mientras el tipo esté intacto y gana el nombre propio en cuanto alguien lo cambia — la misma regla que el proyecto ya aplica a etiquetas y a tipos propios. Compara contra el nombre con el que nació, para no quitarle la traducción a quien nunca renombra. Las cinco copias pasan a `nombreVisible()`, junto a `mapaPorClave()`. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 8 +++ package-lock.json | 4 +- package.json | 2 +- src/components/dashboard/TaskTable.astro | 3 +- src/components/jira/IssueCard.astro | 3 +- src/components/jira/IssueDetailsModal.astro | 3 +- src/lib/issueTypes.ts | 54 ++++++++++++++++-- src/pages/w/[sys_tag]/board.astro | 4 +- src/pages/w/[sys_tag]/settings.astro | 4 +- tests/e2e/renombrar-tipo.spec.ts | 61 +++++++++++++++++++++ tests/nombre-visible-tipo.test.ts | 55 +++++++++++++++++++ 11 files changed, 185 insertions(+), 16 deletions(-) create mode 100644 tests/e2e/renombrar-tipo.spec.ts create mode 100644 tests/nombre-visible-tipo.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 12cd735..355e6f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ El formato está basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1 > Las entradas entre la 0.6.0 y la 1.4.0 se reconstruyeron a posteriori a partir del historial de git, agrupadas por los saltos de versión que realmente ocurrieron en `package.json`. La 1.1.0 nunca existió: se pasó directamente de la 1.0.0 a la 1.2.0. +## [1.27.0] - 2026-08-23 + +### Fixed + +- **Renombrar un tipo de ticket de fábrica no se veía en ninguna parte.** La regla que decide qué nombre enseñar estaba copiada **cinco veces** —tarjeta, tabla del hub, modal, desplegable del tablero y ajustes— y las cinco ignoraban el nombre escrito cuando el tipo era de fábrica: siempre ganaba la traducción de la clave. Llamar «Incidencia» a «Task» solo se veía al volver a abrir el diálogo de renombrar, que lo lee de otro sitio. +- La regla buena es la que el proyecto ya aplica a las etiquetas y a los tipos propios: **lo que escribe una persona no se traduce**. Ahora se traduce mientras el tipo esté intacto —quien nunca renombra, que es casi todo el mundo, sigue viendo «Tarea» y «Task» según el idioma— y en cuanto alguien le cambia el nombre gana el suyo en los dos. Se decide comparando contra el nombre con el que nació, no mirando solo `is_builtin`. +- Las cinco copias pasan a una sola función, `nombreVisible()`, junto a `mapaPorClave()` que ya existía para esto mismo. Cinco copias de la misma expresión es exactamente cómo se llegó aquí. + ## [1.26.2] - 2026-08-20 ### Fixed diff --git a/package-lock.json b/package-lock.json index 7980346..537ee55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "forge-js", - "version": "1.26.2", + "version": "1.27.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forge-js", - "version": "1.26.2", + "version": "1.27.0", "dependencies": { "@astrojs/check": "^0.9.10", "@astrojs/node": "^11.1.2", diff --git a/package.json b/package.json index 5177990..6d50dcd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "forge-js", "type": "module", - "version": "1.26.2", + "version": "1.27.0", "engines": { "node": ">=22.12.0" }, diff --git a/src/components/dashboard/TaskTable.astro b/src/components/dashboard/TaskTable.astro index 5632738..99f7f9a 100644 --- a/src/components/dashboard/TaskTable.astro +++ b/src/components/dashboard/TaskTable.astro @@ -22,6 +22,7 @@ interface Props { const { tasks, sys_tag, emptyMessage, tipos } = Astro.props as Props; import { useTranslations } from '../../i18n/ui'; +import { nombreVisible } from '../../lib/issueTypes'; import { fecha } from '../../lib/fechas'; const lang = Astro.locals.lang || 'en'; const t = useTranslations(lang); @@ -70,7 +71,7 @@ const statusColor: Record = { // no, porque lo escribió alguien. Sin tipo resuelto se enseña // la clave, que es fea pero es información. const nombre = ti - ? (ti.isBuiltin ? (t(`type.${ti.key}` as any) || ti.name) : ti.name) + ? nombreVisible(ti, t) : (task.type || 'task'); return ( {tipo && } - {tipo ? (tipo.isBuiltin ? (t(`type.${tipo.key}` as any) || tipo.name) : tipo.name) : issue.type} + {tipo ? nombreVisible(tipo, t) : issue.type} diff --git a/src/components/jira/IssueDetailsModal.astro b/src/components/jira/IssueDetailsModal.astro index 05650e1..ee4fcbb 100644 --- a/src/components/jira/IssueDetailsModal.astro +++ b/src/components/jira/IssueDetailsModal.astro @@ -1,5 +1,6 @@ --- import { useTranslations } from '../../i18n/ui'; +import { nombreVisible } from '../../lib/issueTypes'; import LabelPicker from '../labels/LabelPicker.astro'; interface Props { sprints?: any[]; @@ -68,7 +69,7 @@ const t = useTranslations(lang);
ID-XXX diff --git a/src/lib/issueTypes.ts b/src/lib/issueTypes.ts index f0b5781..76729bc 100644 --- a/src/lib/issueTypes.ts +++ b/src/lib/issueTypes.ts @@ -107,14 +107,56 @@ export function listar(workspaceId: string): TipoTicket[] { ).all(workspaceId) as any[]).map(aTipo); } +/** + * Los cuatro de siempre, con el nombre con el que nacen. + * + * Está aquí arriba y no dentro de `sembrarDeFabrica()` porque `nombreVisible()` + * necesita saber cuál era el nombre original para distinguir un tipo intacto de + * uno renombrado. Dos listas separadas serían dos verdades sobre lo mismo. + */ +const DE_FABRICA = [ + { key: 'task', name: 'Task', color: '#0091FF' }, + { key: 'bug', name: 'Bug', color: '#E5484D' }, + { key: 'story', name: 'Story', color: '#30A46C' }, + { key: 'epic', name: 'Epic', color: '#8E4EC6' }, +] as const; + +/** El nombre con el que nació cada tipo de fábrica, por clave. */ +const NOMBRE_ORIGINAL = new Map(DE_FABRICA.map((t) => [t.key, t.name])); + +/** + * El nombre que se enseña de un tipo, en el idioma que toca. + * + * Esta regla estaba copiada **cinco veces**, y las cinco tenían el mismo fallo: + * + * ti.isBuiltin ? (t(`type.${ti.key}`) || ti.name) : ti.name + * + * Para un tipo de fábrica eso ignora `ti.name` **siempre**, así que renombrar + * «Tarea» a «Incidencia» no se veía en ninguna parte: ni en la tarjeta, ni en + * la tabla del hub, ni en los desplegables. El único sitio donde aparecía el + * nombre nuevo era el diálogo de renombrar, que lo lee de un `data-name`. + * + * La regla buena es la que el proyecto ya aplica a las etiquetas y a los tipos + * propios: **lo que escribe una persona no se traduce**. Así que se traduce + * solo mientras el tipo esté intacto; en cuanto alguien le cambia el nombre, + * gana el suyo en los dos idiomas. + * + * Se compara contra el nombre original y no contra un `is_builtin` a secas + * porque quien nunca renombra —la mayoría— tiene que seguir viendo «Tarea» y + * «Task» según el idioma, que es lo que hace útil la traducción. + */ +export function nombreVisible( + tipo: { key: string; name: string; isBuiltin: boolean } | null | undefined, + t: (clave: any) => any +): string { + if (!tipo) return ''; + if (!tipo.isBuiltin) return tipo.name; + if (tipo.name !== NOMBRE_ORIGINAL.get(tipo.key)) return tipo.name; + return (t(`type.${tipo.key}`) as string) || tipo.name; +} + /** Los cuatro de siempre, para un espacio que todavía no tiene ninguno. */ export function sembrarDeFabrica(workspaceId: string): void { - const DE_FABRICA = [ - { key: 'task', name: 'Task', color: '#0091FF' }, - { key: 'bug', name: 'Bug', color: '#E5484D' }, - { key: 'story', name: 'Story', color: '#30A46C' }, - { key: 'epic', name: 'Epic', color: '#8E4EC6' }, - ]; const insertar = db.prepare(` INSERT OR IGNORE INTO issue_types (id, workspace_id, key, name, color, position, is_builtin) VALUES (?, ?, ?, ?, ?, ?, 1) diff --git a/src/pages/w/[sys_tag]/board.astro b/src/pages/w/[sys_tag]/board.astro index 7d2eeef..629c3e2 100644 --- a/src/pages/w/[sys_tag]/board.astro +++ b/src/pages/w/[sys_tag]/board.astro @@ -5,7 +5,7 @@ import db from '../../../lib/db'; import { checkWorkspaceAccess } from '../../../lib/guard'; import { useTranslations } from '../../../i18n/ui'; import { deVarias, listar as listarEtiquetas } from '../../../lib/labels'; -import { listar as listarTipos } from '../../../lib/issueTypes'; +import { listar as listarTipos, nombreVisible } from '../../../lib/issueTypes'; import { abrirEspacioDePagina } from '../../../lib/paginaEspacio'; const { sys_tag } = Astro.params; @@ -487,7 +487,7 @@ const metaDescription = `${workspace.name} board on Forge OS. Track issues, spri
diff --git a/src/pages/w/[sys_tag]/settings.astro b/src/pages/w/[sys_tag]/settings.astro index fc173f3..d34f064 100644 --- a/src/pages/w/[sys_tag]/settings.astro +++ b/src/pages/w/[sys_tag]/settings.astro @@ -6,7 +6,7 @@ import { useTranslations } from '../../../i18n/ui'; import LabelChip from '../../../components/labels/LabelChip.astro'; import WorkspaceIcon from '../../../components/brand/WorkspaceIcon.astro'; import { COLORES, listar as listarEtiquetas } from '../../../lib/labels'; -import { listar as listarTipos } from '../../../lib/issueTypes'; +import { listar as listarTipos, nombreVisible } from '../../../lib/issueTypes'; import { abrirEspacioDePagina } from '../../../lib/paginaEspacio'; const user = Astro.locals.user!; @@ -367,7 +367,7 @@ const wsPendingInvites = (db.prepare(`
- {ti.isBuiltin ? (t(`type.${ti.key}` as any) || ti.name) : ti.name} + {nombreVisible(ti, t)} {ti.usos === 0 ? t('types.unused') : ti.usos === 1 ? t('types.used_in_one') : t('types.used_in').replace('{n}', String(ti.usos))} diff --git a/tests/e2e/renombrar-tipo.spec.ts b/tests/e2e/renombrar-tipo.spec.ts new file mode 100644 index 0000000..f20f85d --- /dev/null +++ b/tests/e2e/renombrar-tipo.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '@playwright/test'; +import { getTestDb } from './test-utils'; + +/** + * Renombrar un tipo de fábrica se ve en toda la aplicación. + * + * Antes solo se veía en el diálogo de renombrar, que lee el nombre de un + * `data-name`. Los cinco sitios que lo pintan usaban la traducción de la clave + * e ignoraban el nombre escrito, así que el tablero, la tabla del hub y los + * desplegables seguían diciendo «Task» para siempre. + * + * Se comprueba en las pantallas, no en la función: la unidad ya está cubierta + * en `tests/nombre-visible-tipo.test.ts`, y lo que falló aquí fue que cinco + * copias no llamaran a nadie. + */ +const ESPACIO = 'ws-renombrar-tipo'; + +async function entrar(page: any) { + await page.goto('/login'); + await page.fill('input[name="username"]', 'jose'); + await page.fill('input[name="password"]', process.env.TEST_PASSWORD || 'LocalDevPass123!'); + await page.click('button[type="submit"]'); + await page.waitForURL('**/'); +} + +test('el nombre nuevo de un tipo de fábrica llega al tablero y al modal', async ({ page }) => { + await entrar(page); + + const db = getTestDb(); + const yo = db.prepare("SELECT id FROM users WHERE username = 'jose'").get() as any; + db.prepare('INSERT OR IGNORE INTO workspaces (id, name, sys_tag, created_by) VALUES (?,?,?,?)') + .run(crypto.randomUUID(), 'Renombrar', ESPACIO, yo.id); + const ws = db.prepare('SELECT id FROM workspaces WHERE sys_tag = ?').get(ESPACIO) as any; + db.prepare("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, ws_role) VALUES (?,?,'owner')") + .run(ws.id, yo.id); + + // Los cuatro de fábrica se siembran al primer listado; se fuerza aquí. + await page.goto(`/w/${ESPACIO}/board`); + + // Y se renombra «Task» directamente, que es el caso del reporte. + db.prepare("UPDATE issue_types SET name = 'Incidencia' WHERE workspace_id = ? AND key = 'task'") + .run(ws.id); + + db.prepare('DELETE FROM issues WHERE workspace_id = ?').run(ws.id); + db.prepare(`INSERT INTO issues (id, workspace_id, type, title, status, reporter_id, position) + VALUES (?, ?, 'task', 'Ticket de prueba', 'todo', ?, 100000)`) + .run(crypto.randomUUID(), ws.id, yo.id); + + await page.goto(`/w/${ESPACIO}/board?sprint=backlog`); + + // 1. La insignia de la tarjeta. + const tarjeta = page.locator('.issue-card', { hasText: 'Ticket de prueba' }).first(); + await expect(tarjeta).toBeVisible(); + await expect(tarjeta.locator('.issue-type-badge')).toContainText('Incidencia'); + + // 2. El desplegable del modal de crear. + await expect(page.locator('#new-issue-type option').first()).toHaveText('Incidencia'); + + // Y lo que no puede romperse: los que nadie tocó siguen traduciéndose. + await expect(page.locator('#new-issue-type')).toContainText('Bug'); +}); diff --git a/tests/nombre-visible-tipo.test.ts b/tests/nombre-visible-tipo.test.ts new file mode 100644 index 0000000..0a7be18 --- /dev/null +++ b/tests/nombre-visible-tipo.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { nombreVisible } from '../src/lib/issueTypes'; + +/** + * Qué nombre se enseña de un tipo de ticket. + * + * La regla estaba copiada cinco veces y las cinco ignoraban `name` cuando el + * tipo era de fábrica, así que renombrar «Task» a «Incidencia» no se veía en + * ninguna parte salvo en el propio diálogo de renombrar. + * + * La regla buena es la que el proyecto ya aplica a etiquetas y tipos propios: + * lo que escribe una persona no se traduce. Pero hay que conservar la + * traducción para quien nunca renombra, que es casi todo el mundo — de ahí que + * se compare contra el nombre original en vez de mirar solo `isBuiltin`. + */ +const es = (clave: string) => ({ + 'type.task': 'Tarea', + 'type.bug': 'Error', + 'type.story': 'Historia', + 'type.epic': 'Épica', +}[clave]); + +const en = (clave: string) => ({ + 'type.task': 'Task', + 'type.bug': 'Bug', +}[clave]); + +describe('nombreVisible', () => { + it('un tipo de fábrica intacto se traduce', () => { + const tarea = { key: 'task', name: 'Task', isBuiltin: true }; + expect(nombreVisible(tarea, es)).toBe('Tarea'); + expect(nombreVisible(tarea, en)).toBe('Task'); + }); + + it('un tipo de fábrica renombrado gana en los dos idiomas', () => { + const renombrado = { key: 'task', name: 'Incidencia', isBuiltin: true }; + expect(nombreVisible(renombrado, es)).toBe('Incidencia'); + expect(nombreVisible(renombrado, en)).toBe('Incidencia'); + }); + + it('un tipo propio nunca se traduce', () => { + const propio = { key: 'preventivo', name: 'Preventivo', isBuiltin: false }; + expect(nombreVisible(propio, es)).toBe('Preventivo'); + }); + + it('si falta la traducción cae al nombre, no a la clave', () => { + const sinTraducir = { key: 'story', name: 'Story', isBuiltin: true }; + expect(nombreVisible(sinTraducir, en)).toBe('Story'); + }); + + it('sin tipo resuelto devuelve vacío, no «undefined»', () => { + expect(nombreVisible(null, es)).toBe(''); + expect(nombreVisible(undefined, es)).toBe(''); + }); +});