From 371a0e8a9d4a20f982d567953ef1c5100dcf6db4 Mon Sep 17 00:00:00 2001 From: keilynrp Date: Wed, 7 Oct 2026 03:39:18 -0600 Subject: [PATCH] feat(i18n): workflows en ingles, 19 de 19 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tarea 12 del plan, decimonovena y ultima parte de los espacios: traduce a `en` el espacio `workflows` (lista, historial de ejecuciones y dialogo de alta). Con esto `en` tiene los 19 espacios de `es`. Se registra en `TRANSLATED` y tres cadenas iguales de verdad se aprueban en `IDENTICAL_TO_BASE`: `Workflows`, `Total` y `log`. Ortografia de EE. UU. Como `en` ya esta completo, el frontend lo ofrece solo (`releasedLocales`): el selector de idioma aparece, la deteccion del navegador y `setLocale` lo aceptan. El backend sigue sin ofrecerlo (`UNRELEASED_LOCALES`); pasarlo a `SUPPORTED_LOCALES` es el paso siguiente. Lo que eso obliga a ajustar en los tests, que asumian un solo idioma ofrecido: - `test/pin-locale.ts`: jsdom declara `en-US`, y el `i18n` global arrancaba en ingles. Se fija el navegador de pruebas en `es`; los tests de ingles usan su propia instancia. - `notFromCatalogue` (y los filtros propios de Login y Register) descarta los nombres de idioma («English», «Espanol»): el selector aparece en login, registro y ajustes y no son texto que falte en un catalogo. - `i18n.test.ts`: los dos tests de la puerta de lanzamiento pasan a comprobar que `en` se ofrece y que un idioma sin catalogo (`fr`) sigue fuera de alcance. - `english.test.tsx`: el test de caida al espanol quita un espacio a mano, porque ya no queda ninguno pendiente. - `LanguageSelector.test.tsx`: «no dibuja nada con un solo idioma» pasa `locales={["es"]}`. Tests en ingles real (`english-workflows.test.tsx`): cabecera y KPI, plurales, tipos de disparador y accion, fecha formateada, estado vacio, botones con nombre, historial con estados y error del backend, el dialogo de alta con el mensaje por defecto en ingles y el mensaje escrito por el usuario, y el borrado. Corrige tambien el checklist del plan, que tras #72 decia «Queda 1: `workflows`, `workflows`.» por un reemplazo mio mal hecho. Comprobado con mutaciones sobre el catalogo real: un texto copiado del espanol, un marcador `{{count}}` perdido y una clave perdida fallan el test. Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/LanguageSelector.test.tsx | 2 +- composable-os/src/i18n/catalogues.test.ts | 3 +- .../src/i18n/english-workflows.test.tsx | 191 ++++++++++++++++++ composable-os/src/i18n/english.test.tsx | 10 +- composable-os/src/i18n/i18n.test.ts | 22 +- composable-os/src/locales/en/workflows.json | 58 ++++++ composable-os/src/pages/Login.test.tsx | 4 +- composable-os/src/pages/Register.test.tsx | 4 +- composable-os/src/test/i18n.tsx | 10 + composable-os/src/test/pin-locale.ts | 16 ++ composable-os/src/test/setup.ts | 2 + docs/IMPLEMENTATION_ALIGNMENT.md | 2 +- .../superpowers/plans/2026-08-07-i18n-full.md | 8 +- 13 files changed, 310 insertions(+), 22 deletions(-) create mode 100644 composable-os/src/i18n/english-workflows.test.tsx create mode 100644 composable-os/src/locales/en/workflows.json create mode 100644 composable-os/src/test/pin-locale.ts diff --git a/composable-os/src/components/LanguageSelector.test.tsx b/composable-os/src/components/LanguageSelector.test.tsx index 18fc225..8ee765c 100644 --- a/composable-os/src/components/LanguageSelector.test.tsx +++ b/composable-os/src/components/LanguageSelector.test.tsx @@ -24,7 +24,7 @@ describe("LanguageSelector", () => { it("draws nothing while there is only one language to choose", () => { // Un desplegable de una opcion es ruido: el selector aparece cuando hay // algo que elegir, sin tocar nada mas. - const { container } = render(); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); diff --git a/composable-os/src/i18n/catalogues.test.ts b/composable-os/src/i18n/catalogues.test.ts index 6f14e50..24a2e0a 100644 --- a/composable-os/src/i18n/catalogues.test.ts +++ b/composable-os/src/i18n/catalogues.test.ts @@ -31,7 +31,7 @@ const BASE = "es"; * idioma se ofrece solo (`releasedLocales`): no hay un interruptor aparte. */ const TRANSLATED: Record = { - en: ["common", "errors", "auth", "layout", "dashboard", "analytics", "companyProfile", "customerPortal", "contracts", "crm", "discovery", "hr", "inventory", "invoicing", "portalBuilder", "projects", "sales", "settings"], + en: ["common", "errors", "auth", "layout", "dashboard", "analytics", "companyProfile", "customerPortal", "contracts", "crm", "discovery", "hr", "inventory", "invoicing", "portalBuilder", "projects", "sales", "settings", "workflows"], }; /** @@ -58,6 +58,7 @@ const IDENTICAL_TO_BASE = [ "portalBuilder:sessions.email", "sales:quotes.total", "sales:quotes.pdf", "sales:orders.total", "sales:detail.pdf", "sales:detail.lines.sku", "sales:detail.totals.subtotal", "sales:detail.totals.total", + "workflows:title", "workflows:kpi.total", "workflows:actionType.log", "settings:tabs.general", "settings:architecture.nodes.gateway.label", "settings:architecture.nodes.gateway.sublabel", "settings:architecture.nodes.crm.label", "settings:architecture.nodes.eventbus.label", "settings:architecture.nodes.postgres.label", "settings:architecture.cards.gateway.title", diff --git a/composable-os/src/i18n/english-workflows.test.tsx b/composable-os/src/i18n/english-workflows.test.tsx new file mode 100644 index 0000000..b7c9223 --- /dev/null +++ b/composable-os/src/i18n/english-workflows.test.tsx @@ -0,0 +1,191 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import i18next, { type i18n as I18n } from "i18next"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { buildI18nOptions, resources } from "@/i18n"; +import { formatDate } from "@/i18n/format"; +import Workflows from "@/pages/Workflows"; +import { renderPageWithI18n } from "@/test/i18n"; + +/** + * `workflows` en el ingles *real* (tarea 12 del plan de i18n): la lista, el historial de + * ejecuciones y el dialogo de alta con el catalogo `en` que se envia, no el + * pseudo-localizado de `Workflows.test.tsx`. + */ + +const svc = vi.hoisted(() => ({ + getAll: vi.fn(), create: vi.fn(), toggle: vi.fn(), delete: vi.fn(), getRuns: vi.fn(), +})); +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); + +vi.mock("@/services/workflows", () => ({ workflowsService: svc })); +vi.mock("sonner", () => ({ toast })); + +async function english(): Promise { + const instance = i18next.createInstance(); + await instance.init(buildI18nOptions(resources, "en")); + return instance; +} + +const workflow = (over: Record = {}) => ({ + id: "w1", name: "Lead intake", description: "Alerts the team", trigger_type: "event", + trigger_config: { event_type: "LeadCaptured" }, conditions: [{ field: "a", operator: "eq", value: 1 }], + actions: [ + { type: "log", config: { message: "hello" } }, + { type: "log", config: {} }, + { type: "log", config: {} }, + ], + enabled: true, run_count: 1, last_triggered_at: "2026-09-30T12:00:00Z", + created_at: "2026-09-01T00:00:00Z", updated_at: "2026-09-01T00:00:00Z", ...over, +}); + +const runs = [ + { + id: "r1", workflow_id: "w1", status: "completed", trigger_event_type: "LeadCaptured", trigger_event_id: null, + steps_result: [{ action_type: "log", status: "completed", detail: "", duration_ms: 12 }], + error: null, created_at: "2026-09-30T12:00:00Z", + }, + { + id: "r2", workflow_id: "w1", status: "failed", trigger_event_type: null, trigger_event_id: null, + steps_result: [], error: "boom", created_at: "2026-09-30T13:00:00Z", + }, +]; + +const renderPage = (instance: I18n) => renderPageWithI18n(, instance); +const viewRuns = async () => fireEvent.click((await screen.findAllByRole("button", { name: "View runs" }))[0]); + +beforeEach(() => { + Object.values(svc).forEach((fn) => fn.mockReset()); + Object.values(toast).forEach((fn) => fn.mockReset()); + svc.getAll.mockResolvedValue([ + workflow(), + workflow({ id: "w2", name: "Closed sales", description: null, conditions: [{}, {}], actions: [{ type: "log", config: {} }], run_count: 5, enabled: false, last_triggered_at: null }), + ]); + svc.getRuns.mockResolvedValue(runs); + svc.create.mockResolvedValue({}); + svc.delete.mockResolvedValue(undefined); +}); + +describe("Workflows in English", () => { + it("renders the header, the KPIs and the cards", async () => { + renderPage(await english()); + + expect(screen.getByRole("heading", { name: "Workflows" })).toBeInTheDocument(); + expect(screen.getByText("Event-driven automation — triggers, conditions and actions.")).toBeInTheDocument(); + expect(await screen.findByText("Lead intake")).toBeInTheDocument(); + for (const text of ["Total", "Active", "Runs"]) { + expect(screen.getByText(text)).toBeInTheDocument(); + } + expect(screen.getAllByRole("button", { name: /New workflow/ })[0]).toBeInTheDocument(); + }); + + it("agrees plurals with the number, including one", async () => { + renderPage(await english()); + await screen.findByText("Lead intake"); + + expect(screen.getByText("1 run")).toBeInTheDocument(); + expect(screen.getByText("5 runs")).toBeInTheDocument(); + expect(screen.getByText("1 condition")).toBeInTheDocument(); + expect(screen.getByText("2 conditions")).toBeInTheDocument(); + expect(screen.getByText("+1 more")).toBeInTheDocument(); + }); + + it("shows the trigger and action types, and the last run as a formatted date", async () => { + renderPage(await english()); + await screen.findByText("Lead intake"); + + expect(screen.getAllByText("event").length).toBeGreaterThan(0); + expect(screen.getAllByText("log").length).toBeGreaterThan(0); + expect(screen.queryByText(/2026-09-30/)).not.toBeInTheDocument(); + expect(screen.getByText(formatDate("2026-09-30T12:00:00Z", "en", "short"))).toBeInTheDocument(); + }); + + it("explains an empty list and offers to create the first workflow", async () => { + svc.getAll.mockResolvedValue([]); + renderPage(await english()); + + expect(await screen.findByText("No workflows yet")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Create the first one/ })).toBeInTheDocument(); + }); + + it("names the icon-only buttons and the switches", async () => { + renderPage(await english()); + await screen.findByText("Lead intake"); + + expect(screen.getAllByRole("button", { name: "View runs" })).toHaveLength(2); + expect(screen.getAllByRole("button", { name: "Delete workflow" })).toHaveLength(2); + expect(screen.getByRole("switch", { name: "Turn Lead intake on or off" })).toBeInTheDocument(); + }); + + it("shows the run history with English statuses and the raw backend error", async () => { + renderPage(await english()); + await viewRuns(); + + expect(await screen.findByText("Run history")).toBeInTheDocument(); + const dialog = screen.getByRole("dialog"); + expect(await within(dialog).findByText("failed")).toBeInTheDocument(); + expect(within(dialog).getAllByText("completed").length).toBeGreaterThan(0); + expect(within(dialog).getByText("Event: LeadCaptured")).toBeInTheDocument(); + expect(within(dialog).getByText("12ms")).toBeInTheDocument(); + expect(within(dialog).getByText("boom")).toBeInTheDocument(); + }); + + it("falls back to the raw value for a status the catalogue does not know", async () => { + svc.getRuns.mockResolvedValue([{ ...runs[0], status: "queued_for_retry", steps_result: [] }]); + renderPage(await english()); + await viewRuns(); + + expect(await screen.findByText("queued_for_retry")).toBeInTheDocument(); + }); + + it("shows the empty run history", async () => { + svc.getRuns.mockResolvedValue([]); + renderPage(await english()); + await viewRuns(); + + expect(await screen.findByText("No runs yet")).toBeInTheDocument(); + }); + + it("creates a workflow, sending the English default action message", async () => { + renderPage(await english()); + await screen.findByText("Lead intake"); + + fireEvent.click(screen.getAllByRole("button", { name: /New workflow/ })[0]); + expect(await screen.findByText("Trigger event")).toBeInTheDocument(); + expect(screen.getByText("Description (optional)")).toBeInTheDocument(); + expect(screen.getByText(/^Available events: LeadCaptured, /)).toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText("E.g. Notify new lead"), { target: { value: "My flow" } }); + fireEvent.change(screen.getByPlaceholderText("E.g. LeadCaptured, QuoteAccepted"), { target: { value: "QuoteAccepted" } }); + fireEvent.click(screen.getByRole("button", { name: "Create" })); + + await waitFor(() => expect(svc.create).toHaveBeenCalled()); + const payload = svc.create.mock.calls[0][0]; + expect(payload.name).toBe("My flow"); + expect(payload.actions).toEqual([{ type: "log", config: { message: "Workflow executed" } }]); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Workflow created")); + }); + + it("sends the message the user typed instead of the default", async () => { + renderPage(await english()); + await screen.findByText("Lead intake"); + + fireEvent.click(screen.getAllByRole("button", { name: /New workflow/ })[0]); + fireEvent.change(await screen.findByPlaceholderText("E.g. Notify new lead"), { target: { value: "My flow" } }); + fireEvent.change(screen.getByPlaceholderText("E.g. LeadCaptured, QuoteAccepted"), { target: { value: "QuoteAccepted" } }); + fireEvent.change(screen.getByPlaceholderText("Use {field} for event values"), { target: { value: "Closed {field}" } }); + fireEvent.click(screen.getByRole("button", { name: "Create" })); + + await waitFor(() => expect(svc.create).toHaveBeenCalled()); + expect(svc.create.mock.calls[0][0].actions).toEqual([{ type: "log", config: { message: "Closed {field}" } }]); + }); + + it("deletes a workflow and confirms it", async () => { + renderPage(await english()); + await screen.findByText("Lead intake"); + + fireEvent.click(screen.getAllByRole("button", { name: "Delete workflow" })[0]); + + await waitFor(() => expect(svc.delete).toHaveBeenCalled()); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Workflow deleted")); + }); +}); diff --git a/composable-os/src/i18n/english.test.tsx b/composable-os/src/i18n/english.test.tsx index 493116c..36dc66c 100644 --- a/composable-os/src/i18n/english.test.tsx +++ b/composable-os/src/i18n/english.test.tsx @@ -93,11 +93,13 @@ describe("the real English catalogue", () => { }); it("falls back to Spanish for a namespace English does not have yet", async () => { - // Es la razon por la que `en` no se ofrece hasta estar completo: sin esto un + // Es la razon por la que un idioma no se ofrece hasta estar completo: sin esto un // usuario veria la pantalla mezclada. Se fija para que nadie lo descubra en - // produccion. - const instance = await english(); - // `workflows` sigue pendiente: cuando se traduzca, este test pasa a otro dominio. + // produccion. `en` ya esta completo, asi que se le quita un espacio a mano. + const { workflows: _pending, ...partial } = resources.en; + const instance = i18next.createInstance(); + await instance.init(buildI18nOptions({ ...resources, en: partial }, "en")); + expect(instance.t("workflows:new")).toBe("Nuevo Workflow"); }); }); diff --git a/composable-os/src/i18n/i18n.test.ts b/composable-os/src/i18n/i18n.test.ts index f2725e7..5768bc0 100644 --- a/composable-os/src/i18n/i18n.test.ts +++ b/composable-os/src/i18n/i18n.test.ts @@ -16,19 +16,25 @@ afterEach(async () => { }); describe("catalogues", () => { - it("offers Spanish only while English is incomplete", () => { - // Si esto cambia, el ultimo dominio de `en` ya esta traducido y varios tests - // pasan a ser otro caso: hay que revisarlos, no solo actualizar el numero. - expect(SUPPORTED_LOCALES).toEqual(["es"]); - expect(UNRELEASED_LOCALES).toEqual(["en"]); + it("offers every language that has all the namespaces of Spanish", () => { + // `en` completo (tarea 12, ultimo dominio) entra solo: no hay un interruptor aparte. + expect([...SUPPORTED_LOCALES].sort()).toEqual(["en", "es"]); + expect(UNRELEASED_LOCALES).toEqual([]); }); - it("keeps an unreleased language out of reach: setLocale and the browser language ignore it", async () => { - expect(resources.en).toBeDefined(); - expect(await setLocale("en")).toBe("es"); + it("keeps a language without a shipped catalogue out of reach: setLocale ignores it", async () => { + expect(resources.fr).toBeUndefined(); + expect(await setLocale("fr")).toBe("es"); expect(i18n.language).toBe("es"); }); + it("switches to a released language and remembers it", async () => { + expect(await setLocale("en")).toBe("en"); + expect(i18n.language).toBe("en"); + expect(i18n.t("auth:login.title")).toBe("Sign in"); + expect(window.localStorage.getItem(LOCALE_STORAGE_KEY)).toBe("en"); + }); + it("resolves a key from the bundled catalogue", () => { expect(i18n.t("auth:login.title")).toBe("Iniciar sesión"); }); diff --git a/composable-os/src/locales/en/workflows.json b/composable-os/src/locales/en/workflows.json new file mode 100644 index 0000000..89c07fe --- /dev/null +++ b/composable-os/src/locales/en/workflows.json @@ -0,0 +1,58 @@ +{ + "title": "Workflows", + "subtitle": "Event-driven automation — triggers, conditions and actions.", + "new": "New workflow", + "deleted": "Workflow deleted", + "kpi": { + "total": "Total", + "active": "Active", + "runs": "Runs" + }, + "empty": { + "title": "No workflows yet", + "createFirst": "Create the first one" + }, + "card": { + "conditions_one": "{{count}} condition", + "conditions_other": "{{count}} conditions", + "moreActions": "+{{count}} more", + "runs_one": "{{count}} run", + "runs_other": "{{count}} runs", + "toggle": "Turn {{name}} on or off", + "viewRuns": "View runs", + "delete": "Delete workflow" + }, + "create": { + "title": "New workflow", + "name": "Name", + "namePlaceholder": "E.g. Notify new lead", + "description": "Description (optional)", + "descriptionPlaceholder": "What this workflow does", + "event": "Trigger event", + "eventPlaceholder": "E.g. LeadCaptured, QuoteAccepted", + "availableEvents": "Available events: {{events}}", + "actionMessage": "Action message (log)", + "actionMessagePlaceholder": "Use {field} for event values", + "defaultMessage": "Workflow executed", + "cancel": "Cancel", + "submit": "Create", + "created": "Workflow created" + }, + "runs": { + "title": "Run history", + "empty": "No runs yet", + "event": "Event: {{type}}" + }, + "runStatus": { + "completed": "completed", + "failed": "failed", + "running": "running", + "skipped": "skipped" + }, + "triggerType": { + "event": "event" + }, + "actionType": { + "log": "log" + } +} diff --git a/composable-os/src/pages/Login.test.tsx b/composable-os/src/pages/Login.test.tsx index 97ca874..2c75861 100644 --- a/composable-os/src/pages/Login.test.tsx +++ b/composable-os/src/pages/Login.test.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import i18n from "@/i18n"; import { ApiError } from "@/lib/api"; import Login from "@/pages/Login"; -import { createPseudoInstance, renderWithI18n, visibleStrings } from "@/test/i18n"; +import { LANGUAGE_NAMES, createPseudoInstance, renderWithI18n, visibleStrings } from "@/test/i18n"; const loginMock = vi.fn(); const navigateMock = vi.fn(); @@ -92,7 +92,7 @@ describe("Login, in a second language", () => { const { container } = renderWithI18n(, english); const hardcoded = visibleStrings(container).filter( - (text) => !text.startsWith("EN(") && text !== BRAND, + (text) => !text.startsWith("EN(") && text !== BRAND && !LANGUAGE_NAMES.test(text), ); expect(hardcoded).toEqual([]); diff --git a/composable-os/src/pages/Register.test.tsx b/composable-os/src/pages/Register.test.tsx index 2d8b8fa..e3cff16 100644 --- a/composable-os/src/pages/Register.test.tsx +++ b/composable-os/src/pages/Register.test.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import i18n from "@/i18n"; import { ApiError } from "@/lib/api"; import Register from "@/pages/Register"; -import { createPseudoInstance, renderWithI18n, visibleStrings } from "@/test/i18n"; +import { LANGUAGE_NAMES, createPseudoInstance, renderWithI18n, visibleStrings } from "@/test/i18n"; const registerMock = vi.fn(); const navigateMock = vi.fn(); @@ -124,7 +124,7 @@ describe("Register, in a second language", () => { const english = await createPseudoInstance("en"); const { container } = renderWithI18n(, english); - const hardcoded = visibleStrings(container).filter((text) => !text.startsWith("EN(") && text !== BRAND); + const hardcoded = visibleStrings(container).filter((text) => !text.startsWith("EN(") && text !== BRAND && !LANGUAGE_NAMES.test(text)); expect(hardcoded).toEqual([]); }); diff --git a/composable-os/src/test/i18n.tsx b/composable-os/src/test/i18n.tsx index 856ca12..f69d7c8 100644 --- a/composable-os/src/test/i18n.tsx +++ b/composable-os/src/test/i18n.tsx @@ -80,6 +80,14 @@ export function renderPageWithI18n( ); } +/** + * Cada idioma escrito en si mismo, como lo pinta `LanguageSelector` (el nombre de un + * idioma no se traduce: quien busca el suyo lo reconoce asi). Se descarta siempre: + * en cuanto hay mas de un idioma ofrecido el selector aparece en login, registro y + * ajustes, y no es texto que falte en un catalogo. + */ +export const LANGUAGE_NAMES =/^(Español|English)$/; + /** * Lo que una pagina pinta sin que venga del catalogo de la app: texto que llega * del backend (nombres, descripciones) y fechas o numeros ya formateados. La @@ -89,6 +97,7 @@ export function renderPageWithI18n( * - `data`: valores del backend que el test sembro (se descartan si el texto los contiene). * - se descarta tambien todo texto con un ano de cuatro cifras (una fecha formateada) * y los numeros con unidad abreviada (`12ms`, `1,234.5ms`), que los produce `Intl`. + * - se descartan los nombres de idioma (`LANGUAGE_NAMES`). */ export function notFromCatalogue( texts: string[], @@ -99,6 +108,7 @@ export function notFromCatalogue( return texts.filter( (text) => !text.startsWith(prefix) && + !LANGUAGE_NAMES.test(text) && !data.some((value) => text.includes(value)) && !/\d{4}/.test(text) && !/^\d[\d.,]*\s?[a-zA-Zµ]{0,3}$/.test(text) && diff --git a/composable-os/src/test/pin-locale.ts b/composable-os/src/test/pin-locale.ts new file mode 100644 index 0000000..d2fdf02 --- /dev/null +++ b/composable-os/src/test/pin-locale.ts @@ -0,0 +1,16 @@ +/** + * jsdom declara `en-US` como idioma del navegador. Mientras `en` estaba incompleto no + * importaba (la deteccion lo descartaba y caia al espanol); con los 19 espacios + * traducidos `en` se ofrece, y el `i18n` global arrancaria en ingles: los tests de + * pagina "in Spanish" leerian ingles. Los tests de ingles construyen su propia + * instancia (`createInstance`), asi que el global se queda en espanol, que es el + * idioma de referencia. + * + * Va el primero en `setup.ts`: `@/i18n` lee el idioma al importarse. + */ +for (const [key, value] of [ + ["language", "es"], + ["languages", ["es"]], +] as const) { + Object.defineProperty(navigator, key, { value, configurable: true }); +} diff --git a/composable-os/src/test/setup.ts b/composable-os/src/test/setup.ts index ba2ab4f..ef857a1 100644 --- a/composable-os/src/test/setup.ts +++ b/composable-os/src/test/setup.ts @@ -1,4 +1,6 @@ import "@testing-library/jest-dom"; +// Antes que `@/i18n`, que lee el idioma del navegador al importarse. +import "./pin-locale"; // Inicializa i18next como lo hace main.tsx: sin esto, cualquier codigo que llame // a `t()` o a translateApiError devolveria la clave cruda. import "@/i18n"; diff --git a/docs/IMPLEMENTATION_ALIGNMENT.md b/docs/IMPLEMENTATION_ALIGNMENT.md index d516dec..ab4fa8d 100644 --- a/docs/IMPLEMENTATION_ALIGNMENT.md +++ b/docs/IMPLEMENTATION_ALIGNMENT.md @@ -83,7 +83,7 @@ Legacy documents are historical reference only unless explicitly promoted by ADR | External brand-site integration | Strategic direction documented; public lead intake v1 now has code, contract tests, decision logging, event metadata, and rate-limit response behavior, but is not yet operationally proven under production-like traffic | One validated public integration slice before SDK/builder expansion | High | Run deployment-like validation for CRM public intake before expanding public surfaces | | Public API boundary | Portal proves public interaction can work; CRM public intake now defines the initial site-key/origin/idempotency/rate-limit model | Explicit public endpoint policy with origin, key, idempotency, abuse controls, and logging rules | High | Promote the CRM public intake contract into broader public API conventions only after production validation | | Future stack pressure | Strategic docs mention SDK, builder, semantic layers, and UKIP-compatible ideas | Current stack remains frozen until justified | Medium | Treat those areas as target-state only unless promoted by ADR | -| Internationalization | Committed as a core requirement (ADR 0014, product answer 2026-08-07), half built, and now fully decided. The backend is already language-neutral: registered error codes with structured `detail`, no user-facing prose, parity enforced in CI. The three open decisions closed 2026-08-07 — `react-i18next` promoted for the frontend tier only (ADR 0015), and locale ownership settled on `users.locale` with a workspace default plus `portal_sessions.locale` for external recipients (ADR 0016). Built: the frontend catalogue for every page, the three locale columns with their migration, the single resolution helper (`resolve_locale`, and `resolve_user_locale` for an authenticated user), and the backend catalogues (`app/core/i18n/locales//.json`, `translate()`), which today carry the invoice PDF labels and the 9 email templates (internal ones in the recipient's language, the two client-facing ones in the portal session's). Not built: the quote PDF (`sales/pdf.py`), the Spanish prose still returned by some portal API responses and by the workflow `send_email` action's defaults, locale-aware date and amount formatting in the server-rendered artifacts, and a second language offered to anyone. English is under way: the backend catalogues (invoice PDF, 9 emails) are complete but held back in `UNRELEASED_LOCALES`, the frontend has `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing`, `portalBuilder`, `projects`, `sales` and `settings` translated (18 of 19 namespaces) and offers a language only once every namespace exists. The CI checks (key parity, orphan strings, untranslated copies) already guard it: `src/i18n/catalogues.test.ts`, `backend/tests/test_i18n_catalogue_usage.py` — a frontend catalogue cannot reach the server-rendered artifacts | The product ships in more than one language, with no user-facing string hardcoded in either tier | Medium | Do not start a partial extraction: a half-translated screen reads as broken and is worse than none. The sequenced plan is `docs/superpowers/plans/2026-08-07-i18n-full.md`; the first executable task is the migration. Nothing blocks it now, but nothing schedules it either — it does not improve the wedge, so when it enters is an explicit call. Meanwhile hold the line that already works — new backend code raises codes, never prose | +| Internationalization | Committed as a core requirement (ADR 0014, product answer 2026-08-07), half built, and now fully decided. The backend is already language-neutral: registered error codes with structured `detail`, no user-facing prose, parity enforced in CI. The three open decisions closed 2026-08-07 — `react-i18next` promoted for the frontend tier only (ADR 0015), and locale ownership settled on `users.locale` with a workspace default plus `portal_sessions.locale` for external recipients (ADR 0016). Built: the frontend catalogue for every page, the three locale columns with their migration, the single resolution helper (`resolve_locale`, and `resolve_user_locale` for an authenticated user), and the backend catalogues (`app/core/i18n/locales//.json`, `translate()`), which today carry the invoice PDF labels and the 9 email templates (internal ones in the recipient's language, the two client-facing ones in the portal session's). Not built: the quote PDF (`sales/pdf.py`), the Spanish prose still returned by some portal API responses and by the workflow `send_email` action's defaults, locale-aware date and amount formatting in the server-rendered artifacts, and a second language offered to anyone. English is under way: the backend catalogues (invoice PDF, 9 emails) are complete but held back in `UNRELEASED_LOCALES`, the frontend has `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing`, `portalBuilder`, `projects`, `sales`, `settings` and `workflows` translated (19 of 19 namespaces) and offers a language only once every namespace exists. The CI checks (key parity, orphan strings, untranslated copies) already guard it: `src/i18n/catalogues.test.ts`, `backend/tests/test_i18n_catalogue_usage.py` — a frontend catalogue cannot reach the server-rendered artifacts | The product ships in more than one language, with no user-facing string hardcoded in either tier | Medium | Do not start a partial extraction: a half-translated screen reads as broken and is worse than none. The sequenced plan is `docs/superpowers/plans/2026-08-07-i18n-full.md`; the first executable task is the migration. Nothing blocks it now, but nothing schedules it either — it does not improve the wedge, so when it enters is an explicit call. Meanwhile hold the line that already works — new backend code raises codes, never prose | | Schema drift between models and database | Closed. `alembic revision --autogenerate` on an unchanged model set emits an empty `upgrade()`, so a generated migration now contains only the intended change. Resolved in three steps: foreign-key delete rules declared in the models, `users.notification_preferences` tightened to `NOT NULL` in the database, and two leftovers removed — a redundant unique constraint on `public_sites.api_key` that duplicated the unique index, and an index on `quote_events.user_id` that no query used | Autogenerate stays silent on an unchanged model set | Medium | Keep it closed: run autogenerate before and after a model change and treat any unrelated output as drift to fix in its own change set, not to hand-edit away | --- diff --git a/docs/superpowers/plans/2026-08-07-i18n-full.md b/docs/superpowers/plans/2026-08-07-i18n-full.md index b0aa03c..9b21917 100644 --- a/docs/superpowers/plans/2026-08-07-i18n-full.md +++ b/docs/superpowers/plans/2026-08-07-i18n-full.md @@ -459,9 +459,11 @@ completa migrada no hay patron que seguir. paneles), `invoicing` (facturas, pagos y alta), `portalBuilder` (sesiones, constructor de paginas y bloques de tienda), `projects` (lista, detalle con tareas y dialogos de alta), `sales` (panel, cotizaciones, ordenes - y el detalle de una cotizacion con su dialogo de compartir) y `settings` (las cinco - pestanas de Ajustes). - Queda 1: `workflows`, `workflows`. + y el detalle de una cotizacion con su dialogo de compartir), `settings` (las cinco + pestanas de Ajustes) y `workflows` (lista, historial de ejecuciones y alta). + Los 19 espacios estan traducidos. Como `en` ya tiene todos los de `es`, el + frontend lo ofrece solo (`releasedLocales`): selector, deteccion del navegador y + `setLocale`. El backend sigue sin ofrecerlo; ver el punto siguiente. - [ ] Ofrecer `en`: mover `en` de `UNRELEASED_LOCALES` a `SUPPORTED_LOCALES` en el backend y el selector de idioma en el dialogo de compartir (pendiente desde la tarea 2). - [ ] Recorrer la aplicacion en ingles buscando lo que el segundo idioma revela: