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
7 changes: 6 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"],
en: ["common", "errors", "auth", "layout", "dashboard", "analytics", "companyProfile", "customerPortal", "contracts", "crm", "discovery", "hr", "inventory", "invoicing", "portalBuilder", "projects", "sales", "settings"],
};

/**
Expand All @@ -58,6 +58,11 @@ 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",
"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",
"settings:architecture.cards.eventbus.title", "settings:health.version", "settings:health.services.api",
"settings:health.services.postgres",
"analytics:tabs.pipeline","companyProfile:identity.logoAlt", "companyProfile:contact.email",
"customerPortal:quote.subtotal", "customerPortal:quote.total",
];
Expand Down
181 changes: 181 additions & 0 deletions composable-os/src/i18n/english-settings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { fireEvent, screen, waitFor } 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 Settings from "@/pages/Settings";
import { renderPageWithI18n } from "@/test/i18n";

/**
* `settings` en el ingles *real* (tarea 12 del plan de i18n): las cinco pestanas de
* Ajustes con el catalogo `en` que se envia, no el pseudo-localizado de
* `Settings.test.tsx`.
*/

const svc = vi.hoisted(() => ({ getHealth: vi.fn() }));
const http = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn() }));

vi.mock("@/services/health", () => ({ healthService: svc }));
vi.mock("@/lib/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api")>()),
api: http,
}));

async function english(): Promise<I18n> {
const instance = i18next.createInstance();
await instance.init(buildI18nOptions(resources, "en"));
return instance;
}

const health = (over: Record<string, unknown> = {}) => ({
status: "healthy",
version: "1.2.3",
checks: [
{ name: "api", status: "healthy", latency_ms: 12 },
{ name: "postgres", status: "degraded", latency_ms: 340 },
],
...over,
});

const prefs = (over: Record<string, unknown> = {}) => ({
email_enabled: true,
email_events: { QuoteAccepted: true, SalesOrderCreated: true, WorkflowFailed: true, InventoryLowThresholdDetected: false, InvoiceOverdue: false },
...over,
});

beforeEach(() => {
svc.getHealth.mockReset().mockResolvedValue(health());
http.get.mockReset().mockResolvedValue(prefs());
http.put.mockReset().mockImplementation(async (_url: string, patch: { email_enabled?: boolean; email_events?: Record<string, boolean> }) => {
const current = prefs();
return { ...current, ...patch, email_events: { ...current.email_events, ...(patch.email_events ?? {}) } };
});
});

/** Radix activa una pestana con `mousedown`, no con `click`. */
const openTab = (name: string | RegExp) => fireEvent.mouseDown(screen.getByRole("tab", { name }), { button: 0 });

describe("Settings in English", () => {
it("renders the header and the five tabs", async () => {
renderPageWithI18n(<Settings />, await english());

expect(screen.getByRole("heading", { name: "Settings" })).toBeInTheDocument();
expect(screen.getByText("Platform configuration, architecture and system status.")).toBeInTheDocument();
for (const tab of ["Architecture", "System health", "Notifications", "General", "Team"]) {
expect(screen.getByRole("tab", { name: tab })).toBeInTheDocument();
}
});

it("shows the diagram and the three layer cards in English", async () => {
renderPageWithI18n(<Settings />, await english());

expect(screen.getByText("Platform architecture")).toBeInTheDocument();
expect(screen.getByText(/Hover over the components/)).toBeInTheDocument();
for (const text of ["Client apps", "Graph database", "Vector database", "AI agents", "API LAYER", "MICROSERVICES", "DATA LAYER", "HTTPS"]) {
expect(screen.getByText(text)).toBeInTheDocument();
}
expect(screen.getAllByText("Microservice")).toHaveLength(6);
expect(screen.getByText("Data layer")).toBeInTheDocument();
expect(screen.getByText(/Asynchronous pub\/sub messaging/)).toBeInTheDocument();
expect(screen.getByText(/Handles authentication, rate limiting/)).toBeInTheDocument();
expect(screen.queryByText(/Microservicio|CAPA DE/)).not.toBeInTheDocument();
});

describe("system health", () => {
it("shows each service with an English status and the latency", async () => {
renderPageWithI18n(<Settings />, await english());
openTab("System health");

expect(await screen.findByText("healthy", { selector: "div" })).toBeInTheDocument();
expect(screen.getByText("degraded")).toBeInTheDocument();
expect(screen.getByText("12ms")).toBeInTheDocument();
expect(screen.getByText("340ms")).toBeInTheDocument();
expect(screen.getAllByText("Latency")).toHaveLength(2);
});

it("shows the overall status, the version and how fresh the data is", async () => {
renderPageWithI18n(<Settings />, await english());
openTab("System health");

expect(await screen.findByText(/Overall status:/)).toBeInTheDocument();
expect(screen.getByText(/v1\.2\.3/)).toBeInTheDocument();
expect(await screen.findByText(/^Updated (now|\d+ ?s(ec\.)? ago)$/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
});

it("explains when the backend cannot be reached", async () => {
svc.getHealth.mockRejectedValue(new Error("down"));
renderPageWithI18n(<Settings />, await english());
openTab("System health");

expect(await screen.findByText("Could not connect to the system. Check that the backend is running.", undefined, { timeout: 5000 })).toBeInTheDocument();
});
});

describe("notifications", () => {
it("lists the event types in English with named switches and a summary", async () => {
renderPageWithI18n(<Settings />, await english());
openTab("Notifications");

expect(await screen.findByText("Quotes accepted")).toBeInTheDocument();
expect(screen.getByText("Event types")).toBeInTheDocument();
expect(screen.getByText("When a customer accepts a quote")).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Quotes accepted" })).toBeChecked();
expect(screen.getByRole("switch", { name: "Low stock" })).not.toBeChecked();
expect(screen.getByText("3 of 5 event types active")).toBeInTheDocument();
});

it("saves a per-event toggle and updates the summary", async () => {
renderPageWithI18n(<Settings />, await english());
openTab("Notifications");

fireEvent.click(await screen.findByRole("switch", { name: "Overdue invoices" }));

await waitFor(() => expect(http.put).toHaveBeenCalledWith("/notifications/preferences", { email_events: { InvoiceOverdue: true } }));
expect(await screen.findByText("4 of 5 event types active")).toBeInTheDocument();
});

it("turns everything off from the master switch", async () => {
renderPageWithI18n(<Settings />, await english());
openTab("Notifications");

fireEvent.click(await screen.findByRole("switch", { name: "Email notifications" }));

await waitFor(() => expect(http.put).toHaveBeenCalledWith("/notifications/preferences", { email_enabled: false }));
expect(await screen.findByText("All email notifications are turned off")).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Quotes accepted" })).toBeDisabled();
});

it("explains the in-app notifications", async () => {
renderPageWithI18n(<Settings />, await english());
openTab("Notifications");

expect(await screen.findByText("Real-time notifications")).toBeInTheDocument();
expect(screen.getByText(/delivered over WebSocket/)).toBeInTheDocument();
});
});

describe("general and team", () => {
it("shows the workspace form and the preferences", async () => {
renderPageWithI18n(<Settings />, await english());
openTab("General");

expect(await screen.findByText("Workspace settings")).toBeInTheDocument();
expect(screen.getByLabelText("Workspace name")).toHaveValue("Composable OS");
expect(screen.getByLabelText("Workspace URL")).toBeDisabled();
expect(screen.getByRole("switch", { name: "Enable AI recommendations" })).toBeChecked();
expect(screen.getByRole("switch", { name: "Automatic knowledge graph linking" })).toBeChecked();
expect(screen.getByText("Preferences")).toBeInTheDocument();
});

it("lists the team with English roles", async () => {
renderPageWithI18n(<Settings />, await english());
openTab("Team");

expect(await screen.findByText("Team members")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Invite/ })).toBeInTheDocument();
expect(screen.getAllByText("Admin")).toHaveLength(2);
expect(screen.getAllByText("Member")).toHaveLength(4);
});
});
});
131 changes: 131 additions & 0 deletions composable-os/src/locales/en/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
{
"title": "Settings",
"subtitle": "Platform configuration, architecture and system status.",
"tabs": {
"architecture": "Architecture",
"health": "System health",
"notifications": "Notifications",
"general": "General",
"team": "Team"
},
"architecture": {
"title": "Platform architecture",
"hint": "Hover over the components to follow the data flow paths.",
"layers": {
"presentation": "PRESENTATION",
"api": "API LAYER",
"microservices": "MICROSERVICES",
"messaging": "MESSAGING",
"data": "DATA LAYER"
},
"nodes": {
"client": { "label": "Client apps", "sublabel": "React SPA / Mobile" },
"gateway": { "label": "API Gateway", "sublabel": "Auth · Rate Limit · Routing" },
"projects": { "label": "Projects", "sublabel": "Microservice" },
"crm": { "label": "CRM", "sublabel": "Microservice" },
"docs": { "label": "Documents", "sublabel": "Microservice" },
"knowledge": { "label": "Knowledge", "sublabel": "Microservice" },
"analytics": { "label": "Analytics", "sublabel": "Microservice" },
"ai": { "label": "AI agents", "sublabel": "Microservice" },
"eventbus": { "label": "Event Bus", "sublabel": "Async messaging · Pub/Sub" },
"postgres": { "label": "PostgreSQL", "sublabel": "Primary data store" },
"graph": { "label": "Graph database", "sublabel": "Neo4j · Relationships" },
"vector": { "label": "Vector database", "sublabel": "Embeddings · Semantic search" }
},
"cards": {
"gateway": {
"title": "API Gateway",
"description": "Handles authentication, rate limiting, validation and smart routing to the microservices. Supports REST and GraphQL."
},
"eventbus": {
"title": "Event Bus",
"description": "Asynchronous pub/sub messaging keeps the modules loosely coupled. When a deal is closed in the CRM, events create the project automatically."
},
"data": {
"title": "Data layer",
"description": "PostgreSQL for structured data, Neo4j for the knowledge graph relationships and a vector database for semantic search and AI embeddings."
}
}
},
"health": {
"loading": "Loading system status...",
"updated": "Updated {{when}}",
"refresh": "Refresh",
"error": "Could not connect to the system. Check that the backend is running.",
"latency": "Latency",
"overall": "Overall status:",
"version": "v{{version}}",
"services": {
"api": "API Gateway",
"postgres": "PostgreSQL"
Comment on lines +58 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add the Redis service label to the translated catalogue

When this page uses the real /health response, backend/app/health.py always includes a third check named redis (lines 86–89), but the new English service map only covers api and postgres. useEnumLabel therefore falls back to the wire identifier and renders lowercase redis, even though this namespace is now marked fully translated; add the Redis label to both base and English catalogues and cover the actual three-service response in the English test.

Useful? React with 👍 / 👎.

},
"status": {
"healthy": "healthy",
"degraded": "degraded",
"unhealthy": "unhealthy"
}
},
"notifications": {
"title": "Email notifications",
"saving": "Saving...",
"intro": "Control which business events send email alerts. Changes are saved automatically.",
"loading": "Loading preferences...",
"master": "Email notifications",
"masterHint": "Master switch — turn it off to stop all email alerts",
"eventTypes": "Event types",
"summaryOn": "{{active}} of {{total}} event types active",
"summaryOff": "All email notifications are turned off",
"realtimeTitle": "Real-time notifications",
"realtimeBody": "In-app notifications are delivered over WebSocket and are always on while you are connected. They cover 13 event types, including workflow status, sales activity, inventory alerts, portal interactions and accounting updates. Use the bell icon in the header to see them.",
"events": {
"QuoteAccepted": {
"label": "Quotes accepted",
"desc": "When a customer accepts a quote"
},
"SalesOrderCreated": {
"label": "Sales orders created",
"desc": "When a new sales order is generated"
},
"WorkflowFailed": {
"label": "Failed workflows",
"desc": "When an automated workflow fails"
},
"InventoryLowThresholdDetected": {
"label": "Low stock",
"desc": "When a product reaches its minimum inventory threshold"
},
"InvoiceOverdue": {
"label": "Overdue invoices",
"desc": "When an invoice passes its due date without payment"
}
}
},
"general": {
"title": "Workspace settings",
"name": "Workspace name",
"url": "Workspace URL",
"preferences": "Preferences",
"prefs": {
"ai": {
"label": "Enable AI recommendations",
"desc": "Show AI suggestions across all modules"
},
"projects": {
"label": "Create projects automatically from deals",
"desc": "Automatically generate a project when a CRM deal is closed"
},
"graph": {
"label": "Automatic knowledge graph linking",
"desc": "Automatically detect and link entities in documents"
}
}
},
"team": {
"title": "Team members",
"invite": "Invite",
"role": {
"admin": "Admin",
"member": "Member"
}
}
}
Loading
Loading