Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "forge-js",
"type": "module",
"version": "1.26.2",
"version": "1.27.0",
"engines": {
"node": ">=22.12.0"
},
Expand Down
3 changes: 2 additions & 1 deletion src/components/dashboard/TaskTable.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -70,7 +71,7 @@ const statusColor: Record<string, string> = {
// 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 (
<span
Expand Down
3 changes: 2 additions & 1 deletion src/components/jira/IssueCard.astro
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ interface Props {
const { issue, canEdit = false, arrastrable = canEdit, tipo = null } = Astro.props;

import { useTranslations } from '../../i18n/ui';
import { nombreVisible } from '../../lib/issueTypes';
import LabelChip from '../labels/LabelChip.astro';
import { fecha, vencida } from '../../lib/fechas';
const lang = Astro.locals.lang || 'en';
Expand Down Expand Up @@ -85,7 +86,7 @@ const t = useTranslations(lang);
style={tipo ? `border-color: ${tipo.color}55` : undefined}>
{tipo && <span class="w-1.5 h-1.5 rounded-full shrink-0" style={`background:${tipo.color}`} aria-hidden="true"></span>}
<span class="text-forge-muted">
{tipo ? (tipo.isBuiltin ? (t(`type.${tipo.key}` as any) || tipo.name) : tipo.name) : issue.type}
{tipo ? nombreVisible(tipo, t) : issue.type}
</span>
</span>
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/components/jira/IssueDetailsModal.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
import { useTranslations } from '../../i18n/ui';
import { nombreVisible } from '../../lib/issueTypes';
import LabelPicker from '../labels/LabelPicker.astro';
interface Props {
sprints?: any[];
Expand Down Expand Up @@ -68,7 +69,7 @@ const t = useTranslations(lang);
<div class="flex items-center gap-3 min-w-0">
<select id="modal-issue-type" aria-label={t('a11y.issue_type')} class="px-2 py-1 rounded-md text-[11px] font-bold tracking-wider bg-forge-bg/50 text-forge-text border border-forge-border/50 focus:border-forge-secondary focus:ring-1 focus:ring-forge-secondary/50 focus:outline-none uppercase transition-all shadow-sm">
{issueTypes.map((ti) => (
<option value={ti.key}>{ti.isBuiltin ? (t(`type.${ti.key}` as any) || ti.name) : ti.name}</option>
<option value={ti.key}>{nombreVisible(ti, t)}</option>
))}
</select>
<span id="modal-issue-id" class="font-mono text-[13px] text-forge-muted tracking-wide font-medium truncate">ID-XXX</span>
Expand Down
54 changes: 48 additions & 6 deletions src/lib/issueTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>(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)
Expand Down
4 changes: 2 additions & 2 deletions src/pages/w/[sys_tag]/board.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -487,7 +487,7 @@ const metaDescription = `${workspace.name} board on Forge OS. Track issues, spri
<label class="block text-xs uppercase tracking-wider text-forge-muted mb-1" for="new-issue-type">{t('modal.type')}</label>
<select id="new-issue-type" class="w-full bg-forge-bg border border-forge-border rounded p-2 text-forge-text focus:border-forge-secondary outline-none">
{tiposDelEspacio.map((ti) => (
<option value={ti.key}>{ti.isBuiltin ? (t(`type.${ti.key}` as any) || ti.name) : ti.name}</option>
<option value={ti.key}>{nombreVisible(ti, t)}</option>
))}
</select>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/pages/w/[sys_tag]/settings.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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!;
Expand Down Expand Up @@ -367,7 +367,7 @@ const wsPendingInvites = (db.prepare(`
<div class="flex items-center gap-3 min-w-0 flex-wrap">
<span class="w-3 h-3 rounded-full shrink-0" style={`background:${ti.color}`} aria-hidden="true"></span>
<span class="text-sm font-semibold text-forge-text truncate">
{ti.isBuiltin ? (t(`type.${ti.key}` as any) || ti.name) : ti.name}
{nombreVisible(ti, t)}
</span>
<span class="text-xs text-forge-muted truncate">
{ti.usos === 0 ? t('types.unused') : ti.usos === 1 ? t('types.used_in_one') : t('types.used_in').replace('{n}', String(ti.usos))}
Expand Down
61 changes: 61 additions & 0 deletions tests/e2e/renombrar-tipo.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
55 changes: 55 additions & 0 deletions tests/nombre-visible-tipo.test.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});