Skip to content
Merged
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
2 changes: 1 addition & 1 deletion composable-os/src/components/LanguageSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<LanguageSelector />);
const { container } = render(<LanguageSelector locales={["es"]} />);

expect(container).toBeEmptyDOMElement();
});
Expand Down
3 changes: 2 additions & 1 deletion composable-os/src/i18n/catalogues.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const BASE = "es";
* idioma se ofrece solo (`releasedLocales`): no hay un interruptor aparte.
*/
const TRANSLATED: Record<string, string[]> = {
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"],
};

/**
Expand All @@ -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",
Expand Down
191 changes: 191 additions & 0 deletions composable-os/src/i18n/english-workflows.test.tsx
Original file line number Diff line number Diff line change
@@ -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<I18n> {
const instance = i18next.createInstance();
await instance.init(buildI18nOptions(resources, "en"));
return instance;
}

const workflow = (over: Record<string, unknown> = {}) => ({
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(<Workflows />, 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"));
});
});
10 changes: 6 additions & 4 deletions composable-os/src/i18n/english.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
22 changes: 14 additions & 8 deletions composable-os/src/i18n/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down
58 changes: 58 additions & 0 deletions composable-os/src/locales/en/workflows.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"title": "Workflows",
Comment on lines +1 to +2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep English unreleased until the backend accepts it

Adding the final namespace makes releasedLocales() expose en immediately, but this commit leaves backend/app/core/i18n/__init__.py:30 with only ("es",) supported. Consequently, every authenticated user who selects English in Settings has changeLocale() send PATCH /auth/me, receive a 422 from update_my_locale(), and roll the UI back to Spanish; browser-detected English is likewise replaced by the server's Spanish effective locale after login. Ship the backend locale enablement atomically with this file or retain a frontend release gate.

Useful? React with 👍 / 👎.

"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"
}
}
4 changes: 2 additions & 2 deletions composable-os/src/pages/Login.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -92,7 +92,7 @@ describe("Login, in a second language", () => {
const { container } = renderWithI18n(<Login />, 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([]);
Expand Down
4 changes: 2 additions & 2 deletions composable-os/src/pages/Register.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -124,7 +124,7 @@ describe("Register, in a second language", () => {
const english = await createPseudoInstance("en");
const { container } = renderWithI18n(<Register />, 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([]);
});
Expand Down
10 changes: 10 additions & 0 deletions composable-os/src/test/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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[],
Expand All @@ -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) &&
Expand Down
Loading
Loading