Compare commits

...

57 Commits

Author SHA1 Message Date
Raphael Krut-Landau
cc6fe81522 Fix types and tests 2025-06-02 20:18:09 -04:00
Raphael Krut-Landau
ec84fbce2b Fix imports 2025-06-02 20:10:09 -04:00
Raphael Krut-Landau
e82cdb5b27 Use ngettext 2025-06-02 20:06:21 -04:00
Raphael Krut-Landau
bfe0fff3e2 try to fix flake 2025-06-02 14:29:25 -04:00
Raphael Krut-Landau
4c3e2e72b6 Fix type issues 2025-06-02 14:29:25 -04:00
Raphael Krut-Landau
14627933cd Mark test as flaky: click-behavior.cy.spec: 'should allow settin dashboard and saved question as custom destination for different columns' 2025-06-02 14:29:25 -04:00
Raphael Krut-Landau
c1cffca1c2 simplify unit tests involving content translations 2025-06-02 14:29:25 -04:00
Raphael Krut-Landau
272f8e74ce Rollback changes to ListField unit tests 2025-06-02 14:29:25 -04:00
Raphael Krut-Landau
4515483b79 Restore changes to TableInteractive 2025-06-02 14:29:25 -04:00
Raphael Krut-Landau
0ba0c81916 Fix e2e tests 2025-06-02 14:29:25 -04:00
Raphael Krut-Landau
e76759c27d Fix some types 2025-06-02 14:29:25 -04:00
Raphael Krut-Landau
67330da9f3 Fix types 2025-06-02 14:29:25 -04:00
Raphael Krut-Landau
537723449f Fix imports 2025-06-02 14:29:25 -04:00
Raphael Krut-Landau
7da5cf3ccb Fix whitespace 2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
bf1f15bc69 Increase version number 2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
9480ee33a6 Remove note 2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
e18432ab0f Improve var name 2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
eda2c39c34 Reduce diff 2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
6019b4679b Reduce diff 2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
49d0167e0f Clarify doc 2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
b6b8a463b3 Small tweaks 2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
a5f2e79643 Gate the content translation endpoint setter 2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
e81dff6f3b Reduce diff 2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
a999a268f4 Fix e2e and unit tests, and kondo issues. Limit scope of content translations to static embedding only. 2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
517e88d65a Shorten file name, remove unneeded check 2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
f5ad4f431f Remove console.logs 2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
6315580a4c lint 2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
0892c77823 lint 2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
72155a464e Update doc string 2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
4755851132 Support Jwt authentication for static embeds
Simplify CSV logic
2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
cabc2c5ba9 Add setup functions for content dictionary jwt endpoints 2025-06-02 14:29:24 -04:00
dan sutton
485b961595 remove old unauthed api route in favor of embedding route
previous commit added embedding route, this removes the old routes
2025-06-02 14:29:24 -04:00
dan sutton
281bc441f9 endpoint in embedding for translations
```
-- when no locale you get nothing and you will be happy
❯ http get 'localhost:3000/api/embed/dictionary/eyJhbGciOiJIUzI1NiJ9.eyJyZXNvdXJjZSI6eyJkYXNoYm9hcmQiOjExfSwicGFyYW1zIjp7fX0.7R1vvqgYnERw05m_22B5S-XLG-cZIZW3BOLY8XjgBf8'
HTTP/1.1 204 No Content

-- with french you get french
❯ http get 'localhost:3000/api/embed/dictionary/eyJhbGciOiJIUzI1NiJ9.eyJyZXNvdXJjZSI6eyJkYXNoYm9hcmQiOjExfSwicGFyYW1zIjp7fX0.7R1vvqgYnERw05m_22B5S-XLG-cZIZW3BOLY8XjgBf8?locale=fr'
HTTP/1.1 200 OK
...

[
    {
        "id": 393,
        "locale": "fr",
        "msgid": "Orders over time",
        "msgstr": "l'orders sur temps"
    },
    {
        "id": 394,
        "locale": "fr",
        "msgid": "Orders displayed over tim",
        "msgstr": "Orders qui nous displayons sur temps"
    },
    ...
    ]

```
2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
53108d739c wip 2025-06-02 14:29:24 -04:00
dpsutton
b9dc283cde Content translation cleanup (#58553)
* remove full response for simple map

```diff
-  {:status http-status-ok
-   :headers {"Content-Type" "application/json"}
-   :body (json/encode {:success true})})
+  {:success true})
```
this is equivalent and happens automatically

* just pass a file object

gonna change this as i don't love the api but it's better than the map
passing in the filename that wasn't needed. This is single argument

* pass rows in, not a file

* bit of cleanup in the dictionary creation namespace
2025-06-02 14:29:24 -04:00
Raphael Krut-Landau
2ddf13eeb8 Move some files around 2025-06-02 14:29:23 -04:00
Raphael Krut-Landau
f77abe486f Fix missing import 2025-06-02 14:29:23 -04:00
Raphael Krut-Landau
2584ee62fb Don't verify summaries 2025-06-02 14:29:23 -04:00
Raphael Krut-Landau
c5de0b8f0f Restore BE validation error logic 2025-06-02 14:29:23 -04:00
Raphael Krut-Landau
2b6567a503 This introduces logic that validates content translation dictionaries
when they are uploaded.

A valid dictionary must have:

* Three fields per row
* A valid locale in the first column
* One translation per string per locale (for example, you can only provide one translation of "Category" into German - you can't have two rows that begin "de,Category,")
* Cells containing strings no longer than 255 characters
2025-06-02 14:29:23 -04:00
Raphael Krut-Landau
6223bd7672 Remove unused import 2025-06-02 14:29:23 -04:00
Raphael Krut-Landau
c7ef7eda0f Fix import 2025-06-02 14:29:23 -04:00
Raphael Krut-Landau
cf9fbe64ca Fix lint stuff 2025-06-02 14:29:23 -04:00
Raphael Krut-Landau
a7274bb7e4 Fix test 2025-06-02 14:29:23 -04:00
Raphael Krut-Landau
54a70d90a5 Add e2e test for localizing dashboard tab names 2025-06-02 14:29:23 -04:00
Raphael Krut-Landau
550ca1dd45 Translate tab name if it is not editable 2025-06-02 14:29:23 -04:00
Raphael Krut-Landau
e56ea9ea28 Content translation: downloadable dictionary (#58607) 2025-06-02 14:29:23 -04:00
Raphael Krut-Landau
83b1ae1be0 Content translation: validate uploads (#57544) 2025-06-02 14:29:23 -04:00
Raphael Krut-Landau
21c3c36219 Remove failing but low priority sorting unit test - ideally we should mock out translateContentString 2025-06-02 14:29:23 -04:00
Raphael Krut-Landau
a308a4127a Fix unit tests 2025-06-02 14:29:22 -04:00
Raphael Krut-Landau
b1755c066b Restore CSS 2025-06-02 14:29:22 -04:00
Raphael Krut-Landau
cab5b75955 Localize headings in statically embedded dashboards 2025-06-02 14:29:22 -04:00
Raphael Krut-Landau
afe3f679ff Content translation: localize question names and descriptions in static embedding (#57564) 2025-06-02 14:29:22 -04:00
Raphael Krut-Landau
ba1225b753 Content translation: Localize categorical field values in static-embedded dashboards and questions (#58504) 2025-06-02 14:29:22 -04:00
Raphael Krut-Landau
d0611a7bd7 Fix unit tests 2025-06-02 14:29:22 -04:00
Raphael Krut-Landau
20b65de4a3 Localize content of dashboard text cards (#58497) 2025-06-02 14:29:22 -04:00
Raphael Krut-Landau
4945e2c65d Content translation project integration branch 2025-06-02 14:29:22 -04:00
104 changed files with 3369 additions and 353 deletions

View File

@@ -265,6 +265,10 @@
task
util}}
content-translation
{:api #{metabase.content-translation.api}
:uses #{api}}
content-verification
{:api #{metabase.content-verification.core
metabase.content-verification.init}
@@ -432,6 +436,7 @@
:uses #{analytics
api
config
content-translation
driver
eid-translation
events
@@ -1440,6 +1445,13 @@
task
util}}
enterprise/content-translation
{:api #{metabase-enterprise.content-translation.routes}
:uses #{api
content-translation
util
enterprise/api}}
enterprise/content-verification
{:api #{metabase-enterprise.content-verification.api.routes}
:uses #{api

View File

@@ -122,7 +122,7 @@ export function startNewNativeModel(config) {
/**
* Visit any valid query in an ad-hoc manner.
*
* @param {object} question
* @param {import("./api").QuestionDetails} question
* @param {{callback?: function, mode: (undefined|"notebook")}} config
*/
export function visitQuestionAdhoc(

View File

@@ -26,9 +26,9 @@ export function addCustomColumn() {
* Initiate a certain action such as filtering or summarizing taking the question's mode into account.
*
* @param {("Summarize"|"Filter"|"Join"|"CustomColumn")} actionType
* @param {(undefined|"notebook")} mode
* @param {"notebook"} [mode]
*/
function initiateAction(actionType, mode) {
export function initiateAction(actionType, mode) {
const icon = getIcon(actionType);
if (mode === "notebook") {

View File

@@ -2,6 +2,8 @@ import type {
DashCardId,
DashboardCard,
DashboardId,
DashboardTab,
VirtualDashboardCard,
WritebackActionId,
} from "metabase-types/api";
@@ -361,15 +363,6 @@ export function dashboardParametersPopover() {
return popover({ testId: "parameter-value-dropdown" } as any);
}
/**
* @param {Object} option
* @param {number=} option.id
* @param {number=} option.col
* @param {number=} option.row
* @param {number=} option.size_x
* @param {number=} option.size_y
* @param {string=} option.text
*/
export function getTextCardDetails({
id = getNextUnsavedDashboardCardId(),
col = 0,
@@ -377,7 +370,10 @@ export function getTextCardDetails({
size_x = 4,
size_y = 6,
text = "Text card",
} = {}) {
...cardDetails
}: Partial<VirtualDashboardCard> & {
text?: string;
} = {}): Partial<VirtualDashboardCard> {
return {
id,
card_id: null,
@@ -395,7 +391,20 @@ export function getTextCardDetails({
},
text,
},
} as const;
...cardDetails,
};
}
export function getDashboardTabDetails({
id,
name,
}: Pick<DashboardTab, "id" | "name" | "position">): Pick<
DashboardTab,
"id" | "name" | "position"
> {
return {
id,
name,
};
}
export function getHeadingCardDetails({
@@ -405,7 +414,10 @@ export function getHeadingCardDetails({
size_x = 24,
size_y = 1,
text = "Heading text details",
} = {}) {
...cardDetails
}: Partial<VirtualDashboardCard> & {
text?: string;
} = {}): Partial<VirtualDashboardCard> {
return {
id,
card_id: null,
@@ -424,6 +436,7 @@ export function getHeadingCardDetails({
"dashcard.background": false,
text,
},
...cardDetails,
};
}

View File

@@ -0,0 +1,61 @@
import type { NonEmpty } from "metabase/i18n/types";
import type { DictionaryArray } from "metabase-types/api";
export const germanFieldNames: NonEmpty<DictionaryArray> = [
{ locale: "de", msgid: "Title", msgstr: "Titel" },
{ locale: "de", msgid: "Vendor", msgstr: "Anbieter" },
{ locale: "de", msgid: "Rating", msgstr: "Bewertung" },
{ locale: "de", msgid: "Category", msgstr: "Kategorie" },
{ locale: "de", msgid: "Created At", msgstr: "Erstellt am" },
{ locale: "de", msgid: "Price", msgstr: "Preis" },
];
export const germanFieldValues: NonEmpty<DictionaryArray> = [
{ locale: "de", msgid: "Doohickey", msgstr: "Dingsbums" },
{ locale: "de", msgid: "Gadget", msgstr: "Gerät" },
{ locale: "de", msgid: "Gizmo", msgstr: "Apparat" },
{ locale: "de", msgid: "Widget", msgstr: "Steuerelement" },
{
locale: "de",
msgid: "Rustic Paper Wallet",
msgstr: "Rustikale Papierbörse",
},
];
export const portugueseFieldNames: DictionaryArray = [
{ locale: "pt-BR", msgid: "Title", msgstr: "Título" },
{ locale: "pt-BR", msgid: "Vendor", msgstr: "Fornecedor" },
{ locale: "pt-BR", msgid: "Rating", msgstr: "Avaliação" },
{ locale: "pt-BR", msgid: "Category", msgstr: "Categoria" },
{ locale: "pt-BR", msgid: "Created At", msgstr: "Criado em" },
{ locale: "pt-BR", msgid: "Price", msgstr: "Preço" },
];
export const nonAsciiFieldNames: DictionaryArray = [
{ locale: "ar", msgid: "Title", msgstr: "العنوان" },
{ locale: "he", msgid: "Title", msgstr: "כותרת" },
{ locale: "ja", msgid: "Title", msgstr: "タイトル" },
{ locale: "ko", msgid: "Title", msgstr: "제목" },
{ locale: "ru", msgid: "Title", msgstr: "Название" },
{ locale: "tr", msgid: "Title", msgstr: "Başlık" },
{ locale: "uk", msgid: "Title", msgstr: "Заголовок" },
{ locale: "vi", msgid: "Title", msgstr: "Tiêu đề" },
{ locale: "zh-TW", msgid: "Title", msgstr: "标题" },
{ locale: "en", msgid: "Butterfly", msgstr: "🦋" },
];
export const columnNamesWithTypeText = ["Title", "Category", "Vendor"];
export const invalidLocaleXX = structuredClone(germanFieldNames);
invalidLocaleXX[0].locale = "xx";
export const multipleInvalidLocales = structuredClone(germanFieldNames);
multipleInvalidLocales[0].locale = "ze";
multipleInvalidLocales[3].locale = "qe";
export const stringTranslatedTwice = structuredClone(germanFieldNames);
stringTranslatedTwice.push({
locale: "de",
msgid: "Title",
msgstr: "Überschrift",
});

View File

@@ -0,0 +1,268 @@
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import {
NORMAL_USER_ID,
ORDERS_DASHBOARD_ID,
} from "e2e/support/cypress_sample_instance_data";
import type {
DashboardDetails,
StructuredQuestionDetails,
} from "e2e/support/helpers";
import type { DictionaryArray } from "metabase/i18n/types";
import { germanFieldNames, germanFieldValues } from "./constants";
import {
interceptContentTranslationRoutes,
uploadTranslationDictionaryViaAPI,
} from "./helpers/e2e-content-translation-helpers";
const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE;
const { H } = cy;
const questionDetails: StructuredQuestionDetails = {
name: "Products question",
query: {
"source-table": PRODUCTS_ID,
limit: 30,
},
};
describe("scenarios > content translation > static embedding > dashboards", () => {
describe("filters and field values", () => {
describe("ee", () => {
before(() => {
interceptContentTranslationRoutes();
H.restore();
cy.signInAsAdmin();
H.setTokenFeatures("all");
uploadTranslationDictionaryViaAPI([
...germanFieldNames,
...germanFieldValues,
]);
H.snapshot("with-translations");
});
beforeEach(() => {
cy.intercept("GET", "/api/embed/dashboard/*").as("dashboard");
cy.intercept("GET", "/api/embed/dashboard/**/card/*").as("cardQuery");
H.restore("with-translations" as any);
});
[{ isMultiSelect: true }, { isMultiSelect: false }].forEach(
({ isMultiSelect }) => {
it(`can filter products table via localized, ${isMultiSelect ? "multiselect" : "single-select"} list widget and see localized values`, () => {
const productCategoryFilter = {
name: "Category",
slug: "product_category",
id: "11d79abe",
type: "string/=",
sectionId: "string",
isMultiSelect,
};
const dashboardDetails: DashboardDetails = {
parameters: [productCategoryFilter],
enable_embedding: true,
embedding_params: {
[productCategoryFilter.slug]: "enabled",
},
};
cy.signInAsAdmin();
H.createQuestionAndDashboard({
questionDetails,
dashboardDetails,
}).then(({ body: { id, card_id, dashboard_id } }) => {
cy.request("PUT", `/api/dashboard/${dashboard_id}`, {
dashcards: [
{
id,
card_id,
row: 0,
col: 0,
size_x: 24,
size_y: 20,
parameter_mappings: [
{
parameter_id: productCategoryFilter.id,
card_id,
target: [
"dimension",
["field", PRODUCTS.CATEGORY, null],
],
},
],
},
],
});
H.visitEmbeddedPage(
{
resource: { dashboard: dashboard_id as number },
params: {},
},
{
additionalHashOptions: {
locale: "de",
},
},
);
cy.wait("@cardQuery");
cy.log("Before filtering, multiple categories are shown");
cy.findByTestId("table-body").within(() => {
cy.findAllByText(/Dingsbums/).should(
"have.length.greaterThan",
2,
);
cy.findAllByText(/Apparat/).should(
"have.length.greaterThan",
2,
);
cy.findAllByText(/Gerät/).should("have.length.greaterThan", 2);
cy.findAllByText(/Steuerelement/).should(
"have.length.greaterThan",
2,
);
});
cy.log("Non-categorical field values are not translated");
cy.findByText("Rustic Paper Wallet").should("be.visible");
cy.findByText("Rustikale Papierbörse").should("not.exist");
cy.log("After filtering, only selected categories are shown");
H.filterWidget().findByText("Kategorie").click();
H.popover().within(() => {
cy.findByText(/Dingsbums/).click();
if (isMultiSelect) {
cy.findByText(/Apparat/).click();
}
cy.findByText(/Füge einen Filter hinzu/).click();
});
cy.findByTestId("table-body").within(() => {
cy.findAllByText(/Dingsbums/).should(
"have.length.greaterThan",
2,
);
if (isMultiSelect) {
cy.findAllByText(/Apparat/).should(
"have.length.greaterThan",
2,
);
} else {
cy.findByText(/Apparat/).should("not.exist");
}
cy.findByText(/Gerät/).should("not.exist");
cy.findByText(/Steuerelement/).should("not.exist");
});
});
});
},
);
});
});
describe("tab names and text cards", () => {
const translations: DictionaryArray = [
{ locale: "de", msgid: "Tab 1", msgstr: "Reiter 1" },
{ locale: "de", msgid: "Tab 2", msgstr: "Reiter 2" },
{ locale: "de", msgid: "Sample Heading", msgstr: "Beispielüberschrift" },
{ locale: "de", msgid: "Sample Text", msgstr: "Beispieltext" },
];
type VisitWithLocale = (options?: { locale?: string }) => void;
let visitEmbeddedDashboard = null as unknown as VisitWithLocale,
visitNormalDashboard = null as unknown as VisitWithLocale;
before(() => {
H.restore();
cy.signInAsAdmin();
H.setTokenFeatures("all");
uploadTranslationDictionaryViaAPI(translations);
cy.request("PUT", `/api/dashboard/${ORDERS_DASHBOARD_ID}`, {
enable_embedding: true,
tabs: [
H.getDashboardTabDetails({
name: "Tab 1",
id: 100,
}),
H.getDashboardTabDetails({
name: "Tab 2",
id: 101,
}),
],
dashcards: [
H.getHeadingCardDetails({
col: 0,
text: "Sample Heading",
dashboard_tab_id: 100,
}),
H.getTextCardDetails({
col: 0,
text: "Sample Text",
dashboard_tab_id: 100,
}),
],
} satisfies DashboardDetails);
H.snapshot("tab-names-and-text-cards");
});
beforeEach(() => {
cy.intercept("POST", "api/ee/content-translation/upload-dictionary").as(
"uploadDictionary",
);
H.restore("tab-names-and-text-cards" as any);
cy.signInAsAdmin();
visitEmbeddedDashboard = ({ locale = "de" } = {}) => {
H.visitEmbeddedPage(
{
resource: { dashboard: ORDERS_DASHBOARD_ID },
params: {},
},
{
additionalHashOptions: {
locale,
},
},
);
};
visitNormalDashboard = ({ locale = "de" } = {}) => {
cy.request("PUT", `/api/user/${NORMAL_USER_ID}`, { locale });
cy.signInAsNormalUser();
H.visitDashboard(ORDERS_DASHBOARD_ID);
};
});
it("should translate text in dashboard tab names", () => {
visitEmbeddedDashboard();
cy.findByRole("tab", { name: "Reiter 1" }).should("be.visible");
cy.findByRole("tab", { name: "Reiter 2" }).should("be.visible");
});
it("should translate content in heading cards", () => {
visitEmbeddedDashboard();
H.getDashboardCard(0)
.findByText(/Beispielüberschrift/)
.should("be.visible");
});
it("should translate content in text cards", () => {
visitEmbeddedDashboard();
H.getDashboardCard(1)
.findByText(/Beispieltext/)
.should("be.visible");
});
it("translations do not break normal dashboard", () => {
visitNormalDashboard();
cy.findByRole("tab", { name: "Tab 1" }).should("be.visible");
cy.findByRole("tab", { name: "Tab 2" }).should("be.visible");
cy.findAllByTestId("dashcard")
.contains(/Sample Heading/)
.should("be.visible");
cy.findAllByTestId("dashcard")
.contains(/Sample Text/)
.should("be.visible");
});
});
});

View File

@@ -0,0 +1,118 @@
import { parse } from "csv-parse/browser/esm/sync";
import { METABASE_SECRET_KEY } from "e2e/support/cypress_data";
import type { DictionaryArray, DictionaryResponse } from "metabase-types/api";
import { isDictionaryArray } from "metabase-types/api";
export const getCSVWithHeaderRow = (dictionary: DictionaryArray) => {
const header = ["Language", "String", "Translation"];
return [header, ...dictionary]
.map((row) => Object.values(row).join(","))
.join("\n");
};
export const uploadTranslationDictionary = (rows: DictionaryArray) => {
interceptContentTranslationRoutes();
cy.signInAsAdmin();
cy.visit("/admin/settings/localization");
cy.findByTestId("content-localization-setting").findByText(
/Upload translation dictionary/,
);
cy.get("#content-translation-dictionary-upload-input").selectFile(
{
contents: Cypress.Buffer.from(getCSVWithHeaderRow(rows)),
fileName: "file.csv",
mimeType: "text/csv",
},
{ force: true },
);
cy.wait("@uploadDictionary");
};
export const uploadTranslationDictionaryViaAPI = (rows: DictionaryArray) => {
cy.signInAsAdmin();
const csvString = getCSVWithHeaderRow(rows);
const fileBlob = new Blob([csvString], { type: "text/csv" });
const formData = new FormData();
formData.append("file", fileBlob, "dictionary.csv");
return cy
.request({
method: "POST",
url: "/api/ee/content-translation/upload-dictionary",
body: formData,
headers: {
"Content-Type": "multipart/form-data",
},
})
.then((response) => {
expect(response.status).to.equal(200);
});
};
export const assertOnlyTheseTranslationsAreStored = (
rows: DictionaryArray,
locale = "de",
) => {
cy.task("signJwt", {
payload: {
question: 1,
exp: Math.round(Date.now() / 1000) + 10 * 60,
},
secret: METABASE_SECRET_KEY,
}).then((jwtToken) => {
cy.log("A normal user should be able to get the translations via the API");
cy.signInAsNormalUser();
cy.request<DictionaryResponse>(
"GET",
`/api/embed/content-translation/dictionary/${jwtToken}?locale=${locale}`,
).then((interception) => {
const { data } = interception.body;
const msgstrs = data.map((row) => row.msgstr);
expect(msgstrs.toSorted()).to.deep.equal(
rows.map((row) => row.msgstr).toSorted(),
`The expected translations (length: ${rows.length}) match the actual translations (length: ${msgstrs.length})`,
);
});
});
};
export const interceptContentTranslationRoutes = () => {
cy.intercept("POST", "/api/ee/content-translation/upload-dictionary").as(
"uploadDictionary",
);
};
export const generateLargeCSV = ({
sizeInMebibytes,
}: {
sizeInMebibytes: number;
}) => {
const oneMebibyte = 2 ** 20;
const row = "de,data2,data3\n";
const charsPerRow = row.length;
const totalRows = Math.floor((sizeInMebibytes * oneMebibyte) / charsPerRow);
const header = "locale,string,translation\n";
const largeCSV = header + row.repeat(totalRows);
return largeCSV;
};
export const parseCSVFromString = (str: string): DictionaryArray => {
try {
const strings: unknown = parse(str, {
delimiter: [",", "\t", "\n"],
skip_empty_lines: true,
relax_column_count: true,
relax_quotes: true,
trim: true,
quote: '"',
escape: "\\",
}).flat();
if (isDictionaryArray(strings)) {
return strings;
}
throw new Error("Invalid dictionary");
} catch (err) {
return [];
}
};

View File

@@ -0,0 +1,101 @@
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { NORMAL_USER_ID } from "e2e/support/cypress_sample_instance_data";
import { germanFieldNames } from "./constants";
import {
interceptContentTranslationRoutes,
uploadTranslationDictionaryViaAPI,
} from "./helpers/e2e-content-translation-helpers";
const { PRODUCTS_ID } = SAMPLE_DATABASE;
const { H } = cy;
describe("scenarios > content translation > static embedding > questions", () => {
describe("ee", () => {
beforeEach(() => {
interceptContentTranslationRoutes();
});
let productsQuestionId = null as unknown as number;
before(() => {
H.restore();
cy.signInAsAdmin();
H.setTokenFeatures("all");
H.createQuestion(
{
name: "Products question",
query: {
"source-table": PRODUCTS_ID,
},
enable_embedding: true,
},
{ wrapId: true, idAlias: "productsQuestionId" },
);
uploadTranslationDictionaryViaAPI(germanFieldNames);
H.snapshot("snapshot-for-questions");
cy.get<number>("@productsQuestionId").then((id) => {
productsQuestionId = id;
});
});
let visitEmbeddedQuestion = null as unknown as ({
locale,
}: {
locale: string;
}) => void;
beforeEach(() => {
H.restore("snapshot-for-questions" as any);
visitEmbeddedQuestion = ({ locale }) => {
H.visitEmbeddedPage(
{
resource: { question: productsQuestionId },
params: {},
},
{
additionalHashOptions: {
locale,
},
},
);
};
});
it("when locale is English, column names are NOT localized in column headers", () => {
visitEmbeddedQuestion({ locale: "en" });
cy.findByTestId("table-header").within(() => {
germanFieldNames.forEach((row) => {
cy.findByText(row.msgid).should("be.visible");
cy.findByText(row.msgstr).should("not.exist");
});
});
});
it("when locale is German, column names ARE localized in column headers", () => {
visitEmbeddedQuestion({ locale: "de" });
cy.findByTestId("table-header").within(() => {
germanFieldNames.forEach((row) => {
cy.findByText(row.msgid).should("not.exist");
cy.findByText(row.msgstr).should("be.visible");
});
});
});
it("translations do not break questions in the normal app", () => {
cy.signInAsNormalUser();
cy.request("PUT", `/api/user/${NORMAL_USER_ID}`, { locale: "de" });
H.visitQuestion(productsQuestionId);
cy.findByTestId("table-header").within(() => {
germanFieldNames.forEach((row) => {
cy.log("No fields names are translated");
cy.findByText(row.msgstr).should("not.exist");
cy.findByText(row.msgid).should("be.visible");
});
});
});
});
});

View File

@@ -0,0 +1,200 @@
import path from "path";
import {
germanFieldNames,
invalidLocaleXX,
multipleInvalidLocales,
nonAsciiFieldNames,
portugueseFieldNames,
stringTranslatedTwice,
} from "./constants";
import {
assertOnlyTheseTranslationsAreStored,
generateLargeCSV,
uploadTranslationDictionary,
} from "./helpers/e2e-content-translation-helpers";
const { H } = cy;
describe("scenarios > admin > localization > content translation", () => {
describe("oss", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
});
it("admin settings configuration form is not present", () => {
cy.visit("/admin/settings/localization");
cy.findByTestId("content-translation-configuration").should("not.exist");
});
});
describe("ee", () => {
before(() => {
H.restore();
cy.signInAsAdmin();
H.setTokenFeatures("all");
H.snapshot("snapshot-for-upload-and-download");
});
beforeEach(() => {
cy.intercept(
"POST",
"api/ee/content-translation/upload-dictionary",
cy.spy().as("uploadDictionarySpy"),
).as("uploadDictionary");
H.restore("snapshot-for-upload-and-download" as any);
cy.signInAsAdmin();
});
describe("The translation download button", () => {
it("downloads the uploaded translations", () => {
uploadTranslationDictionary(germanFieldNames);
cy.visit("/admin/settings/localization");
cy.findByTestId("content-translation-configuration")
.button(/Download translation dictionary/i)
.click();
const downloadsFolder = Cypress.config("downloadsFolder");
cy.readFile(
path.join(downloadsFolder, "metabase-content-translations.csv"),
).then((fileContents) => {
expect(fileContents).to.include("de,Rating,Bewertung");
});
});
});
describe("The translation upload form", () => {
it("accepts a CSV upload with ASCII characters", () => {
uploadTranslationDictionary(germanFieldNames);
cy.findByTestId("content-localization-setting").findByText(
"Dictionary uploaded",
);
assertOnlyTheseTranslationsAreStored(germanFieldNames);
});
it("accepts a CSV upload with non-ASCII characters", () => {
uploadTranslationDictionary(nonAsciiFieldNames);
cy.findByTestId("content-localization-setting").findByText(
"Dictionary uploaded",
);
nonAsciiFieldNames.forEach(({ locale, msgid, msgstr }) => {
assertOnlyTheseTranslationsAreStored(
[{ locale, msgid, msgstr }],
locale,
);
});
});
it("accepts a CSV upload with a hyphenated locale", () => {
uploadTranslationDictionary(portugueseFieldNames);
cy.findByTestId("content-localization-setting").findByText(
"Dictionary uploaded",
);
cy.signInAsNormalUser();
assertOnlyTheseTranslationsAreStored(portugueseFieldNames, "pt-BR");
});
it("does not store rows with translations made of only whitespace and/or semicolons", () => {
const blankTranslation = { locale: "de", msgid: "Cat", msgstr: "" };
const translationWithJustSpaces = {
locale: "de",
msgid: "Spaces",
msgstr: " ",
};
const translationWithJustTabs = {
locale: "de",
msgid: "Tabs",
msgstr: "\t\t",
};
const translationWithJustSemicolons = {
locale: "de",
msgid: "Semicolons",
msgstr: ";;",
};
const translationsWithBlanks = [
...germanFieldNames,
blankTranslation,
translationWithJustSpaces,
translationWithJustTabs,
translationWithJustSemicolons,
];
uploadTranslationDictionary(translationsWithBlanks);
assertOnlyTheseTranslationsAreStored(germanFieldNames);
});
it("rejects a CSV upload that provides two translations for the same string", () => {
uploadTranslationDictionary(stringTranslatedTwice);
cy.findAllByRole("alert")
.contains(/couldn.*t upload the file/)
.should("be.visible");
cy.findAllByRole("alert")
.contains(
new RegExp(
`Row ${stringTranslatedTwice.length + 1}.*earlier in the file`,
),
)
.should("be.visible");
});
it("rejects a CSV upload with invalid locale in one row", () => {
uploadTranslationDictionary(invalidLocaleXX);
cy.findAllByRole("alert")
.contains(/couldn.*t upload the file/)
.should("be.visible");
cy.findAllByRole("alert")
.contains(/Row 2: Invalid locale: xx/)
.should("be.visible");
});
it("erases previously stored translations when a new CSV is uploaded", () => {
uploadTranslationDictionary(germanFieldNames);
assertOnlyTheseTranslationsAreStored(germanFieldNames);
const oneArabicFieldName = [nonAsciiFieldNames[0]];
uploadTranslationDictionary(oneArabicFieldName);
assertOnlyTheseTranslationsAreStored(oneArabicFieldName, "ar");
});
it("does not erase previously stored translations when an upload fails", () => {
uploadTranslationDictionary(germanFieldNames);
assertOnlyTheseTranslationsAreStored(germanFieldNames);
uploadTranslationDictionary(invalidLocaleXX);
assertOnlyTheseTranslationsAreStored(germanFieldNames);
});
it("rejects a CSV upload with invalid locales in multiple rows", () => {
uploadTranslationDictionary(multipleInvalidLocales);
cy.findAllByRole("alert")
.contains(/couldn.*t upload the file/)
.should("be.visible");
cy.log("The first error is in row 2 (the first row is the header)");
cy.findAllByRole("alert")
.contains(/Row 2: Invalid locale: ze/)
.should("be.visible");
cy.findAllByRole("alert")
.contains(/Row 5: Invalid locale: qe/)
.should("be.visible");
});
it("rejects, in the frontend, a CSV upload that is too big", () => {
cy.visit("/admin/settings/localization");
cy.get("#content-translation-dictionary-upload-input").selectFile(
{
contents: Cypress.Buffer.from(
generateLargeCSV({ sizeInMebibytes: 1.6 }),
),
fileName: "file.csv",
mimeType: "text/csv",
},
{ force: true }, // We need this because the input has display: none
);
cy.findAllByRole("alert")
.contains(/The file is larger than 1.5 MB/)
.should("be.visible");
cy.log(
"The frontend should prevent the upload attempt; the endpoint should not be called",
);
cy.get("@uploadDictionarySpy").should("not.have.been.called");
});
});
});
});

View File

@@ -1294,136 +1294,142 @@ describe("scenarios > dashboard > dashboard cards > click behavior", () => {
.should("have.text", "Open the drill-through menu");
});
it("should allow setting dashboard and saved question as custom destination for different columns", () => {
H.createQuestion(TARGET_QUESTION);
H.createDashboard(
{
...TARGET_DASHBOARD,
parameters: [DASHBOARD_FILTER_TEXT, DASHBOARD_FILTER_TIME],
dashcards: [
createMockDashboardCard({
card_id: ORDERS_QUESTION_ID,
parameter_mappings: [
createTextFilterMapping({ card_id: ORDERS_QUESTION_ID }),
createTimeFilterMapping({ card_id: ORDERS_QUESTION_ID }),
],
}),
],
},
{
wrapId: true,
idAlias: "targetDashboardId",
},
);
H.createQuestionAndDashboard({ questionDetails }).then(
({ body: card }) => {
H.visitDashboard(card.dashboard_id);
},
);
H.editDashboard();
H.getDashboardCard().realHover().icon("click").click();
(function addCustomDashboardDestination() {
cy.log("custom destination (dashboard) behavior for 'Count' column");
getCountToDashboardMapping().should("not.exist");
cy.get("aside").findByText(COUNT_COLUMN_NAME).click();
addDashboardDestination();
cy.get("aside")
.findByText("Select a dashboard tab")
.should("not.exist");
cy.get("aside").findByText("No available targets").should("not.exist");
addTextParameter();
addTimeParameter();
customizeLinkText(`Count: {{${COUNT_COLUMN_ID}}}`);
cy.icon("chevronleft").click();
getCountToDashboardMapping().should("exist");
H.getDashboardCard()
.button()
.should("have.text", "1 column has custom behavior");
})();
(function addCustomQuestionDestination() {
cy.log(
"custom destination (question) behavior for 'Created at' column",
);
getCreatedAtToQuestionMapping().should("not.exist");
cy.get("aside").findByText(CREATED_AT_COLUMN_NAME).click();
addSavedQuestionDestination();
addSavedQuestionCreatedAtParameter();
addSavedQuestionQuantityParameter();
customizeLinkText(`Created at: {{${CREATED_AT_COLUMN_ID}}}`);
cy.icon("chevronleft").click();
getCreatedAtToQuestionMapping().should("exist");
H.getDashboardCard()
.button()
.should("have.text", "2 columns have custom behavior");
})();
cy.get("aside").button("Done").click();
H.saveDashboard({ waitMs: 250 });
(function testDashboardDestinationClick() {
cy.log("it handles 'Count' column click");
getTableCell(COLUMN_INDEX.COUNT)
.should("have.text", `Count: ${POINT_COUNT}`)
.click();
cy.findAllByTestId("field-set")
.should("have.length", 2)
.should("contain.text", POINT_COUNT)
.should("contain.text", POINT_CREATED_AT_FORMATTED);
cy.get("@targetDashboardId").then((targetDashboardId) => {
cy.location().should(({ pathname, search }) => {
expect(pathname).to.equal(`/dashboard/${targetDashboardId}`);
expect(search).to.equal(
`?${DASHBOARD_FILTER_TEXT.slug}=${POINT_COUNT}&${DASHBOARD_FILTER_TIME.slug}=${POINT_CREATED_AT}`,
);
});
});
})();
cy.go("back");
(function testQuestionDestinationClick() {
cy.log("it handles 'Created at' column click");
getTableCell(COLUMN_INDEX.CREATED_AT)
.should("have.text", `Created at: ${POINT_CREATED_AT_FORMATTED}`)
.click();
cy.wait("@dataset");
cy.findByTestId("qb-filters-panel")
.should("contain.text", "Created At is Jul 1–31, 2022")
.should("contain.text", "Quantity is equal to 64");
cy.location("pathname").should("equal", "/question");
cy.findByTestId("app-bar").should(
"contain.text",
`Started from ${TARGET_QUESTION.name}`,
);
verifyVizTypeIsLine();
H.openNotebook();
H.verifyNotebookQuery("Orders", [
it(
"should allow setting dashboard and saved question as custom destination for different columns",
{ tags: "@flaky" },
() => {
H.createQuestion(TARGET_QUESTION);
H.createDashboard(
{
filters: [
"Created At is Jul 1–31, 2022",
"Quantity is equal to 64",
...TARGET_DASHBOARD,
parameters: [DASHBOARD_FILTER_TEXT, DASHBOARD_FILTER_TIME],
dashcards: [
createMockDashboardCard({
card_id: ORDERS_QUESTION_ID,
parameter_mappings: [
createTextFilterMapping({ card_id: ORDERS_QUESTION_ID }),
createTimeFilterMapping({ card_id: ORDERS_QUESTION_ID }),
],
}),
],
aggregations: ["Count"],
breakouts: ["Created At: Month"],
limit: 5,
},
]);
})();
});
{
wrapId: true,
idAlias: "targetDashboardId",
},
);
H.createQuestionAndDashboard({ questionDetails }).then(
({ body: card }) => {
H.visitDashboard(card.dashboard_id);
},
);
H.editDashboard();
H.getDashboardCard().realHover().icon("click").click();
(function addCustomDashboardDestination() {
cy.log("custom destination (dashboard) behavior for 'Count' column");
getCountToDashboardMapping().should("not.exist");
cy.get("aside").findByText(COUNT_COLUMN_NAME).click();
addDashboardDestination();
cy.get("aside")
.findByText("Select a dashboard tab")
.should("not.exist");
cy.get("aside")
.findByText("No available targets")
.should("not.exist");
addTextParameter();
addTimeParameter();
customizeLinkText(`Count: {{${COUNT_COLUMN_ID}}}`);
cy.icon("chevronleft").click();
getCountToDashboardMapping().should("exist");
H.getDashboardCard()
.button()
.should("have.text", "1 column has custom behavior");
})();
(function addCustomQuestionDestination() {
cy.log(
"custom destination (question) behavior for 'Created at' column",
);
getCreatedAtToQuestionMapping().should("not.exist");
cy.get("aside").findByText(CREATED_AT_COLUMN_NAME).click();
addSavedQuestionDestination();
addSavedQuestionCreatedAtParameter();
addSavedQuestionQuantityParameter();
customizeLinkText(`Created at: {{${CREATED_AT_COLUMN_ID}}}`);
cy.icon("chevronleft").click();
getCreatedAtToQuestionMapping().should("exist");
H.getDashboardCard()
.button()
.should("have.text", "2 columns have custom behavior");
})();
cy.get("aside").button("Done").click();
H.saveDashboard({ waitMs: 250 });
(function testDashboardDestinationClick() {
cy.log("it handles 'Count' column click");
getTableCell(COLUMN_INDEX.COUNT)
.should("have.text", `Count: ${POINT_COUNT}`)
.click();
cy.findAllByTestId("field-set")
.should("have.length", 2)
.should("contain.text", POINT_COUNT)
.should("contain.text", POINT_CREATED_AT_FORMATTED);
cy.get("@targetDashboardId").then((targetDashboardId) => {
cy.location().should(({ pathname, search }) => {
expect(pathname).to.equal(`/dashboard/${targetDashboardId}`);
expect(search).to.equal(
`?${DASHBOARD_FILTER_TEXT.slug}=${POINT_COUNT}&${DASHBOARD_FILTER_TIME.slug}=${POINT_CREATED_AT}`,
);
});
});
})();
cy.go("back");
(function testQuestionDestinationClick() {
cy.log("it handles 'Created at' column click");
getTableCell(COLUMN_INDEX.CREATED_AT)
.should("have.text", `Created at: ${POINT_CREATED_AT_FORMATTED}`)
.click();
cy.wait("@dataset");
cy.findByTestId("qb-filters-panel")
.should("contain.text", "Created At is Jul 1–31, 2022")
.should("contain.text", "Quantity is equal to 64");
cy.location("pathname").should("equal", "/question");
cy.findByTestId("app-bar").should(
"contain.text",
`Started from ${TARGET_QUESTION.name}`,
);
verifyVizTypeIsLine();
H.openNotebook();
H.verifyNotebookQuery("Orders", [
{
filters: [
"Created At is Jul 1–31, 2022",
"Quantity is equal to 64",
],
aggregations: ["Count"],
breakouts: ["Created At: Month"],
limit: 5,
},
]);
})();
},
);
it("should allow setting dashboard tab with parameter for a column", () => {
H.createQuestion(TARGET_QUESTION);

View File

@@ -71,6 +71,9 @@ describe("scenarios > dashboard > chained filter", () => {
// now test that it worked!
// Select Alaska as a state. We should see Anchorage as a option but not Anacoco
H.filterWidget().contains("Location").click();
cy.findByPlaceholderText(/search the list/i).should("be.visible");
H.popover().within(() => {
cy.findByText("AK").click();
cy.findByText("Add filter").click();

View File

@@ -45,6 +45,11 @@ describe("scenarios > dashboard > filters > SQL > text/category", () => {
([filter, { value, representativeResult }], index) => {
// eslint-disable-next-line no-unsafe-element-filtering
H.filterWidget().eq(index).click();
if (["Is", "Is not"].includes(filter)) {
cy.log("Wait for the correct popover to appear");
cy.findByPlaceholderText(/search the list/i).should("be.visible");
}
applyFilterByType(filter, value);
cy.log(`Make sure ${filter} filter returns correct result`);

View File

@@ -13,6 +13,7 @@
[metabase-enterprise.api.routes.common :as ee.api.common]
[metabase-enterprise.audit-app.api.routes]
[metabase-enterprise.billing.api.routes]
[metabase-enterprise.content-translation.routes]
[metabase-enterprise.content-verification.api.routes]
[metabase-enterprise.data-editing.api]
[metabase-enterprise.database-routing.api]
@@ -37,10 +38,11 @@
{:advanced-permissions (deferred-tru "Advanced Permissions")
:attached-dwh (deferred-tru "Attached DWH")
:audit-app (deferred-tru "Audit app")
:collection-cleanup (deferred-tru "Collection Cleanup")
:ai-sql-fixer (deferred-tru "AI SQL Fixer")
:ai-sql-generation (deferred-tru "AI SQL Generation")
:ai-entity-analysis (deferred-tru "AI Entity Analysis")
:collection-cleanup (deferred-tru "Collection Cleanup")
:content-translation (deferred-tru "Content translation")
:etl-connections (deferred-tru "ETL Connections")
:table-data-editing (deferred-tru "Editing Table Data")
:llm-autodescription (deferred-tru "LLM Auto-description")
@@ -78,6 +80,7 @@
"/autodescribe" (premium-handler 'metabase-enterprise.llm.api :llm-autodescription)
"/billing" metabase-enterprise.billing.api.routes/routes
"/data-editing" (premium-handler metabase-enterprise.data-editing.api/routes :table-data-editing)
"/content-translation" (premium-handler metabase-enterprise.content-translation.routes/routes :content-translation)
"/gsheets" (-> gsheets.api/routes ;; gsheets requires both features.
(premium-handler :attached-dwh)
(premium-handler :etl-connections))

View File

@@ -0,0 +1,93 @@
(ns metabase-enterprise.content-translation.dictionary
"Implementation of dictionary upload and retrieval logic for content translations"
(:require
[clojure.string :as str]
[metabase.util.i18n :as i18n :refer [tru]]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(def ^:private http-status-unprocessable 422)
(defn- translation-key
"The identity of a translation. It's locale and source string so we can identify if the same translation is present
multiple times."
[t]
(select-keys t [:locale :msgid]))
(defn- adjust-index
"Adjust index: increment once for the header row that was chopped off and again to go to 1-based indexing for human
consumption."
[i]
(+ i 2))
(defn- collect-locale-error
"Returns an error message if a row does not have a valid locale."
[_state index {:keys [locale]}]
(when (not (i18n/available-locale? locale))
(tru "Row {0}: Invalid locale: {1}" (adjust-index index) locale)))
(defn- collect-duplication-error
"Returns an error message if this translation key has already been seen in the file. A translation key is a string
like 'de,Category'"
[{:keys [seen] :as _state} index {:keys [msgid locale] :as translation}]
(when (-> translation translation-key seen)
(tru "Row {0}: The string \"{1}\" is translated into locale \"{2}\" earlier in the file"
(adjust-index index) msgid locale)))
(defn- is-msgstr-usable
"Check if the translation string is usable. It should not be blank or contain only commas, whitespace, or semicolons."
[msgstr]
(not
(or
(str/blank? msgstr)
(re-matches #"^[,;\s]*$" msgstr))))
(defn- row-errors
[state index translation]
(keep (fn [f] (f state index translation))
[collect-duplication-error collect-locale-error]))
(defn- wrong-row-shape
[index]
(tru "Row {0}: Invalid format. Expected exactly 3 columns (Language, String, Translation)"
(adjust-index index)))
(defn- process-rows
"Format, trim, validate rows. Takes the vectors from a csv and returns a map with the shape
{:translations [{:locale :msgid :msgstr}]
:errors [string]}.
The :seen set is returned but not meant for consumption."
[rows]
(reduce (fn [state [index row]]
(let [[locale msgid msgstr & extra] row
translation {:locale locale
:msgid (str/trim msgid)
:msgstr (str/trim msgstr)}
errors (cond-> (row-errors state index translation)
(seq extra) (conj (wrong-row-shape index)))]
(cond-> (-> state
(update :seen conj (dissoc translation :msgstr))
(update :translations conj translation))
(seq errors) (update :errors into errors))))
{:seen #{}
:errors []
:translations []}
(map-indexed vector rows)))
(defn import-translations!
"Insert or update rows in the content_translation table."
[rows]
(let [{:keys [translations errors]} (process-rows rows)]
(when (seq errors)
(throw (ex-info (tru "The file could not be uploaded due to the following error(s):")
{:status-code http-status-unprocessable
:errors errors})))
;; remove bad msgstrs after error generator for line number reporting reasons
(let [usable-rows (filter (comp is-msgstr-usable :msgstr) translations)]
(t2/with-transaction [_tx]
;; Replace all existing entries
(t2/delete! :model/ContentTranslation)
;; Insert all usable rows at once
(when-not (empty? usable-rows)
(t2/insert! :model/ContentTranslation usable-rows))))))

View File

@@ -0,0 +1,64 @@
(ns metabase-enterprise.content-translation.routes
"Endpoints relating to the translation of user-generated content"
(:require
[clojure.data.csv :as csv]
[clojure.java.io :as io]
[metabase-enterprise.content-translation.dictionary :as dictionary]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.content-translation.api :as ct]
[metabase.util.i18n :refer [tru]]
[metabase.util.malli.schema :as ms]))
(set! *warn-on-reflection* true)
(def ^:private http-status-content-too-large 413)
;; The maximum size of a content translation dictionary is 1.5MiB
;; This should equal the maxContentDictionarySizeInMiB variable in the frontend
(def ^:private max-content-translation-dictionary-size (* 1.5 1024 1024))
(api.macros/defendpoint :post
"/upload-dictionary"
"Upload a CSV of content translations"
{:multipart true}
[_route_params
_query-params
_body
{:keys [multipart-params], :as _request} :- [:map
[:multipart-params
[:map
["file"
[:map
[:filename :string]
[:tempfile (ms/InstanceOfClass java.io.File)]]]]]]]
(let [file (get-in multipart-params ["file" :tempfile])]
(when (> (get-in multipart-params ["file" :size]) max-content-translation-dictionary-size)
(throw (ex-info (tru "The dictionary should be less than {0}MB." (/ max-content-translation-dictionary-size (* 1024 1024)))
{:status-code http-status-content-too-large})))
(when-not (instance? java.io.File file)
(throw (ex-info (tru "No file provided") {:status-code 400})))
(with-open [rdr (io/reader file)]
(let [[_header & rows] (csv/read-csv rdr)]
(dictionary/import-translations! rows)))
{:success true}))
(api.macros/defendpoint :get "/csv"
"Provides content translation dictionary in CSV"
[_route-params
_query-params
_body]
(let [translations (ct/get-translations)
csv-data (cons ["Language" "String" "Translation"]
(map (fn [{:keys [locale msgid msgstr]}]
[locale msgid msgstr])
translations))]
{:status 200
:headers {"Content-Type" "text/csv; charset=utf-8"
"Content-Disposition" "attachment; filename=\"metabase-content-translations.csv\""}
:body (with-out-str
(csv/write-csv *out* csv-data))}))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/content-translation` routes."
(api.macros/ns-handler *ns* +auth))

View File

@@ -51,6 +51,7 @@
"CollectionBookmark"
"CollectionPermissionGraphRevision"
"ConnectionImpersonation"
"ContentTranslation"
"DatabaseRouter"
"DashboardBookmark"
"DataPermissions"

View File

@@ -13,6 +13,7 @@
:cache-granular-controls
:cache-preemptive
:config-text-file
:content-translation
:content-verification
:dashboard-subscription-filters
:disable-password-login
@@ -50,6 +51,7 @@
:cache_granular_controls true
:cache_preemptive true
:config_text_file true
:content_translation true
:content_verification true
:dashboard_subscription_filters true
:disable_password_login true

View File

@@ -40,6 +40,7 @@
:model/CardBookmark
:model/ChannelTemplate
:model/CollectionBookmark
:model/ContentTranslation
:model/DashboardBookmark
:model/DataPermissions
:model/DatabaseRouter

View File

@@ -0,0 +1,52 @@
import { invalidateTags, listTag } from "metabase/api/tags";
import { PLUGIN_CONTENT_TRANSLATION } from "metabase/plugins";
import type { DictionaryResponse } from "metabase-types/api/content-translation";
import { EnterpriseApi } from "./api";
type ListContentTranslationsRequest = {
locale?: string;
};
export const contentTranslationApi = EnterpriseApi.injectEndpoints({
endpoints: (builder) => {
return {
listContentTranslations: builder.query<
DictionaryResponse,
ListContentTranslationsRequest | void
>({
query: (params) => ({
method: "GET",
// The URL is currently only set in static embedding
url: PLUGIN_CONTENT_TRANSLATION.contentTranslationDictionaryUrl,
params,
}),
providesTags: () => [listTag("content-translation")],
}),
uploadContentTranslationDictionary: builder.mutation<
{ success: boolean },
{ file: File }
>({
query: ({ file }) => {
const formData = new FormData();
formData.append("file", file);
return {
method: "POST",
url: "/api/ee/content-translation/upload-dictionary",
body: { formData },
formData: true,
fetch: true,
};
},
invalidatesTags: (_, error) =>
invalidateTags(error, [listTag("content-translation")]),
}),
};
},
});
export const {
useListContentTranslationsQuery,
useUploadContentTranslationDictionaryMutation,
} = contentTranslationApi;

View File

@@ -5,6 +5,7 @@ export * from "./audit-info";
export * from "./api";
export * from "./billing-info";
export * from "./metabot";
export * from "./content-translation";
export * from "./gdrive";
export * from "./db-routing";
export * from "./query-validation";

View File

@@ -0,0 +1,250 @@
import {
type ChangeEvent,
type Dispatch,
type ReactNode,
type SetStateAction,
useCallback,
useRef,
useState,
} from "react";
import { c, msgid, ngettext, t } from "ttag";
import { SettingHeader } from "metabase/admin/settings/components/SettingHeader";
import { useDocsUrl } from "metabase/common/hooks";
import { UploadInput } from "metabase/components/upload";
import ExternalLink from "metabase/core/components/ExternalLink";
import Markdown from "metabase/core/components/Markdown";
import {
Form,
FormProvider,
FormSubmitButton,
useFormContext,
} from "metabase/forms";
import { openSaveDialog } from "metabase/lib/dom";
import { Button, Group, Icon, List, Loader, Stack, Text } from "metabase/ui";
import { useUploadContentTranslationDictionaryMutation } from "metabase-enterprise/api";
/** Maximum file size for uploaded content-translation dictionaries, expressed
* in mebibytes. */
const maxContentDictionarySizeInMiB = 1.5;
/** The maximum file size is 1.5 mebibytes (which equals 1.57 metabytes).
* For simplicity, though, let's express this as 1.5 megabytes, which is
* approximately right. */
const approxMaxContentDictionarySizeInMB = 1.5;
/** This should equal the max-content-translation-dictionary-size variable in the backend */
const maxContentDictionarySizeInBytes =
maxContentDictionarySizeInMiB * 1024 * 1024;
export const ContentTranslationConfiguration = () => {
// eslint-disable-next-line no-unconditional-metabase-links-render -- This is used in admin settings
const availableLocalesDocsUrl = useDocsUrl(
"configuring-metabase/localization",
{ anchor: "supported-languages" },
).url;
const [downloadErrorMessage, setDownloadErrorMessage] = useState<
string | null
>();
const triggerDownload = async () => {
const response = await fetch("/api/ee/content-translation/csv", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
setDownloadErrorMessage(t`Couldn't download this file`);
}
const blob = await response.blob();
const filename = "metabase-content-translations.csv";
openSaveDialog(filename, blob);
};
const [errorMessages, setErrorMessages] = useState<string[]>([]);
return (
<Stack
gap="sm"
maw="38rem"
aria-labelledby="content-translation-header"
data-testid="content-translation-configuration"
>
<SettingHeader
id="content-translation-header"
title={t`Content translation`}
description={
<Markdown
components={{
strong: ({ children }: { children: ReactNode }) => (
<ExternalLink href={availableLocalesDocsUrl}>
{children}
</ExternalLink>
),
}}
>
{t`You can upload a translation dictionary. We'll use this to translate user-provided strings (like question names) into the viewer's language. (Built-in strings won't be affected.) Your translation dictionary should be a CSV with three columns: Locale code, String, Translation. Supported locale codes are **listed here**. Uploading a new dictionary will replace the existing translations. Don't translate sensitive data, since the dictionary will be accessible to all users as well as viewers of public links.`}
</Markdown>
}
/>
<Group>
<Button
onClick={triggerDownload}
leftSection={<Icon name="download" />}
maw="20rem"
>
{t`Download translation dictionary`}
</Button>
<FormProvider
// We're only using Formik to make the appearance of the submit button
// depend on the form's status. We're not using Formik's other features
// here.
initialValues={{}}
onSubmit={() => {}}
>
<UploadForm setErrorMessages={setErrorMessages} />
</FormProvider>
</Group>
{downloadErrorMessage && (
<Text role="alert" c="danger">
{downloadErrorMessage}
</Text>
)}
{!!errorMessages.length && (
<Stack gap="xs">
<Text role="alert" c="error">
{ngettext(
msgid`We couldn't upload the file due to this error:`,
`We couldn't upload the file due to these errors:`,
errorMessages.length,
)}
</Text>
<List withPadding>
{errorMessages.map((errorMessage) => (
<List.Item key={errorMessage} role="alert" c="danger">
{errorMessage}
</List.Item>
))}
</List>
</Stack>
)}
</Stack>
);
};
const UploadForm = ({
setErrorMessages,
}: {
setErrorMessages: Dispatch<SetStateAction<string[]>>;
}) => {
const [uploadContentTranslationDictionary] =
useUploadContentTranslationDictionaryMutation();
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.files?.length) {
const file = event.target.files[0];
if (file.size > maxContentDictionarySizeInBytes) {
setErrorMessages([
c("{0} is a number")
.t`The file is larger than ${approxMaxContentDictionarySizeInMB} MB`,
]);
setStatus("rejected");
return;
}
await uploadFile(file);
resetInput();
}
};
const resetInput = () => {
const input = inputRef.current;
if (input) {
input.value = "";
}
};
const { status, setStatus } = useFormContext();
const uploadFile = useCallback(
async (file: File) => {
if (!file) {
console.error("No file selected");
return;
}
setErrorMessages([]);
setStatus("pending");
await uploadContentTranslationDictionary({ file })
.unwrap()
.then(() => {
setStatus("fulfilled");
})
.catch((e) => {
setErrorMessages(e.data.errors ?? [t`Unknown error encountered`]);
setStatus("rejected");
});
},
[uploadContentTranslationDictionary, setErrorMessages, setStatus],
);
const triggerUpload = () => {
const input = inputRef.current;
if (!input) {
return;
}
input.style.display = "block";
input.click();
input.style.display = "none";
};
const inputRef = useRef<HTMLInputElement>(null);
return (
<Form data-testid="content-localization-setting">
<Stack gap="md">
<FormSubmitButton
disabled={status === "pending"}
label={
<Group gap="sm">
<Icon name="upload" opacity=".8" />
<Text c="inherit">{t`Upload translation dictionary`}</Text>
</Group>
}
successLabel={
<Group gap="sm" role="alert">
<Icon name="check" opacity=".8" />
<Text c="inherit">{t`Dictionary uploaded`}</Text>
</Group>
}
failedLabel={
<Group gap="sm" role="alert">
<Icon name="warning" opacity=".8" />
<Text c="inherit">{t`Could not upload dictionary`}</Text>
</Group>
}
activeLabel={
<Group gap="md" role="alert">
<Loader size="xs" opacity=".8" />
<Text c="inherit">{t`Uploading dictionary…`}</Text>
</Group>
}
maw="20rem"
onClick={(e) => {
triggerUpload();
e.preventDefault();
}}
/>
<UploadInput
id="content-translation-dictionary-upload-input"
ref={inputRef}
accept="text/csv"
onChange={handleFileChange}
/>
</Stack>
</Form>
);
};

View File

@@ -0,0 +1 @@
export { ContentTranslationConfiguration } from "./ContentTranslationConfiguration";

View File

@@ -0,0 +1,27 @@
import { PLUGIN_CONTENT_TRANSLATION } from "metabase/plugins";
import { hasPremiumFeature } from "metabase-enterprise/settings";
import { ContentTranslationConfiguration } from "./components";
import { useTranslateContent } from "./use-translate-content";
import {
shouldTranslateFieldValuesOfColumn,
translateDisplayNames,
translateFieldValuesInHoveredObject,
translateFieldValuesInSeries,
useTranslateSeries,
} from "./utils";
if (hasPremiumFeature("content_translation")) {
Object.assign(PLUGIN_CONTENT_TRANSLATION, {
isEnabled: true,
useTranslateContent,
translateDisplayNames,
ContentTranslationConfiguration,
shouldTranslateFieldValuesOfColumn,
translateFieldValuesInHoveredObject,
translateFieldValuesInSeries,
useTranslateSeries,
// This gets overwritten in static embedding
contentTranslationDictionaryUrl: null,
});
}

View File

@@ -0,0 +1,35 @@
import { useCallback } from "react";
import { skipToken } from "metabase/api";
import { useLocale } from "metabase/common/hooks";
import type { ContentTranslationFunction } from "metabase/i18n/types";
import { PLUGIN_CONTENT_TRANSLATION } from "metabase/plugins";
import { useListContentTranslationsQuery } from "metabase-enterprise/api";
import { translateContentString } from "./utils";
export const useTranslateContent = (): ContentTranslationFunction => {
const locale = useLocale();
const dictionary = useListContentTranslations();
return useCallback(
<T = string | null | undefined>(msgid: T) =>
translateContentString<T>(dictionary || [], locale, msgid),
[locale, dictionary],
);
};
export const useListContentTranslations = () => {
const locale = useLocale();
const { data } = useListContentTranslationsQuery(
PLUGIN_CONTENT_TRANSLATION.isEnabled &&
// This URL is currently only defined in static embedding
PLUGIN_CONTENT_TRANSLATION.contentTranslationDictionaryUrl
? {
locale,
}
: skipToken,
);
const dictionary = data?.data;
return dictionary;
};

View File

@@ -0,0 +1,53 @@
import { setupEnterprisePlugins } from "__support__/enterprise";
import { setupTranslateContentStringSpy } from "__support__/server-mocks/content-translation";
import { mockSettings } from "__support__/settings";
import { renderWithProviders, screen } from "__support__/ui";
import {
createMockTokenFeatures,
createMockUser,
} from "metabase-types/api/mocks";
import { createMockState } from "metabase-types/store/mocks";
import { useTranslateContent } from "./use-translate-content";
interface SetupOpts {
locale: string | undefined;
/** An untranslated string */
msgid: string | null | undefined;
}
const TestComponent = ({ msgid }: { msgid?: string | null }) => {
const tc = useTranslateContent();
const msgstr = tc(msgid);
return <>{msgstr}</>;
};
function setup({ locale, msgid }: SetupOpts) {
setupEnterprisePlugins();
const storeInitialState = createMockState({
settings: mockSettings({
"token-features": createMockTokenFeatures({ content_translation: true }),
}),
currentUser: createMockUser({ locale }),
});
return renderWithProviders(<TestComponent msgid={msgid} />, {
storeInitialState,
});
}
describe("useTranslateContent", () => {
const getContentTranslatorSpy = setupTranslateContentStringSpy();
it("returns a function that translates the given string", async () => {
setup({
msgid: "Hello World",
locale: "es",
});
expect(
await screen.findByText("translated_Hello World"),
).toBeInTheDocument();
expect(getContentTranslatorSpy()).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,157 @@
import * as I from "icepick";
import { useMemo } from "react";
import _ from "underscore";
import type { HoveredObject } from "metabase/visualizations/types";
import type { ContentTranslationFunction } from "metabase-lib";
import type {
DatasetColumn,
DictionaryArray,
Series,
} from "metabase-types/api";
export type TranslateContentStringFunction = <
MsgidType = string | null | undefined,
>(
dictionary: DictionaryArray | undefined,
locale: string | undefined,
/** This argument will be translated only if it is a string. If it is not a
* string, it will be returned untranslated. */
msgid: MsgidType,
) => string | MsgidType;
/** Translate a user-generated string
*
* Terminology: A "msgid" is a 'raw', untranslated string. A "msgstr" is a
* translation of a msgid.
* */
export const translateContentString: TranslateContentStringFunction = (
dictionary,
locale,
msgid,
) => {
if (!locale) {
return msgid;
}
if (typeof msgid !== "string") {
return msgid;
}
if (!msgid.trim()) {
return msgid;
}
const msgstr = dictionary?.find(
(row) => row.locale === locale && row.msgid === msgid,
)?.msgstr;
if (!msgstr || !msgstr.trim()) {
return msgid;
}
return msgstr;
};
const isRecord = (obj: unknown): obj is Record<string, unknown> =>
_.isObject(obj) && Object.keys(obj).every((key) => typeof key === "string");
/** Walk through obj and translate any display name fields */
export const translateDisplayNames = <T>(
obj: T,
tc: ContentTranslationFunction,
fieldsToTranslate = ["display_name", "displayName"],
): T => {
const traverse = (o: T): T => {
if (Array.isArray(o)) {
return o.map((item) => traverse(item)) as T;
}
if (isRecord(o)) {
return Object.entries(o).reduce((acc, [key, value]) => {
const newValue =
fieldsToTranslate.includes(key as string) && typeof value === "string"
? tc(value)
: traverse(value as T);
return I.assoc(acc, key, newValue);
}, o);
}
return o;
};
return traverse(obj);
};
export const shouldTranslateFieldValuesOfColumn = (col: DatasetColumn) =>
["type/Category", "type/Country", "type/State", "type/Source"].includes(
col?.semantic_type || "",
);
export const translateFieldValuesInHoveredObject = (
obj: HoveredObject | null,
tc?: ContentTranslationFunction,
) => {
if (!tc) {
return obj;
}
return {
...obj,
data: obj?.data?.map((row) => {
const { value, col } = row;
return {
...row,
value:
col &&
shouldTranslateFieldValuesOfColumn(col) &&
typeof value === "string"
? tc(value)
: value,
};
}),
};
};
export const translateFieldValuesInSeries = (
series: Series,
tc: ContentTranslationFunction,
) => {
return series.map((singleSeries) => {
if (!singleSeries.data) {
return singleSeries;
}
const { rows, cols } = singleSeries.data;
const indexesOfColsToTranslate = cols.reduce<number[]>(
(acc, col, index) =>
shouldTranslateFieldValuesOfColumn(col) ? [...acc, index] : acc,
[],
);
const translatedRows = rows.map((row) =>
row.map((value, index) =>
indexesOfColsToTranslate.includes(index) && typeof value === "string"
? tc(value)
: value,
),
);
return {
...singleSeries,
data: { ...singleSeries.data, rows: translatedRows },
};
});
};
export const useTranslateSeries = (
series: Series,
tc: ContentTranslationFunction,
) => {
return useMemo(() => {
const withTranslatedDisplayNames = translateDisplayNames(series, tc);
// Do not translate field values here if display is a map, since this can
// break the map
if (series?.[0]?.card?.display === "map") {
return withTranslatedDisplayNames;
}
return translateFieldValuesInSeries(withTranslatedDisplayNames, tc);
}, [series, tc]);
};

View File

@@ -0,0 +1,451 @@
import type { ContentTranslationFunction } from "metabase/i18n/types";
import type { HoveredObject } from "metabase/visualizations/types";
import type { DatasetColumn, DictionaryArray } from "metabase-types/api";
import {
translateContentString,
translateDisplayNames,
translateFieldValuesInHoveredObject,
} from "./utils";
describe("content translation utils", () => {
describe("translateContentString", () => {
const mockDictionary: DictionaryArray = [
{ locale: "es", msgid: "Hello", msgstr: "Hola" },
{ locale: "es", msgid: "World", msgstr: "Mundo" },
{ locale: "fr", msgid: "Hello", msgstr: "Bonjour" },
{ locale: "es", msgid: " ", msgstr: " " }, // whitespace only
{ locale: "es", msgid: "Empty Translation", msgstr: "" },
{ locale: "es", msgid: "Whitespace Translation", msgstr: " " },
];
it("should return msgid when locale is undefined", () => {
expect(translateContentString(mockDictionary, undefined, "Hello")).toBe(
"Hello",
);
});
it("should return msgid when locale is empty string", () => {
expect(translateContentString(mockDictionary, "", "Hello")).toBe("Hello");
});
it("should return msgid when dictionary is undefined", () => {
expect(translateContentString(undefined, "es", "Hello")).toBe("Hello");
});
it("should return msgid when msgid is null", () => {
expect(translateContentString(mockDictionary, "es", null)).toBe(null);
});
it("should return msgid when msgid is undefined", () => {
expect(translateContentString(mockDictionary, "es", undefined)).toBe(
undefined,
);
});
it("should return msgid when msgid is not a string", () => {
expect(translateContentString(mockDictionary, "es", 123 as any)).toBe(
123,
);
expect(translateContentString(mockDictionary, "es", {} as any)).toEqual(
{},
);
expect(translateContentString(mockDictionary, "es", [] as any)).toEqual(
[],
);
});
it("should return msgid when msgid is empty string", () => {
expect(translateContentString(mockDictionary, "es", "")).toBe("");
});
it("should return msgid when msgid is whitespace only", () => {
expect(translateContentString(mockDictionary, "es", " ")).toBe(" ");
});
it("should return translated string when translation exists", () => {
expect(translateContentString(mockDictionary, "es", "Hello")).toBe(
"Hola",
);
expect(translateContentString(mockDictionary, "es", "World")).toBe(
"Mundo",
);
expect(translateContentString(mockDictionary, "fr", "Hello")).toBe(
"Bonjour",
);
});
it("should return msgid when translation doesn't exist for locale", () => {
expect(translateContentString(mockDictionary, "de", "Hello")).toBe(
"Hello",
);
});
it("should return msgid when translation doesn't exist for msgid", () => {
expect(translateContentString(mockDictionary, "es", "Goodbye")).toBe(
"Goodbye",
);
});
it("should return msgid when translation is empty", () => {
expect(
translateContentString(mockDictionary, "es", "Empty Translation"),
).toBe("Empty Translation");
});
it("should return msgid when translation is whitespace only", () => {
expect(
translateContentString(mockDictionary, "es", "Whitespace Translation"),
).toBe("Whitespace Translation");
});
it("should handle case sensitivity", () => {
expect(translateContentString(mockDictionary, "es", "hello")).toBe(
"hello",
); // no match
expect(translateContentString(mockDictionary, "ES", "Hello")).toBe(
"Hello",
); // no match
});
});
describe("translateDisplayNames", () => {
const mockTc = jest.fn(
((str: string) => `translated_${str}`) as ContentTranslationFunction,
);
beforeEach(() => {
mockTc.mockClear();
mockTc.mockImplementation((str: string) => `translated_${str}`);
});
it("should translate display_name fields in simple objects", () => {
const input = { display_name: "Test Name", other_field: "unchanged" };
const result = translateDisplayNames(input, mockTc);
expect(result).toEqual({
display_name: "translated_Test Name",
other_field: "unchanged",
});
expect(mockTc).toHaveBeenCalledWith("Test Name");
});
it("should translate custom field names when specified", () => {
const input = { title: "Test Title", name: "Test Name" };
const result = translateDisplayNames(input, mockTc, ["title", "name"]);
expect(result).toEqual({
title: "translated_Test Title",
name: "translated_Test Name",
});
expect(mockTc).toHaveBeenCalledWith("Test Title");
expect(mockTc).toHaveBeenCalledWith("Test Name");
});
it("should handle arrays of objects", () => {
const input = [
{ display_name: "First", id: 1 },
{ display_name: "Second", id: 2 },
];
const result = translateDisplayNames(input, mockTc);
expect(result).toEqual([
{ display_name: "translated_First", id: 1 },
{ display_name: "translated_Second", id: 2 },
]);
expect(mockTc).toHaveBeenCalledWith("First");
expect(mockTc).toHaveBeenCalledWith("Second");
});
it("should handle nested objects", () => {
const input = {
display_name: "Parent",
child: {
display_name: "Child",
value: 42,
},
};
const result = translateDisplayNames(input, mockTc);
expect(result).toEqual({
display_name: "translated_Parent",
child: {
display_name: "translated_Child",
value: 42,
},
});
expect(mockTc).toHaveBeenCalledWith("Parent");
expect(mockTc).toHaveBeenCalledWith("Child");
});
it("should handle deeply nested structures with arrays and objects", () => {
const input = {
display_name: "Root",
items: [
{
display_name: "Item 1",
metadata: {
display_name: "Meta 1",
},
},
{
display_name: "Item 2",
tags: ["tag1", "tag2"],
},
],
};
const result = translateDisplayNames(input, mockTc);
expect(result).toEqual({
display_name: "translated_Root",
items: [
{
display_name: "translated_Item 1",
metadata: {
display_name: "translated_Meta 1",
},
},
{
display_name: "translated_Item 2",
tags: ["tag1", "tag2"],
},
],
});
expect(mockTc).toHaveBeenCalledWith("Root");
expect(mockTc).toHaveBeenCalledWith("Item 1");
expect(mockTc).toHaveBeenCalledWith("Meta 1");
expect(mockTc).toHaveBeenCalledWith("Item 2");
});
it("should not translate non-string values in target fields", () => {
const input = {
display_name: 123,
other_display_name: null,
another_display_name: undefined,
};
const result = translateDisplayNames(input, mockTc);
expect(result).toEqual({
display_name: 123,
other_display_name: null,
another_display_name: undefined,
});
expect(mockTc).not.toHaveBeenCalled();
});
it("should handle primitive values", () => {
expect(translateDisplayNames("string", mockTc)).toBe("string");
expect(translateDisplayNames(123, mockTc)).toBe(123);
expect(translateDisplayNames(true, mockTc)).toBe(true);
expect(translateDisplayNames(null, mockTc)).toBe(null);
expect(translateDisplayNames(undefined, mockTc)).toBe(undefined);
expect(mockTc).not.toHaveBeenCalled();
});
it("should create a deep copy and not mutate the original object", () => {
const input = {
display_name: "Original",
nested: { display_name: "Nested" },
};
const result = translateDisplayNames(input, mockTc);
expect(input.display_name).toBe("Original");
expect(input.nested.display_name).toBe("Nested");
expect(result.display_name).toBe("translated_Original");
expect(result.nested.display_name).toBe("translated_Nested");
expect(result).not.toBe(input);
expect(result.nested).not.toBe(input.nested);
});
it("should handle empty arrays and objects", () => {
expect(translateDisplayNames([], mockTc)).toEqual([]);
expect(translateDisplayNames({}, mockTc)).toEqual({});
expect(mockTc).not.toHaveBeenCalled();
});
});
describe("translateFieldValuesInHoveredObject", () => {
const mockTc = jest.fn(
((str: string) => `translated_${str}`) as ContentTranslationFunction,
);
beforeEach(() => {
mockTc.mockClear();
mockTc.mockImplementation((str: string) => `translated_${str}`);
});
it("should return object with null data when passed null", () => {
const result = translateFieldValuesInHoveredObject(null, mockTc);
expect(result).toEqual({ data: undefined });
expect(mockTc).not.toHaveBeenCalled();
});
it("should return object unchanged when data is undefined", () => {
const obj: HoveredObject = { index: 1 };
const result = translateFieldValuesInHoveredObject(obj, mockTc);
expect(result).toEqual({ index: 1, data: undefined });
expect(mockTc).not.toHaveBeenCalled();
});
it("should translate string values for categorical columns", () => {
const categoryCol: DatasetColumn = {
semantic_type: "type/Category",
source: "",
name: "category",
display_name: "Category",
base_type: "type/Text",
};
const obj: HoveredObject = {
data: [
{ col: categoryCol, value: "Red", key: "test1" },
{ col: categoryCol, value: "Blue", key: "test2" },
],
};
const result = translateFieldValuesInHoveredObject(obj, mockTc);
expect(result?.data).toEqual([
{ col: categoryCol, value: "translated_Red", key: "test1" },
{ col: categoryCol, value: "translated_Blue", key: "test2" },
]);
expect(mockTc).toHaveBeenCalledWith("Red");
expect(mockTc).toHaveBeenCalledWith("Blue");
});
it("should translate string values for country columns", () => {
const countryCol: DatasetColumn = {
semantic_type: "type/Country",
source: "",
name: "country",
display_name: "Country",
base_type: "type/Text",
};
const obj: HoveredObject = {
data: [
{ col: countryCol, value: "Vietnam", key: "test1" },
{ col: countryCol, value: "Rwanda", key: "test2" },
],
};
const result = translateFieldValuesInHoveredObject(obj, mockTc);
expect(result?.data).toEqual([
{ col: countryCol, value: "translated_Vietnam", key: "test1" },
{ col: countryCol, value: "translated_Rwanda", key: "test2" },
]);
expect(mockTc).toHaveBeenCalledWith("Vietnam");
expect(mockTc).toHaveBeenCalledWith("Rwanda");
});
it("should not translate values for non-categorical columns", () => {
const numberCol: DatasetColumn = {
semantic_type: "type/Number",
source: "",
name: "amount",
display_name: "Amount",
base_type: "type/Integer",
};
const obj: HoveredObject = {
data: [{ col: numberCol, value: "123", key: "test1" }],
};
const result = translateFieldValuesInHoveredObject(obj, mockTc);
expect(result?.data).toEqual([
{ col: numberCol, value: "123", key: "test1" },
]);
expect(mockTc).not.toHaveBeenCalled();
});
it("should not translate non-string values", () => {
const categoryCol: DatasetColumn = {
semantic_type: "type/Category",
source: "",
name: "category",
display_name: "Category",
base_type: "type/Text",
};
const obj: HoveredObject = {
data: [
{ col: categoryCol, value: 123, key: "test1" },
{ col: categoryCol, value: null, key: "test2" },
],
};
const result = translateFieldValuesInHoveredObject(obj, mockTc);
expect(result?.data).toEqual([
{ col: categoryCol, value: 123, key: "test1" },
{ col: categoryCol, value: null, key: "test2" },
]);
expect(mockTc).not.toHaveBeenCalled();
});
it("should not translate values when col is null", () => {
const obj: HoveredObject = {
data: [{ col: null, value: "test", key: "test1" }],
};
const result = translateFieldValuesInHoveredObject(obj, mockTc);
expect(result?.data).toEqual([
{ col: null, value: "test", key: "test1" },
]);
expect(mockTc).not.toHaveBeenCalled();
});
it("should preserve other properties of the hovered object", () => {
const categoryCol: DatasetColumn = {
semantic_type: "type/Category",
source: "",
name: "category",
display_name: "Category",
base_type: "type/Text",
};
const obj: HoveredObject = {
index: 5,
seriesIndex: 2,
value: "some value",
data: [{ col: categoryCol, value: "Red", key: "test1" }],
};
const result = translateFieldValuesInHoveredObject(obj, mockTc);
expect(result).toEqual({
index: 5,
seriesIndex: 2,
value: "some value",
data: [{ col: categoryCol, value: "translated_Red", key: "test1" }],
});
expect(mockTc).toHaveBeenCalledWith("Red");
});
it("should handle mixed column types in data array", () => {
const categoryCol: DatasetColumn = {
semantic_type: "type/Category",
source: "",
name: "category",
display_name: "Category",
base_type: "type/Text",
};
const numberCol: DatasetColumn = {
semantic_type: "type/Number",
source: "",
name: "amount",
display_name: "Amount",
base_type: "type/Integer",
};
const obj: HoveredObject = {
data: [
{ col: categoryCol, value: "Red", key: "test1" },
{ col: numberCol, value: "100", key: "test2" },
{ col: categoryCol, value: "Blue", key: "test3" },
],
};
const result = translateFieldValuesInHoveredObject(obj, mockTc);
expect(result?.data).toEqual([
{ col: categoryCol, value: "translated_Red", key: "test1" },
{ col: numberCol, value: "100", key: "test2" },
{ col: categoryCol, value: "translated_Blue", key: "test3" },
]);
expect(mockTc).toHaveBeenCalledWith("Red");
expect(mockTc).toHaveBeenCalledWith("Blue");
expect(mockTc).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,91 @@
import { setupEnterprisePlugins } from "__support__/enterprise";
import { setupTranslateContentStringSpy } from "__support__/server-mocks/content-translation";
import { mockSettings } from "__support__/settings";
import { renderWithProviders, screen, waitFor } from "__support__/ui";
import { ParameterWidget } from "metabase/parameters/components/ParameterWidget";
import type Field from "metabase-lib/v1/metadata/Field";
import type { FieldFilterUiParameter } from "metabase-lib/v1/parameters/types";
import type { Parameter, TokenFeatures } from "metabase-types/api";
import {
createMockParameter,
createMockTokenFeatures,
createMockUser,
} from "metabase-types/api/mocks";
import { createMockState } from "metabase-types/store/mocks";
interface SetupOpts {
connectedField?: Field;
tokenFeatures?: Partial<TokenFeatures>;
hasEnterprisePlugins?: boolean;
parameterOpts?: Partial<Parameter>;
locale?: string;
}
function setup({
connectedField,
tokenFeatures = {},
hasEnterprisePlugins = false,
parameterOpts = {},
locale = "en",
}: SetupOpts = {}) {
const state = createMockState({
settings: mockSettings({
"token-features": createMockTokenFeatures(tokenFeatures),
}),
currentUser: createMockUser({ locale }),
});
if (hasEnterprisePlugins) {
setupEnterprisePlugins();
}
const parameter: FieldFilterUiParameter = {
...createMockParameter({
id: "1fe8ce3",
type: "string/contains",
slug: "text_contains",
name: "Text contains",
...parameterOpts,
}),
fields: connectedField ? [connectedField] : [],
};
const setValue = jest.fn();
renderWithProviders(
<ParameterWidget parameter={parameter} setValue={setValue} />,
{ storeInitialState: state },
);
return { setValue };
}
describe("Parameter Widget", () => {
describe("content translation", () => {
const setupOptions = { locale: "de", parameterOpts: { value: "a" } };
const getContentTranslatorSpy = setupTranslateContentStringSpy();
it("(OSS) should not translate any content", async () => {
setup(setupOptions);
expect(await screen.findByTestId("field-set-legend")).toHaveTextContent(
"Text contains",
);
expect(getContentTranslatorSpy()).not.toHaveBeenCalled();
});
it("(EE with token) should translate legend", async () => {
setup({
...setupOptions,
hasEnterprisePlugins: true,
tokenFeatures: { content_translation: true },
});
await waitFor(async () => {
expect(await screen.findByTestId("field-set-legend")).toHaveTextContent(
"translated_Text contains",
);
});
expect(getContentTranslatorSpy()).toHaveBeenCalled();
});
});
});

View File

@@ -13,6 +13,7 @@ import "./sandboxes";
import "./auth";
import "./caching";
import "./collections";
import "./content_translation";
import "./content_verification";
import "./database_routing";
import "./whitelabel";

View File

@@ -4,6 +4,7 @@ import type { TokenFeature } from "metabase-types/api";
export function hasPremiumFeature(feature: TokenFeature) {
const tokenFeatures = MetabaseSettings.get("token-features");
if (tokenFeatures == null) {
// This is the SDK, because settings are only loaded asynchronously after the SDK has run.
return false;

View File

@@ -5,6 +5,7 @@ import {
setupSettingsEndpoints,
setupUpdateSettingEndpoint,
} from "__support__/server-mocks";
import { setupContentTranslationEndpoints } from "__support__/server-mocks/content-translation";
import { renderWithProviders, screen, waitFor, within } from "__support__/ui";
import { findRequests } from "__support__/utils";
import type { EnterpriseSettings } from "metabase-types/api";
@@ -24,6 +25,7 @@ const setup = async (
...overrides,
});
setupPropertiesEndpoints(settings);
setupContentTranslationEndpoints();
setupSettingsEndpoints([
{
key: "application-font",

View File

@@ -664,3 +664,7 @@ export type FieldItem = {
};
export type DependentItem = DatabaseItem | SchemaItem | TableItem | FieldItem;
export type ContentTranslationFunction = <T extends string | null | undefined>(
msgid: T,
) => string | T;

View File

@@ -0,0 +1,33 @@
export type DictionaryArrayRow = {
locale: string;
msgid: string;
msgstr: string;
};
export type DictionaryArray = DictionaryArrayRow[];
/** Translations retrieved from the BE have ids */
export type RetrievedDictionaryArrayRow = DictionaryArrayRow & { id: number };
export type DictionaryResponse = {
data: RetrievedDictionaryArrayRow[];
};
export const isDictionaryArrayRow = (
item: unknown,
): item is DictionaryArrayRow => {
return (
typeof item === "object" &&
item !== null &&
"locale" in item &&
typeof item.locale === "string" &&
"msgid" in item &&
typeof item.msgid === "string" &&
"msgstr" in item &&
typeof item.msgstr === "string"
);
};
export const isDictionaryArray = (data: unknown): data is DictionaryArray => {
return Array.isArray(data) && data.every(isDictionaryArrayRow);
};

View File

@@ -189,6 +189,7 @@ export type VirtualDashboardCard = BaseDashboardCard & {
visualization_settings: BaseDashboardCard["visualization_settings"] & {
virtual_card: VirtualCard;
link?: LinkCardSettings;
text?: string;
};
};

View File

@@ -50,8 +50,8 @@ export type FieldVisibilityType =
| "sensitive";
type HumanReadableFieldValue = string;
type RemappedFieldValue = [RowValue, HumanReadableFieldValue];
type NotRemappedFieldValue = [RowValue];
export type RemappedFieldValue = [RowValue, HumanReadableFieldValue];
export type NotRemappedFieldValue = [RowValue];
export type FieldValue = NotRemappedFieldValue | RemappedFieldValue;
export type FieldValuesType = "list" | "search" | "none";

View File

@@ -12,6 +12,7 @@ export * from "./bug-report";
export * from "./card";
export * from "./click-behavior";
export * from "./collection";
export * from "./content-translation";
export * from "./dashboard";
export * from "./database";
export * from "./dataset";

View File

@@ -101,6 +101,7 @@ export const createMockTokenFeatures = (
audit_app: false,
cache_granular_controls: false,
disable_password_login: false,
content_translation: false,
content_verification: false,
embedding: false,
embedding_sdk: false,
@@ -165,7 +166,6 @@ export const createMockSettings = (
["es", "Spanish"],
["ko", "Korean"],
["pt_BR", "Portuguese (Brazil)"],
["zh", "Chinese"],
["zh_CN", "Chinese (China)"],
["zh_HK", "Chinese (Hong Kong SAR China)"],
["zh_TW", "Chinese (Taiwan)"],

View File

@@ -165,6 +165,7 @@ const tokenStatusFeatures = [
"collection-cleanup",
"config-text-file",
"content-management",
"content-translation",
"content-verification",
"dashboard-subscription-filters",
"database-auth-providers",
@@ -221,8 +222,9 @@ export const tokenFeatures = [
"advanced_permissions",
"audit_app",
"cache_granular_controls",
"disable_password_login",
"content_translation",
"content_verification",
"disable_password_login",
"embedding",
"embedding_sdk",
"embedding_iframe_sdk",

View File

@@ -15,7 +15,7 @@ export const isVirtualCardDisplayType = (
typeof value === "string" &&
virtualCardDisplayTypes.includes(value as VirtualCardDisplay);
const cardDisplayTypes = [
export const cardDisplayTypes = [
"table",
"bar",
"line",

View File

@@ -1,7 +1,9 @@
import { t } from "ttag";
import _ from "underscore";
import ErrorBoundary from "metabase/ErrorBoundary";
import { useSetting } from "metabase/common/hooks";
import { PLUGIN_CONTENT_TRANSLATION } from "metabase/plugins";
import { Box, Stack } from "metabase/ui";
import { AdminSettingInput } from "../widgets/AdminSettingInput";
@@ -21,6 +23,11 @@ export function LocalizationSettingsPage() {
)}
inputType="select"
/>
{PLUGIN_CONTENT_TRANSLATION.isEnabled && (
<ErrorBoundary>
<PLUGIN_CONTENT_TRANSLATION.ContentTranslationConfiguration />
</ErrorBoundary>
)}
<AdminSettingInput
name="report-timezone"
searchable

View File

@@ -9,6 +9,7 @@ export const TAG_TYPES = [
"channel",
"cloud-migration",
"collection",
"content-translation",
"dashboard",
"dashboard-question-candidates",
"database",
@@ -45,6 +46,7 @@ export const TAG_TYPES = [
export const TAG_TYPE_MAPPING = {
collection: "collection",
"content-translation": "content-translation",
card: "card",
dashboard: "dashboard",
database: "database",

View File

@@ -3,7 +3,7 @@ import styled from "@emotion/styled";
import { doNotForwardProps } from "metabase/common/utils/doNotForwardProps";
import Link from "metabase/core/components/Link";
import { LegendLabel } from "metabase/visualizations/components/legend/LegendCaption.styled";
import { LegendLabel } from "metabase/visualizations/components/legend/LegendCaption/LegendCaption.styled";
import ChartSkeleton from "metabase/visualizations/components/skeletons/ChartSkeleton";
import StaticSkeleton from "metabase/visualizations/components/skeletons/StaticSkeleton";

View File

@@ -1,19 +0,0 @@
/* eslint-disable react/prop-types */
import cx from "classnames";
import { memo } from "react";
import CS from "metabase/css/core/index.css";
import { Icon, Tooltip } from "metabase/ui";
const TitleAndDescription = ({ title, description, className }) => (
<div className={cx(CS.flex, CS.alignCenter, className)}>
<h2 className={cx(CS.h2, CS.mr1, CS.textWrap)}>{title}</h2>
{description && (
<Tooltip label={description} maw="22em">
<Icon name="info" className={CS.mx1} />
</Tooltip>
)}
</div>
);
export default memo(TitleAndDescription);

View File

@@ -0,0 +1,31 @@
import cx from "classnames";
import { memo } from "react";
import CS from "metabase/css/core/index.css";
import { useTranslateContent } from "metabase/i18n/hooks";
import { Icon, Tooltip } from "metabase/ui";
const TitleAndDescription = ({
title,
description,
className,
}: {
title: string;
description?: string | null;
className?: string;
}) => {
const tc = useTranslateContent();
return (
<div className={cx(CS.flex, CS.alignCenter, className)}>
<h2 className={cx(CS.h2, CS.mr1, CS.textWrap)}>{tc(title)}</h2>
{description && (
<Tooltip label={tc(description)} maw="22em">
<Icon name="info" className={CS.mx1} />
</Tooltip>
)}
</div>
);
};
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default memo(TitleAndDescription);

View File

@@ -0,0 +1,31 @@
import userEvent from "@testing-library/user-event";
import { setupTranslateContentStringSpy } from "__support__/server-mocks/content-translation";
import { screen } from "__support__/ui";
import { type SetupOpts, setup as baseSetup } from "./utils";
describe("TitleAndDescription component (OSS)", () => {
const setup = (opts?: SetupOpts) =>
baseSetup({ hasEnterprisePlugins: false, ...opts });
const getContentTranslatorSpy = setupTranslateContentStringSpy();
it("displays untranslated question title and description", async () => {
setup();
expect(
await screen.findByRole("heading", {
name: "Sample Heading",
}),
).toBeInTheDocument();
await userEvent.hover(screen.getByLabelText("info icon"));
expect(
await screen.findByRole("tooltip", {
name: "Sample Description",
}),
).toBeInTheDocument();
expect(getContentTranslatorSpy()).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,36 @@
import userEvent from "@testing-library/user-event";
import { setupTranslateContentStringSpy } from "__support__/server-mocks/content-translation";
import { screen } from "__support__/ui";
import { type SetupOpts, setup as baseSetup } from "./utils";
describe("TitleAndDescription component", () => {
describe("EE with token", () => {
const setup = (opts?: SetupOpts) =>
baseSetup({
hasEnterprisePlugins: true,
tokenFeatures: { content_translation: true },
...opts,
});
const getContentTranslatorSpy = setupTranslateContentStringSpy();
it("displays translated question title and description", async () => {
setup();
expect(
await screen.findByRole("heading", {
name: "translated_Sample Heading",
}),
).toBeInTheDocument();
await userEvent.hover(screen.getByLabelText("info icon"));
expect(
await screen.findByRole("tooltip", {
name: "translated_Sample Description",
}),
).toBeInTheDocument();
expect(getContentTranslatorSpy()).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,42 @@
import { setupEnterprisePlugins } from "__support__/enterprise";
import { mockSettings } from "__support__/settings";
import { renderWithProviders } from "__support__/ui";
import type { TokenFeatures } from "metabase-types/api";
import {
createMockTokenFeatures,
createMockUser,
} from "metabase-types/api/mocks";
import { createMockState } from "metabase-types/store/mocks";
import TitleAndDescription from "../TitleAndDescription";
export interface SetupOpts {
localeCode?: string;
hasEnterprisePlugins?: boolean;
tokenFeatures?: Partial<TokenFeatures>;
}
export const setup = ({
localeCode,
hasEnterprisePlugins,
tokenFeatures = {},
}: SetupOpts) => {
const storeInitialState = createMockState({
settings: mockSettings({
"token-features": createMockTokenFeatures(tokenFeatures),
}),
currentUser: createMockUser({ locale: localeCode }),
});
if (hasEnterprisePlugins) {
setupEnterprisePlugins();
}
return renderWithProviders(
<TitleAndDescription
title={"Sample Heading"}
description={"Sample Description"}
/>,
{ storeInitialState },
);
};

View File

@@ -1,6 +1,6 @@
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import { type ChangeEventHandler, forwardRef } from "react";
import { type InputHTMLAttributes, forwardRef } from "react";
import { DEFAULT_UPLOAD_INPUT_ID } from "./constants";
@@ -8,14 +8,13 @@ const StyledUploadInput = styled.input`
display: none;
`;
interface IUploadInputProps {
interface IUploadInputProps extends InputHTMLAttributes<HTMLInputElement> {
id?: string;
onChange: ChangeEventHandler<HTMLInputElement>;
}
export const UploadInput = forwardRef<HTMLInputElement, IUploadInputProps>(
function UploadInputRef(
{ id = DEFAULT_UPLOAD_INPUT_ID, onChange }: IUploadInputProps,
{ id = DEFAULT_UPLOAD_INPUT_ID, ...props }: IUploadInputProps,
ref,
) {
return (
@@ -25,7 +24,7 @@ export const UploadInput = forwardRef<HTMLInputElement, IUploadInputProps>(
ref={ref}
type="file"
accept="text/csv,text/tab-separated-values"
onChange={onChange}
{...props}
/>
);
},

View File

@@ -21,6 +21,7 @@ import {
import { t } from "ttag";
import ControlledPopoverWithTrigger from "metabase/components/PopoverWithTrigger/ControlledPopoverWithTrigger";
import { useTranslateContent } from "metabase/i18n/hooks";
import { lighten } from "metabase/lib/colors";
import type { TabContextType } from "../Tab";
@@ -218,17 +219,23 @@ export function RenameableTabButton({
tabIndex,
...props
}: RenameableTabButtonProps) {
const tc = useTranslateContent();
const { value: selectedValue } = useContext(TabContext);
const isSelected = props.value === selectedValue;
// Only translate the label if it is not editable
const maybeTranslatedLabelProp = canRename ? labelProp : tc(labelProp);
const [label, setLabel] = useState(labelProp);
const [prevLabel, setPrevLabel] = useState(label);
const [isRenaming, setIsRenaming] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setLabel(labelProp);
}, [labelProp]);
setLabel(maybeTranslatedLabelProp);
}, [maybeTranslatedLabelProp]);
useEffect(() => {
if (isRenaming) {

View File

@@ -12,9 +12,11 @@ import {
isQuestionCard,
isVirtualDashCard,
} from "metabase/dashboard/utils";
import { useTranslateContent } from "metabase/i18n/hooks";
import { useSelector } from "metabase/lib/redux";
import { isJWT } from "metabase/lib/utils";
import { isUuid } from "metabase/lib/uuid";
import { PLUGIN_CONTENT_TRANSLATION } from "metabase/plugins";
import { getMetadata } from "metabase/selectors/metadata";
import { Flex, type IconName, type IconProps, Menu, Title } from "metabase/ui";
import { getVisualizationRaw, isCartesianChart } from "metabase/visualizations";
@@ -115,7 +117,7 @@ interface DashCardVisualizationProps {
export function DashCardVisualization({
dashcard,
dashboard,
series: rawSeries,
series: untranslatedRawSeries,
getClickActionMode,
getHref,
gridSize,
@@ -155,6 +157,12 @@ export function DashCardVisualization({
: null;
}, [dashcard.card, metadata]);
const tc = useTranslateContent();
const rawSeries = PLUGIN_CONTENT_TRANSLATION.useTranslateSeries(
untranslatedRawSeries,
tc,
);
const series = useMemo(() => {
if (
!dashcard ||

View File

@@ -37,6 +37,7 @@ export const FormSubmitButton = forwardRef(function FormSubmitButton(
successLabel,
failedLabel,
});
const submitColor = getSubmitButtonColor(status, { color });
return (

View File

@@ -0,0 +1,27 @@
// To keep the components that require content translation tidier, they can
// invoke these facades, which delegate to the plugin implementation
//
import { useCallback } from "react";
import { PLUGIN_CONTENT_TRANSLATION } from "metabase/plugins";
import type { ContentTranslationFunction } from "./types";
export const useTranslateContent = <
T = string | null | undefined,
>(): ContentTranslationFunction => {
return PLUGIN_CONTENT_TRANSLATION.useTranslateContent<T>();
};
/** Returns a function that can be used to sort user-generated strings in an
* array by their translations. */
export const useSortByContentTranslation = () => {
const tc = PLUGIN_CONTENT_TRANSLATION.useTranslateContent();
// What makes this sort translation-aware is the use of the tc function.
// 'localeCompare' is just a standard way of comparing two strings
// alphabetically.
return useCallback(
(a: string, b: string) => tc(a).localeCompare(tc(b)),
[tc],
);
};

View File

@@ -0,0 +1,7 @@
export type NonEmpty<ArrayType> = ArrayType extends (infer ItemType)[]
? [ItemType, ...ItemType[]]
: never;
export type ContentTranslationFunction = <T = string | null | undefined>(
msgid: T,
) => string | T;

View File

@@ -5,6 +5,7 @@ import { t } from "ttag";
import { Sortable } from "metabase/core/components/Sortable";
import CS from "metabase/css/core/index.css";
import { useTranslateContent } from "metabase/i18n/hooks";
import FormattedParameterValue from "metabase/parameters/components/FormattedParameterValue";
import S from "metabase/parameters/components/ParameterValueWidget.module.css";
import { ParameterValueWidgetTrigger } from "metabase/parameters/components/ParameterValueWidgetTrigger";
@@ -68,6 +69,8 @@ export const ParameterValueWidget = ({
value,
...popoverProps
}: ParameterValueWidgetProps) => {
const tc = useTranslateContent();
const [isFocused, setIsFocused] = useState(false);
const hasValue = !parameterHasNoDisplayValue(value);
@@ -229,11 +232,13 @@ export const ParameterValueWidget = ({
);
}
const maybeTranslatedPlaceholder = tc(placeholder);
const placeholderText = isEditing
? isDateParameter(parameter)
? t`Select a default value…`
: t`Enter a default value…`
: placeholder || t`Select…`;
: maybeTranslatedPlaceholder || t`Select…`;
return (
<Popover

View File

@@ -6,6 +6,7 @@ import { Sortable } from "metabase/core/components/Sortable";
import CS from "metabase/css/core/index.css";
import DashboardS from "metabase/css/dashboard.module.css";
import type { DashboardFullscreenControls } from "metabase/dashboard/types";
import { useTranslateContent } from "metabase/i18n/hooks";
import EmbedFrameS from "metabase/public/components/EmbedFrame/EmbedFrame.module.css";
import { Box, Flex, Icon } from "metabase/ui";
import type Question from "metabase-lib/v1/Question";
@@ -102,7 +103,11 @@ export const ParameterWidget = ({
const [isFocused, setIsFocused] = useState(false);
const isEditingParameter = editingParameter?.id === parameter.id;
const fieldHasValueOrFocus = parameter.value != null || isFocused;
const legend = fieldHasValueOrFocus ? parameter.name : "";
const tc = useTranslateContent();
const maybeTranslatedParameterName = tc(parameter.name);
const legend = fieldHasValueOrFocus ? maybeTranslatedParameterName : "";
if (!isEditing || !setEditingParameter) {
return (

View File

@@ -54,7 +54,7 @@ import type {
} from "metabase-types/api";
import type { State } from "metabase-types/store";
import ValueComponent from "../Value";
import { Value as ValueComponent } from "../Value";
import { OptionsMessage, StyledEllipsified } from "./FieldValuesWidget.styled";
import { ListField } from "./ListField";

View File

@@ -22,6 +22,7 @@ export const OptionsList = styled.ul<OptionListProps>`
list-style: none;
max-height: ${(props) => (props.isDashboardFilter ? "300px" : "none")};
padding: ${(props) => (props.isDashboardFilter ? "0.5rem" : "0")};
padding-bottom: 0;
`;
export const OptionContainer = styled.li`

View File

@@ -1,5 +1,5 @@
import type * as React from "react";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { t } from "ttag";
import _ from "underscore";
@@ -8,6 +8,10 @@ import LoadingSpinner from "metabase/components/LoadingSpinner";
import type { InputProps } from "metabase/core/components/Input";
import Input from "metabase/core/components/Input";
import { useDebouncedValue } from "metabase/hooks/use-debounced-value";
import {
useSortByContentTranslation,
useTranslateContent,
} from "metabase/i18n/hooks";
import { delay } from "metabase/lib/delay";
import { Checkbox, Flex, Text } from "metabase/ui";
import type { RowValue } from "metabase-types/api";
@@ -18,15 +22,19 @@ import {
OptionContainer,
OptionsList,
} from "./ListField.styled";
import type { ListFieldProps, Option } from "./types";
import { isValidOptionItem } from "./utils";
import {
type ListFieldProps,
type Option,
getOptionDisplayName,
} from "./types";
import { optionMatchesFilter } from "./utils";
const DEBOUNCE_FILTER_TIME = delay(100);
function createOptionsFromValuesWithoutOptions(
values: RowValue[],
options: Option[],
): Option {
): Option[] {
const optionsMap = new Map(options.map((option) => [option[0], option]));
return values
.filter((value) => !optionsMap.has(value))
@@ -43,7 +51,7 @@ export const ListField = ({
isLoading,
}: ListFieldProps) => {
const [selectedValues, setSelectedValues] = useState(new Set(value));
const [addedOptions, setAddedOptions] = useState<Option>(() =>
const [addedOptions, setAddedOptions] = useState<Option[]>(() =>
createOptionsFromValuesWithoutOptions(value, options),
);
@@ -51,18 +59,52 @@ export const ListField = ({
return [...options.filter((option) => option[0] != null), ...addedOptions];
}, [addedOptions, options]);
const sortedOptions = useMemo(() => {
if (selectedValues.size === 0) {
return augmentedOptions;
}
const tc = useTranslateContent();
const sortByTranslation = useSortByContentTranslation();
const optionsHaveSomeTranslations = useMemo(
() => augmentedOptions.some((option) => tc(option) !== option),
[augmentedOptions, tc],
);
const [selected, unselected] = _.partition(augmentedOptions, (option) =>
selectedValues.has(option[0]),
);
/**
* Sorts options alphabetically, or by their translation if content
* translation is enabled. Selected options are sorted to the top. Since
* options are arrays, the last item in the array is used as the name of the
* option.
*/
const sortOptions = useCallback(
(optionA: Option, optionB: Option) => {
const aSelected = selectedValues.has(optionA[0]),
bSelected = selectedValues.has(optionB[0]);
return [...selected, ...unselected];
if (aSelected && !bSelected) {
return -1;
}
if (!aSelected && bSelected) {
return 1;
}
// If no options have translations, rely on the sorting that was already
// done in the backend
if (!optionsHaveSomeTranslations) {
return 0;
}
const aName = getOptionDisplayName(optionA),
bName = getOptionDisplayName(optionB);
return typeof aName === "string" && typeof bName === "string"
? sortByTranslation(aName, bName)
: 0;
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedValues is omitted from the deps so that selecting a value does not trigger re-sorting
[sortByTranslation],
);
const sortedOptions = useMemo(
() => augmentedOptions.sort(sortOptions),
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [augmentedOptions.length]);
[augmentedOptions.length, sortOptions],
);
const [filter, setFilter] = useState("");
const debouncedFilter = useDebouncedValue(filter, DEBOUNCE_FILTER_TIME);
@@ -72,25 +114,10 @@ export const ListField = ({
if (formattedFilter.length === 0) {
return sortedOptions;
}
return augmentedOptions.filter((option) => {
if (!option || option.length === 0) {
return false;
}
// option as: [id, name]
if (
option.length > 1 &&
option[1] &&
isValidOptionItem(option[1], formattedFilter)
) {
return true;
}
// option as: [id]
return isValidOptionItem(option[0], formattedFilter);
});
}, [augmentedOptions, debouncedFilter, sortedOptions]);
return sortedOptions.filter((option) =>
optionMatchesFilter(option, formattedFilter, tc),
);
}, [debouncedFilter, sortedOptions, tc]);
const selectedFilteredOptions = filteredOptions.filter(([value]) =>
selectedValues.has(value),

View File

@@ -20,7 +20,7 @@ type SetupOpts = {
function setup({
value = [],
options = [],
optionRenderer = ([value]) => value,
optionRenderer = ([value]) => <>{value}</>,
placeholder = "Search the list",
checkedColor,
isDashboardFilter,

View File

@@ -0,0 +1,27 @@
import _ from "underscore";
import type { ContentTranslationFunction } from "metabase/i18n/types";
import type { DictionaryResponse } from "metabase-types/api";
export const translateToGerman: ContentTranslationFunction = (msgid) => {
const dictionary: Record<string, string> = {
"Marble Shoes": "Marmorschuhe",
Gadget: "Gerät",
Widget: "Apparat",
};
return _.get(dictionary, msgid as string, msgid);
};
export const translateToJapanese: ContentTranslationFunction = (msgid) => {
const dictionary = {
Products: "グッズ",
};
return _.get(dictionary, msgid as string, msgid);
};
export const portugueseDictionary: DictionaryResponse["data"] = [
{ id: 0, locale: "pt_BR", msgid: "Doohickey", msgstr: "Treco" },
{ id: 1, locale: "pt_BR", msgid: "Gizmo", msgstr: "Aparelho" },
{ id: 2, locale: "pt_BR", msgid: "Gadget", msgstr: "Dispositivo" },
{ id: 3, locale: "pt_BR", msgid: "Widget", msgstr: "Engenhoca" },
];

View File

@@ -1,13 +1,15 @@
import type { JSX } from "react";
import type { RowValue } from "metabase-types/api";
export type Option = any[];
import type { FieldValue, RowValue } from "metabase-types/api";
export type Option = FieldValue;
export const getOptionDisplayName = (option: Option) => String(option.at(-1));
export interface ListFieldProps {
onChange: (value: RowValue[]) => void;
value: RowValue[];
options: Option;
optionRenderer: (option: any) => JSX.Element;
options: Option[];
optionRenderer: (option: Option) => JSX.Element;
placeholder: string;
isDashboardFilter?: boolean;
checkedColor?: string;

View File

@@ -1,3 +1,31 @@
export function isValidOptionItem(optionItem: any, filter: string): boolean {
return String(optionItem).toLowerCase().includes(filter);
import type { ContentTranslationFunction } from "metabase/i18n/types";
import type { FieldValue } from "metabase-types/api";
/**
* Returns true when the option matches the filter. If the option has an
* available content translation, the filter is tested against this translation
**/
export function optionMatchesFilter(
option: FieldValue | undefined,
filter: string,
translate?: ContentTranslationFunction,
) {
// If filter has no diacritics (like äéĩøů), let's ignore diacritics in the
// option
const ignoreDiacritics = filter === removeDiacritics(filter);
return option?.some((value) => {
const stringifiedValue = String(value ?? "");
const maybeTranslatedValue =
translate?.(stringifiedValue) || stringifiedValue;
const normalizedValue = ignoreDiacritics
? removeDiacritics(maybeTranslatedValue)
: maybeTranslatedValue;
return normalizedValue.toLowerCase().includes(filter);
});
}
/** To allow a string like Gerät to match a query like 'gera', we remove
* diacritics from a string */
export const removeDiacritics = (value: string) =>
value?.normalize("NFD").replace(/[\u0300-\u036f]/g, "");

View File

@@ -1,22 +1,71 @@
import { isValidOptionItem } from "./utils";
import type { ContentTranslationFunction } from "metabase/i18n/types";
import type { FieldValue } from "metabase-types/api";
import { translateToGerman, translateToJapanese } from "./test-constants";
import { optionMatchesFilter, removeDiacritics } from "./utils";
describe("ListField - utils", () => {
describe("isValidOptionItem", () => {
const TEST_CASES = [
["Marble Shoes", "marble", true],
[[6, "Marble Shoes"], "marble", true],
[[6, "Marble Shoes"], "6", true],
[[6], "6", true],
[[null, "Marble Shoes"], "marble", true],
[[6, null], "6", true],
[[6, "Marble Shoes"], "abcde", false],
const TEST_CASES: [
expectedResult: boolean,
optionItem: FieldValue,
filter: string,
translate?: ContentTranslationFunction,
][] = [
// Test cases without localization
[true, [6, "Marble Shoes"], "marble"],
[true, [6, "Marble Shoes"], "6"],
[true, [6], "6"],
[true, [null, "Marble Shoes"], "marble"],
[false, [6, "Marble Shoes"], "abcde"],
[false, ["Gerät"], "app"],
[true, ["Gerät"], "ger"],
[true, ["Gerät"], "gerät"],
[false, ["Apparat"], "ät"],
[true, ["グッズ"], "グ"],
// Test cases with localization
[true, [6, "Marble Shoes"], "marm", translateToGerman], // 'marm' matches the translation of 'Marble Shoes'
[true, [null, "Marble Shoes"], "marm", translateToGerman],
[false, [6, "Marble Shoes"], "marble", translateToGerman], // 'marble' does not match the translation of 'Marble Shoes'
[true, ["Gadget"], "ger", translateToGerman],
[true, ["Gadget"], "gerät", translateToGerman],
[false, ["Widget"], "ät", translateToGerman], // "ät" does not match the translation of Widget ("Apparat")
[true, ["Products"], "グ", translateToJapanese],
];
TEST_CASES.map(([optionItem, filter, expectedResult]) => {
it(`includes "${filter}" in: ${optionItem}`, () => {
const result = isValidOptionItem(optionItem, String(filter));
TEST_CASES.map(([expectedResult, option, filter, tc]) => {
it(`"${filter}" ${expectedResult ? "matches" : "does not match"} ${option}`, () => {
const result = optionMatchesFilter(option, filter, tc);
expect(result).toEqual(expectedResult);
});
});
});
describe("removeDiacritics", () => {
it("should remove diacritics from a string", () => {
expect(removeDiacritics("Gerät")).toBe("Gerat");
});
it("should handle strings without diacritics", () => {
expect(removeDiacritics("Gerat")).toBe("Gerat");
});
it("should return an empty string given an empty string", () => {
expect(removeDiacritics("")).toBe("");
});
it("should remove diacritics from characters with multiple accents", () => {
expect(removeDiacritics("café")).toBe("cafe");
expect(removeDiacritics("jalapeño")).toBe("jalapeno");
});
it("should handle complex Unicode sequences", () => {
expect(removeDiacritics("ḡ")).toBe("g");
});
it("should remove diacritics from combined characters", () => {
expect(removeDiacritics("e\u0301")).toBe("e");
});
});
});

View File

@@ -8,10 +8,17 @@ import LoadingSpinner from "metabase/components/LoadingSpinner";
import type { InputProps } from "metabase/core/components/Input";
import Input from "metabase/core/components/Input";
import { useDebouncedValue } from "metabase/hooks/use-debounced-value";
import {
useSortByContentTranslation,
useTranslateContent,
} from "metabase/i18n/hooks";
import { delay } from "metabase/lib/delay";
import { Flex } from "metabase/ui";
import type { RowValue } from "metabase-types/api";
import { getOptionDisplayName } from "../ListField/types";
import { optionMatchesFilter } from "../ListField/utils";
import {
EmptyStateContainer,
FilterInputContainer,
@@ -20,14 +27,13 @@ import {
OptionsList,
} from "./SingleSelectListField.styled";
import type { Option, SingleSelectListFieldProps } from "./types";
import { isValidOptionItem } from "./utils";
const DEBOUNCE_FILTER_TIME = delay(100);
function createOptionsFromValuesWithoutOptions(
values: RowValue[],
options: Option[],
): Option {
): Option[] {
const optionsMap = _.indexBy(options, "0");
return values
.filter((value) => typeof value !== "string" || !optionsMap[value])
@@ -46,32 +52,32 @@ const SingleSelectListField = ({
checkedColor,
}: SingleSelectListFieldProps) => {
const [selectedValue, setSelectedValue] = useState(value?.[0]);
const [addedOptions, setAddedOptions] = useState<Option>(() =>
const [addedOptions, setAddedOptions] = useState<Option[]>(() =>
createOptionsFromValuesWithoutOptions(value, options),
);
const tc = useTranslateContent();
const sortByTranslation = useSortByContentTranslation();
const augmentedOptions = useMemo<Option[]>(() => {
return [...options.filter((option) => option[0] != null), ...addedOptions];
}, [addedOptions, options]);
const sortedOptions = useMemo(() => {
if (selectedValue) {
return augmentedOptions;
}
const [selected, unselected] = _.partition(
augmentedOptions,
(option) => selectedValue === option[0],
);
return [...selected, ...unselected];
const sortedOptions = useMemo(
() =>
augmentedOptions.toSorted((optionA, optionB) =>
sortByTranslation(
getOptionDisplayName(optionA),
getOptionDisplayName(optionB),
),
),
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [augmentedOptions.length]);
[augmentedOptions.length, sortByTranslation],
);
const [filter, setFilter] = useState("");
const debouncedFilter = useDebouncedValue(filter, DEBOUNCE_FILTER_TIME);
const isFilterInValues = value[0] === filter;
const isFilterInValues = tc(String(value[0])) === filter;
const filteredOptions = useMemo(() => {
const formattedFilter = debouncedFilter.trim().toLowerCase();
@@ -79,29 +85,17 @@ const SingleSelectListField = ({
return sortedOptions;
}
// Allow picking of different values in the list
// When the user selects a value, this populates the search field, but this
// should not filter the list. This way, the user can select a value, and
// then select a different value
if (isFilterInValues) {
return augmentedOptions;
return sortedOptions;
}
return augmentedOptions.filter((option) => {
if (!option || option.length === 0) {
return false;
}
// option as: [id, name]
if (
option.length > 1 &&
option[1] &&
isValidOptionItem(option[1], formattedFilter)
) {
return true;
}
// option as: [id]
return isValidOptionItem(option[0], formattedFilter);
});
}, [augmentedOptions, debouncedFilter, sortedOptions, isFilterInValues]);
return sortedOptions.filter((option) =>
optionMatchesFilter(option, formattedFilter, tc),
);
}, [debouncedFilter, sortedOptions, isFilterInValues, tc]);
const shouldShowEmptyState =
filter.length > 0 && !isLoading && filteredOptions.length === 0;
@@ -109,7 +103,9 @@ const SingleSelectListField = ({
const onClickOption = (option: any) => {
if (selectedValue !== option) {
setSelectedValue(option);
setFilter(String(option));
const maybeTranslatedOption =
typeof option === "string" ? tc(option) : String(option);
setFilter(maybeTranslatedOption);
onChange?.([option]);
}
};
@@ -170,7 +166,7 @@ const SingleSelectListField = ({
{!isLoading && (
<OptionsList isDashboardFilter={isDashboardFilter}>
{filteredOptions.map((option) => (
<OptionContainer key={option[0]}>
<OptionContainer key={String(option[0])}>
<OptionItem
data-testid={`${option[0]}-filter-value`}
selectedColor={

View File

@@ -1,6 +1,6 @@
import { render, screen } from "__support__/ui";
import ValueComponent from "../../Value";
import { Value as ValueComponent } from "../../Value";
import SingleSelectListField from "./index";

View File

@@ -1,12 +1,12 @@
import type { RowValue } from "metabase-types/api";
import type { FieldValue, RowValue } from "metabase-types/api";
export type Option = any[];
export type Option = FieldValue;
export interface SingleSelectListFieldProps {
onChange: (value: RowValue[]) => void;
value: RowValue[];
options: Option;
optionRenderer: (option: any) => JSX.Element;
options: Option[];
optionRenderer: (option: Option) => JSX.Element;
onSearchChange?: (value: string) => void;
placeholder: string;
isDashboardFilter?: boolean;

View File

@@ -1,3 +0,0 @@
export function isValidOptionItem(optionItem: any, filter: string): boolean {
return String(optionItem).toLowerCase().includes(filter);
}

View File

@@ -1,22 +0,0 @@
import { isValidOptionItem } from "./utils";
describe("ListField - utils", () => {
describe("isValidOptionItem", () => {
const TEST_CASES = [
["Marble Shoes", "marble", true],
[[6, "Marble Shoes"], "marble", true],
[[6, "Marble Shoes"], "6", true],
[[6], "6", true],
[[null, "Marble Shoes"], "marble", true],
[[6, null], "6", true],
[[6, "Marble Shoes"], "abcde", false],
];
TEST_CASES.map(([optionItem, filter, expectedResult]) => {
it(`includes "${filter}" in: ${optionItem}`, () => {
const result = isValidOptionItem(optionItem, String(filter));
expect(result).toEqual(expectedResult);
});
});
});
});

View File

@@ -2,7 +2,7 @@ import { renderNumberOfSelections } from "metabase/parameters/utils/formatting";
import type Field from "metabase-lib/v1/metadata/Field";
import type { CardId, DashboardId, Parameter } from "metabase-types/api";
import Value from "../Value";
import { Value } from "../Value";
import { normalizeValue } from "../normalizeValue";
type ParameterFieldWidgetValueProps = {

View File

@@ -1,23 +0,0 @@
/* eslint-disable react/prop-types */
import { isValidElement } from "react";
import { formatValue } from "metabase/lib/formatting";
import RemappedValue from "./RemappedValue";
const Value = ({ value, ...options }) => {
if (options.hide) {
return null;
}
if (options.remap) {
return <RemappedValue value={value} {...options} />;
}
const formatted = formatValue(value, { ...options, jsx: true });
if (isValidElement(formatted)) {
return formatted;
} else {
return <span>{formatted}</span>;
}
};
export default Value;

View File

@@ -0,0 +1,47 @@
import { isValidElement } from "react";
import { useTranslateContent } from "metabase/i18n/hooks";
import { formatValue } from "metabase/lib/formatting";
import type { OptionsType } from "metabase/lib/formatting/types";
import type { DashboardId, Parameter } from "metabase-types/api";
import RemappedValue from "./RemappedValue";
export const Value = ({
value: rawValue,
...rawOptions
}: {
value: unknown;
displayValue?: unknown;
hide?: boolean;
autoLoad?: boolean;
parameter?: Parameter;
cardId?: number;
dashboardId?: DashboardId;
} & OptionsType) => {
const tc = useTranslateContent<unknown>();
if (rawOptions.hide) {
return null;
}
const value = tc(rawValue);
const options = {
...rawOptions,
displayValue: tc(rawOptions.displayValue),
};
if (rawOptions.remap) {
return <RemappedValue value={value} {...options} />;
}
const formatted = formatValue(value, {
...options,
jsx: true,
});
if (isValidElement(formatted)) {
return formatted;
} else {
return <span>{formatted}</span>;
}
};

View File

@@ -1 +1 @@
export { default } from "./Value";
export { Value } from "./Value";

View File

@@ -1,8 +1,10 @@
import React, {
type ComponentType,
type Dispatch,
type HTMLAttributes,
type ReactNode,
type SetStateAction,
useCallback,
useMemo,
} from "react";
import { t } from "ttag";
@@ -35,6 +37,7 @@ import type { LinkProps } from "metabase/core/components/Link";
import type { DashCardMenuItem } from "metabase/dashboard/components/DashCard/DashCardMenu/DashCardMenu";
import type { EmbeddingEntityType } from "metabase/embedding-sdk/store";
import type { DataSourceSelectorProps } from "metabase/embedding-sdk/types/components/data-picker";
import type { ContentTranslationFunction } from "metabase/i18n/types";
import { getIconBase } from "metabase/lib/icon";
import type { MetabotContext } from "metabase/metabot";
import { SearchButton } from "metabase/nav/components/search/SearchButton";
@@ -43,6 +46,7 @@ import PluginPlaceholder from "metabase/plugins/components/PluginPlaceholder";
import type { SearchFilterComponent } from "metabase/search/types";
import { _FileUploadErrorModal } from "metabase/status/components/FileUploadStatusLarge/FileUploadErrorModal";
import type { IconName, IconProps, StackProps } from "metabase/ui";
import type { HoveredObject } from "metabase/visualizations/types";
import type * as Lib from "metabase-lib";
import type Question from "metabase-lib/v1/Question";
import type Database from "metabase-lib/v1/metadata/Database";
@@ -63,6 +67,7 @@ import type {
DashboardId,
Database as DatabaseType,
Dataset,
DatasetColumn,
DatasetError,
DatasetErrorType,
Group,
@@ -72,6 +77,7 @@ import type {
ParameterId,
Pulse,
Revision,
Series,
TableId,
Timeline,
TimelineEvent,
@@ -80,7 +86,7 @@ import type {
import type {
AdminPath,
AdminPathKey,
Dispatch,
Dispatch as ReduxDispatch,
State,
} from "metabase-types/store";
@@ -720,7 +726,7 @@ export const PLUGIN_METABOT = {
type DashCardMenuItemGetter = (
question: Question,
dashcardId: DashCardId | undefined,
dispatch: Dispatch,
dispatch: ReduxDispatch,
) => (DashCardMenuItem & { key: string }) | null;
export type PluginDashcardMenu = {
@@ -731,6 +737,32 @@ export const PLUGIN_DASHCARD_MENU: PluginDashcardMenu = {
dashcardMenuItemGetters: [],
};
export const PLUGIN_CONTENT_TRANSLATION = {
isEnabled: false,
ContentTranslationConfiguration: PluginPlaceholder,
useTranslateContent: <
T = string | null | undefined,
>(): ContentTranslationFunction => {
// In OSS, the input is not translated
return useCallback(<U = T>(arg: U) => arg, []);
},
translateDisplayNames: <T extends object>(
obj: T,
_tc: ContentTranslationFunction,
) => obj,
shouldTranslateFieldValuesOfColumn: (_col: DatasetColumn) => false,
translateFieldValuesInHoveredObject: (
obj: HoveredObject | null,
_tc?: ContentTranslationFunction,
) => obj,
translateFieldValuesInSeries: (
obj: Series,
_tc: ContentTranslationFunction,
) => obj,
useTranslateSeries: (obj: Series, _tc: ContentTranslationFunction) => obj,
contentTranslationDictionaryUrl: null as string | null,
};
export const PLUGIN_DB_ROUTING = {
DatabaseRoutingSection: PluginPlaceholder as ComponentType<{
database: DatabaseType;

View File

@@ -4,6 +4,8 @@ import type { Dispatch, SetStateAction } from "react";
import { LoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper";
import CS from "metabase/css/core/index.css";
import { useTranslateContent } from "metabase/i18n/hooks";
import { PLUGIN_CONTENT_TRANSLATION } from "metabase/plugins";
import { EmbedFrame } from "metabase/public/components/EmbedFrame";
import type { DisplayTheme } from "metabase/public/lib/types";
import { PublicOrEmbeddedQuestionDownloadPopover } from "metabase/query_builder/components/QuestionDownloadPopover/QuestionDownloadPopover";
@@ -77,6 +79,13 @@ export function PublicOrEmbeddedQuestionView({
/>
) : null;
const untranslatedRawSeries = [{ card, data: result?.data }] as RawSeries;
const tc = useTranslateContent();
const rawSeries = PLUGIN_CONTENT_TRANSLATION.useTranslateSeries(
untranslatedRawSeries,
tc,
);
return (
<EmbedFrame
name={card && card.name}
@@ -107,7 +116,7 @@ export function PublicOrEmbeddedQuestionView({
<Visualization
isNightMode={theme === "night"}
error={result?.error?.toString()}
rawSeries={[{ card, data: result?.data }] as RawSeries}
rawSeries={rawSeries}
className={cx(CS.full, CS.flexFull, CS.z1)}
onUpdateVisualizationSettings={(
settings: VisualizationSettings,

View File

@@ -228,6 +228,7 @@ const ViewInner = forwardRef(function _ViewInner(props, ref) {
showLeftSidebar={showLeftSidebar}
showRightSidebar={showRightSidebar}
{...props}
rawSeries={rawSeries}
/>
<ViewSidebar
side="right"

View File

@@ -2,7 +2,7 @@ import _ from "underscore";
import api, { DELETE, GET, POST, PUT } from "metabase/lib/api";
import { IS_EMBED_PREVIEW } from "metabase/lib/embed";
import { PLUGIN_API } from "metabase/plugins";
import { PLUGIN_API, PLUGIN_CONTENT_TRANSLATION } from "metabase/plugins";
import Question from "metabase-lib/v1/Question";
import { normalizeParameters } from "metabase-lib/v1/parameters/utils/parameter-values";
import { isNative } from "metabase-lib/v1/queries/utils/card";
@@ -352,11 +352,19 @@ export function setPublicDashboardEndpoints(uuid) {
}
export function setEmbedQuestionEndpoints(token) {
setCardEndpoints(`${embedBase}/card/${encodeURIComponent(token)}`);
const encodedToken = encodeURIComponent(token);
setCardEndpoints(`${embedBase}/card/${encodedToken}`);
setContentTranslationEndpoints(
`${embedBase}/content-translation/dictionary/${encodedToken}`,
);
}
export function setEmbedDashboardEndpoints(token) {
setDashboardEndpoints(`${embedBase}/dashboard/${encodeURIComponent(token)}`);
const encodedToken = encodeURIComponent(token);
setDashboardEndpoints(`${embedBase}/dashboard/${encodedToken}`);
setContentTranslationEndpoints(
`${embedBase}/content-translation/dictionary/${encodedToken}`,
);
}
function GET_with(url, omitKeys) {
@@ -395,6 +403,12 @@ function setDashboardEndpoints(prefix) {
);
}
function setContentTranslationEndpoints(url) {
if (PLUGIN_CONTENT_TRANSLATION.isEnabled) {
PLUGIN_CONTENT_TRANSLATION.contentTranslationDictionaryUrl = url;
}
}
export const ActionsApi = {
execute: POST("/api/action/:id/execute"),
prefetchValues: GET("/api/action/:id/execute"),

View File

@@ -40,9 +40,11 @@ import {
memoize,
useMemoizedCallback,
} from "metabase/hooks/use-memoized-callback";
import { useTranslateContent } from "metabase/i18n/hooks";
import { getScrollBarSize } from "metabase/lib/dom";
import { formatValue } from "metabase/lib/formatting";
import { useDispatch } from "metabase/lib/redux";
import { PLUGIN_CONTENT_TRANSLATION } from "metabase/plugins";
import EmbedFrameS from "metabase/public/components/EmbedFrame/EmbedFrame.module.css";
import { setUIControls } from "metabase/query_builder/actions";
import { Flex, type MantineTheme } from "metabase/ui";
@@ -169,6 +171,8 @@ export const TableInteractiveInner = forwardRef(function TableInteractiveInner(
const isDashcardViewTable = isDashboard && !isSettings;
const [sorting, setSorting] = useState<SortingState>([]);
const tc = useTranslateContent();
const { rows, cols } = data;
const getColumnSortDirection = useMemo(() => {
@@ -236,7 +240,13 @@ export const TableInteractiveInner = forwardRef(function TableInteractiveInner(
return memoize((value, rowIndex) => {
const clicked = getCellClickedObject(columnIndex, rowIndex);
return formatValue(value, {
const maybeTranslatedValue =
PLUGIN_CONTENT_TRANSLATION.shouldTranslateFieldValuesOfColumn(col)
? tc(value)
: value;
return formatValue(maybeTranslatedValue, {
...columnSettings,
type: "cell",
jsx: true,
@@ -245,7 +255,7 @@ export const TableInteractiveInner = forwardRef(function TableInteractiveInner(
});
});
});
}, [cols, settings, getCellClickedObject]);
}, [cols, settings, getCellClickedObject, tc]);
const handleBodyCellClick = useCallback(
(
@@ -463,9 +473,11 @@ export const TableInteractiveInner = forwardRef(function TableInteractiveInner(
sortDirection = getColumnSortDirection(columnIndex);
}
const translatedColumnName = tc(columnName);
const options: ColumnOptions<RowValues, RowValue> = {
id,
name: columnName,
name: translatedColumnName,
accessorFn: (row: RowValues) => row[columnIndex],
cellVariant,
getCellClassName: (value) =>
@@ -489,7 +501,7 @@ export const TableInteractiveInner = forwardRef(function TableInteractiveInner(
timezone={data.results_timezone}
question={question}
column={col}
name={columnName}
name={translatedColumnName}
align={align}
sort={sortDirection}
variant={headerVariant}
@@ -550,6 +562,7 @@ export const TableInteractiveInner = forwardRef(function TableInteractiveInner(
settings,
tableTheme,
isDashboard,
tc,
]);
const handleColumnResize = useCallback(

View File

@@ -18,9 +18,12 @@ import ExplicitSize from "metabase/components/ExplicitSize";
import CS from "metabase/css/core/index.css";
import DashboardS from "metabase/css/dashboard.module.css";
import type { CardSlownessStatus } from "metabase/dashboard/components/DashCard/types";
import { useTranslateContent } from "metabase/i18n/hooks";
import type { ContentTranslationFunction } from "metabase/i18n/types";
import { formatNumber } from "metabase/lib/formatting";
import { connect } from "metabase/lib/redux";
import { equals } from "metabase/lib/utils";
import { PLUGIN_CONTENT_TRANSLATION } from "metabase/plugins";
import {
getIsShowingRawTable,
getUiControls,
@@ -161,6 +164,7 @@ type VisualizationOwnProps = {
showWarnings?: boolean;
style?: CSSProperties;
timelineEvents?: TimelineEvent[];
tc?: ContentTranslationFunction;
uuid?: string;
token?: string;
onOpenChartSettings?: (data: {
@@ -607,6 +611,7 @@ class Visualization extends PureComponent<
style,
tableHeaderHeight,
timelineEvents,
tc,
token,
totalNumGridCols,
uuid,
@@ -626,7 +631,13 @@ class Visualization extends PureComponent<
const small = width < SMALL_CARD_WIDTH_THRESHOLD;
// these may be overridden below
let { series, hovered, clicked } = this.state;
const { series, hovered: untranslatedHoveredObject, clicked } = this.state;
let hovered =
PLUGIN_CONTENT_TRANSLATION.translateFieldValuesInHoveredObject(
untranslatedHoveredObject,
tc,
);
const clickActions = this.getClickActions(clicked);
const regularClickActions = clickActions.filter(isRegularClickAction);
@@ -917,7 +928,8 @@ export default _.compose(
)(
forwardRef<HTMLDivElement, VisualizationProps>(
function VisualizationForwardRef(props, ref) {
return <VisualizationMemoized {...props} forwardedRef={ref} />;
const tc = useTranslateContent();
return <VisualizationMemoized {...props} forwardedRef={ref} tc={tc} />;
},
),
) as ComponentType<VisualizationOwnProps>;

View File

@@ -5,7 +5,7 @@ import { forwardRef } from "react";
import { lighten } from "metabase/lib/colors";
import { Box, type BoxProps, Icon, type IconProps } from "metabase/ui";
import { LegendLabel as BaseLegendLabel } from "./LegendLabel";
import { LegendLabel as BaseLegendLabel } from "../LegendLabel";
export const LegendCaptionRoot = styled.div`
display: flex;

View File

@@ -5,11 +5,13 @@ import { Ellipsified } from "metabase/core/components/Ellipsified";
import Markdown from "metabase/core/components/Markdown";
import CS from "metabase/css/core/index.css";
import DashboardS from "metabase/css/dashboard.module.css";
import { useTranslateContent } from "metabase/i18n/hooks";
import EmbedFrameS from "metabase/public/components/EmbedFrame/EmbedFrame.module.css";
import type { IconProps } from "metabase/ui";
import { Icon, Menu, Tooltip } from "metabase/ui";
import LegendActions from "./LegendActions";
import LegendActions from "../LegendActions";
import {
LegendCaptionRoot,
LegendDescriptionIcon,
@@ -45,7 +47,7 @@ interface LegendCaptionProps {
export const LegendCaption = ({
className,
title,
title: untranslatedTitle,
description,
getHref,
icon,
@@ -77,6 +79,9 @@ export const LegendCaption = ({
}
}, [getHref]);
const tc = useTranslateContent();
const title = tc(untranslatedTitle);
const titleElement = (
<LegendLabel
className={cx(

View File

@@ -0,0 +1 @@
export { LegendCaption } from "./LegendCaption";

View File

@@ -0,0 +1,24 @@
import { screen, waitFor } from "@testing-library/react";
import { setupTranslateContentStringSpy } from "__support__/server-mocks/content-translation";
import { setup } from "./setup";
describe("LegendCaption (OSS)", () => {
const getContentTranslatorSpy = setupTranslateContentStringSpy();
it("should not attempt to translate any content", async () => {
setup({
title: "Hello World",
locale: "es",
});
await waitFor(() => {
expect(screen.getByTestId("legend-caption-title")).toHaveTextContent(
"Hello World",
);
});
expect(getContentTranslatorSpy()).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,31 @@
import { screen, waitFor } from "@testing-library/react";
import { setupTranslateContentStringSpy } from "__support__/server-mocks/content-translation";
import { type SetupOpts, setup as baseSetup } from "./setup";
const setup = (opts: SetupOpts) =>
baseSetup({
hasEnterprisePlugins: true,
tokenFeatures: { content_translation: false },
...opts,
});
describe("LegendCaption (EE without token)", () => {
const getContentTranslatorSpy = setupTranslateContentStringSpy();
it("should not try to translate any content", async () => {
setup({
title: "Hello World",
locale: "es",
});
await waitFor(() => {
expect(screen.getByTestId("legend-caption-title")).toHaveTextContent(
"Hello World",
);
});
expect(getContentTranslatorSpy()).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,26 @@
import { screen, waitFor } from "@testing-library/react";
import { setupTranslateContentStringSpy } from "__support__/server-mocks/content-translation";
import { setup } from "./setup";
describe("LegendCaption (EE with token)", () => {
const getContentTranslatorSpy = setupTranslateContentStringSpy();
it("should translate caption title", async () => {
setup({
title: "Hello World",
locale: "es",
hasEnterprisePlugins: true,
tokenFeatures: { content_translation: true },
});
await waitFor(() => {
expect(screen.getByTestId("legend-caption-title")).toHaveTextContent(
"translated_Hello World",
);
});
expect(getContentTranslatorSpy()).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,47 @@
import { setupEnterprisePlugins } from "__support__/enterprise";
import { mockSettings } from "__support__/settings";
import { renderWithProviders } from "__support__/ui";
import type { TokenFeatures } from "metabase-types/api";
import {
createMockTokenFeatures,
createMockUser,
} from "metabase-types/api/mocks";
import { createMockState } from "metabase-types/store/mocks";
import { LegendCaption } from "../LegendCaption";
export interface SetupOpts {
title: string;
description?: string;
locale?: string;
tokenFeatures?: Partial<TokenFeatures>;
hasEnterprisePlugins?: boolean;
}
export function setup({
title,
description,
locale,
tokenFeatures,
hasEnterprisePlugins = false,
}: SetupOpts) {
const storeInitialState = createMockState({
settings: tokenFeatures
? mockSettings({
"token-features": createMockTokenFeatures(tokenFeatures),
})
: undefined,
currentUser: locale ? createMockUser({ locale }) : undefined,
});
if (hasEnterprisePlugins) {
setupEnterprisePlugins();
}
return renderWithProviders(
<LegendCaption title={title} description={description} />,
{
storeInitialState,
},
);
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useMemo } from "react";
import type { DragEndEvent } from "metabase/core/components/Sortable";
import { useTranslateContent } from "metabase/i18n/hooks";
import { Box } from "metabase/ui";
import type {
DatasetColumn,
@@ -33,15 +34,16 @@ export const TableColumnPanel = ({
onChange,
onShowWidget,
}: TableColumnPanelProps) => {
const tc = useTranslateContent();
const columnItems = useMemo(() => {
return getColumnItems(columns, columnSettings);
}, [columns, columnSettings]);
return getColumnItems(columns, columnSettings, tc);
}, [columns, columnSettings, tc]);
const getItemName = useCallback(
(columnItem: ColumnItem) => {
return getColumnName(columnItem.column);
return tc(getColumnName(columnItem.column));
},
[getColumnName],
[getColumnName, tc],
);
const handleEnableColumn = useCallback(

View File

@@ -1,3 +1,6 @@
import _ from "underscore";
import type { ContentTranslationFunction } from "metabase/i18n/types";
import type { IconName } from "metabase/ui";
import { getIconForField } from "metabase-lib/v1/metadata/utils/fields";
import { getColumnKey } from "metabase-lib/v1/queries/utils/column-key";
@@ -14,6 +17,7 @@ import type { ColumnItem } from "./types";
export function getColumnItems(
columns: DatasetColumn[],
columnSettings: TableColumnOrderSetting[],
tc: ContentTranslationFunction = _.identity,
): ColumnItem[] {
const columnIndexes = findColumnIndexesForColumnSettings(
columns,
@@ -29,7 +33,7 @@ export function getColumnItems(
enabled: columnSetting.enabled,
index: columnSettingIndex,
icon: getIconForField(column) as IconName,
column,
column: { ...column, display_name: tc(column.display_name) },
columnSetting,
};
});

View File

@@ -5,7 +5,7 @@ import { Tooltip } from "metabase/ui";
import {
LegendDescriptionIcon,
LegendRightContent,
} from "metabase/visualizations/components/legend/LegendCaption.styled";
} from "metabase/visualizations/components/legend/LegendCaption/LegendCaption.styled";
import type { VisualizationSkeletonProps } from "metabase/visualizations/components/skeletons/VisualizationSkeleton/VisualizationSkeleton";
import {

View File

@@ -1,5 +1,6 @@
import { useCallback, useMemo } from "react";
import { useTranslateContent } from "metabase/i18n/hooks";
import { isReducedMotionPreferred } from "metabase/lib/dom";
import { extractRemappings } from "metabase/visualizations";
import { getChartMeasurements } from "metabase/visualizations/echarts/cartesian/chart-measurements";
@@ -36,6 +37,8 @@ export function useModelsAndOption(
}: VisualizationProps,
containerRef: React.RefObject<HTMLDivElement>,
) {
const tc = useTranslateContent();
const renderingContext = useBrowserRenderingContext({ fontFamily });
const seriesToRender = useMemo(
@@ -55,6 +58,13 @@ export function useModelsAndOption(
const chartModel = useMemo(() => {
let getModel;
settings["graph.x_axis.title_text"] = tc(
settings["graph.x_axis.title_text"],
);
settings["graph.y_axis.title_text"] = tc(
settings["graph.y_axis.title_text"],
);
getModel = getCartesianChartModel;
if (card.display === "waterfall") {
getModel = getWaterfallChartModel;
@@ -62,13 +72,18 @@ export function useModelsAndOption(
getModel = getScatterPlotModel;
}
return getModel(
const model = getModel(
seriesToRender,
settings,
Array.from(hiddenSeries),
renderingContext,
showWarning,
);
model.dimensionModel.column.display_name = tc(
model.dimensionModel.column.display_name,
);
return model;
}, [
card.display,
seriesToRender,
@@ -76,6 +91,7 @@ export function useModelsAndOption(
hiddenSeries,
renderingContext,
showWarning,
tc,
]);
const chartMeasurements = useMemo(

View File

@@ -4,8 +4,10 @@ import { t } from "ttag";
import { getParameterValues } from "metabase/dashboard/selectors";
import { useToggle } from "metabase/hooks/use-toggle";
import { useTranslateContent } from "metabase/i18n/hooks";
import { useSelector } from "metabase/lib/redux";
import { isEmpty } from "metabase/lib/validate";
import { TextInput } from "metabase/ui";
import { fillParametersInText } from "metabase/visualizations/shared/utils/parameter-substitution";
import type {
Dashboard,
@@ -17,7 +19,6 @@ import {
HeadingContainer,
HeadingContent,
InputContainer,
TextInput,
} from "./Heading.styled";
interface HeadingProps {
@@ -38,6 +39,8 @@ export function Heading({
const parameterValues = useSelector(getParameterValues);
const justAdded = useMemo(() => dashcard?.justAdded || false, [dashcard]);
const tc = useTranslateContent();
const [isFocused, { turnOn: toggleFocusOn, turnOff: toggleFocusOff }] =
useToggle(justAdded);
const isPreviewing = !isFocused;
@@ -46,6 +49,8 @@ export function Heading({
const preventDragging = (e: MouseEvent<HTMLInputElement>) =>
e.stopPropagation();
const translatedText = useMemo(() => tc(settings.text), [settings.text, tc]);
// handles a case when settings are updated externally
useEffect(() => {
setTextValue(settings.text);
@@ -57,9 +62,9 @@ export function Heading({
dashcard,
dashboard,
parameterValues,
text: settings.text,
text: translatedText,
}),
[dashcard, dashboard, parameterValues, settings.text],
[dashcard, dashboard, parameterValues, translatedText],
);
const hasContent = !isEmpty(settings.text);

View File

@@ -73,6 +73,10 @@ export function PieChart(props: VisualizationProps) {
() => getPieChartFormatters(chartModel, settings),
[chartModel, settings],
);
// const chartModelDimensionCol = chartModel.colDescs.dimensionDesc.column;
// chartModelDimensionCol.display_name = tc(chartModelDimensionCol.display_name);
const option = useMemo(
() => ({
...getPieChartOption(

View File

@@ -9,6 +9,7 @@ import { t } from "ttag";
import CS from "metabase/css/core/index.css";
import { getParameterValues } from "metabase/dashboard/selectors";
import { useToggle } from "metabase/hooks/use-toggle";
import { useTranslateContent } from "metabase/i18n/hooks";
import { useSelector } from "metabase/lib/redux";
import { isEmpty } from "metabase/lib/validate";
import { fillParametersInText } from "metabase/visualizations/shared/utils/parameter-substitution";
@@ -46,6 +47,9 @@ export function Text({
const justAdded = useMemo(() => dashcard?.justAdded || false, [dashcard]);
const [textValue, setTextValue] = useState(settings.text);
const tc = useTranslateContent();
const translatedText = useMemo(() => tc(settings.text), [settings.text, tc]);
const [isFocused, { turnOn: toggleFocusOn, turnOff: toggleFocusOff }] =
useToggle(justAdded);
const isPreviewing = !isFocused;
@@ -65,10 +69,10 @@ export function Text({
dashcard,
dashboard,
parameterValues,
text: settings.text,
text: translatedText,
escapeMarkdown: true,
}),
[dashcard, dashboard, parameterValues, settings.text],
[dashcard, dashboard, parameterValues, translatedText],
);
const hasContent = !isEmpty(settings.text);

View File

@@ -0,0 +1,45 @@
import fetchMock from "fetch-mock";
import * as EnterpriseContentTranslationUtilsModule from "metabase-enterprise/content_translation/utils";
export function setupContentTranslationEndpoints() {
fetchMock.post("path:/api/ee/content-translation/upload-dictionary", () => ({
success: true,
}));
}
/**
* One of the utility functions that makes the content translation feature tick
* is translateContentString. It takes a msgid and returns a msgstr.
* setupTranslateContentStringSpy mocks out this utility function. By default,
* it also mocks out the implementation, so that `translated_${msgid}` is
* returned. This makes unit testing easier.
*
* To check that no content translation was performed, use this spy to assert
* that the translateContentString utility function was not invoked
* */
export const setupTranslateContentStringSpy = (
mockImplementation: EnterpriseContentTranslationUtilsModule.TranslateContentStringFunction = (
...[_dictionary, _locale, msgid]
) => `translated_${msgid}`,
) => {
let translateContentStringSpy: jest.SpyInstance;
beforeEach(() => {
translateContentStringSpy = jest.spyOn(
EnterpriseContentTranslationUtilsModule,
"translateContentString",
);
if (mockImplementation) {
translateContentStringSpy.mockImplementation(mockImplementation);
}
});
afterEach(() => {
translateContentStringSpy?.mockClear();
translateContentStringSpy?.mockRestore();
});
return () => translateContentStringSpy;
};

View File

@@ -1,6 +1,6 @@
import fetchMock from "fetch-mock";
import { act, waitFor } from "./ui";
import { act, waitFor, type waitForOptions } from "./ui";
export const getNextId = (() => {
let id = 0;
@@ -53,3 +53,22 @@ export async function findRequests(method: "PUT" | "POST" | "DELETE" | "GET") {
return Promise.all(reqs);
}
/**
* Asserts that a Jest expect() assertion fails. This function is useful for
* ensuring that the page is NOT asynchronously updated. It ensures that the
* callback function repeatedly throws an error until the timeout elapses
*
* NOTE: To verify in a unit test that something does not occur, it may be more
* efficient to spy on a function and verify that it is not called.
*
* @see https://stackoverflow.com/questions/68118941/how-to-wait-for-something-not-to-happen-in-testing-library
* */
export const assertNeverPasses = async (
fn: () => void | Promise<void>,
expectedError: string | RegExp = /Timed out in waitFor/,
options: waitForOptions = {},
) => {
options.timeout ??= 200;
await expect(waitFor(fn, options)).rejects.toThrow(expectedError);
};

View File

@@ -11589,7 +11589,6 @@ databaseChangeLog:
nullable: true
unique: true
- changeSet:
id: v56.2025-05-19T16:46:48
author: edpaget
@@ -11780,6 +11779,47 @@ databaseChangeLog:
, now()
);
- changeSet:
id: v56.2025-05-30T15:00:00
author: rafpaf
comment: Create content translation table
preConditions:
- onFail: MARK_RAN
- not:
- tableExists:
tableName: content_translation
changes:
- createTable:
tableName: content_translation
remarks: Content translations
columns:
- column:
name: id
remarks: Unique ID
type: int
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: locale
remarks: Locale
type: varchar(5)
constraints:
nullable: false
- column:
name: msgid
remarks: The raw string
type: ${text.type}
constraints:
nullable: false
- column:
name: msgstr
remarks: The translation
type: ${text.type}
constraints:
nullable: false
# >>>>>>>>>> DO NOT ADD NEW MIGRATIONS BELOW THIS LINE! ADD THEM ABOVE <<<<<<<<<<
########################################################################################################################

View File

@@ -0,0 +1,170 @@
{
"$schema": "http://iglucentral.com/schemas/com.snowplowanalytics.self-desc/schema/jsonschema/1-0-0#",
"description": "Metabase instance",
"self": {
"vendor": "com.metabase",
"name": "instance",
"format": "jsonschema",
"version": "1-1-3"
},
"type": "object",
"properties": {
"id": {
"description": "Instance ID",
"type": "string",
"maxLength": 1024
},
"version": {
"description": "Instance version",
"type": "object",
"properties": {
"tag": {
"description": "Instance version tag",
"type": "string",
"maxLength": 1024
}
},
"required": [
"tag"
]
},
"created_at": {
"description": "The date the instance was created",
"type": [
"string",
"null"
],
"format": "date-time",
"maxLength": 1024
},
"token_features": {
"description": "Premium token features",
"type": [
"object",
"null"
],
"properties": {
"embedding": {
"description": "Should we hide the 'Powered by Metabase' attribution on the embedding pages?",
"type": "boolean"
},
"whitelabel": {
"description": "Should we allow full whitelabel embedding (reskinning the entire interface?)",
"type": "boolean"
},
"audit_app": {
"description": "Should we enable the Audit Logs interface in the Admin UI?",
"type": "boolean"
},
"sandboxes": {
"description": "Should we enable data sandboxes (row-level permissions)?",
"type": "boolean"
},
"email_allow_list": {
"description": "Should we enable restrict email domains for subscription recipients?",
"type": "boolean"
},
"email_restrict_recipients": {
"description": "Shoudl we enable restrict email subscription recipients?",
"type": "boolean"
},
"cache_granular_controls": {
"description": "Should we enable granular controls for cache TTL at the database, dashboard, and card level?",
"type": "boolean"
},
"dashboard_subscription_filters": {
"description": "Should we enable filters for dashboard subscriptions?",
"type": "boolean"
},
"config_text_file": {
"description": "Should we enable initialization on launch from a config file?",
"type": "boolean"
},
"sso": {
"description": "DEPRECATED: Should we enable advanced SSO features (SAML and JWT authentication; role and group mapping)?",
"type": "boolean"
},
"sso_jwt": {
"description": "Should we enable JWT-based authentication?",
"type": "boolean"
},
"sso_saml": {
"description": "Should we enable SAML-based authentication?",
"type": "boolean"
},
"sso_ldap": {
"description": "Should we enable advanced configuration for LDAP authentication?",
"type": "boolean"
},
"sso_google": {
"description": "Should we enable advanced configuration for Google Sign-In authentication?",
"type": "boolean"
},
"advanced_config": {
"description": "DEPRECATED: Should we enable knobs and levers for more complex orgs (granular caching controls, allow-lists email domains for notifications, more in the future)?",
"type": "boolean"
},
"content_management": {
"description": "DEPRECATED: Should we enable official Collections, Question verifications (and more in the future, like workflows, forking, etc.)?",
"type": "boolean"
},
"disable_password_login": {
"description": "Can we disable login by password?",
"type": "boolean"
},
"session_timeout_config": {
"description": "Should we enable configuring session timeouts?",
"type": "boolean"
},
"content_verification": {
"description": "Should we enable verified content, like verified questions and models (and more in the future, like actions)?",
"type": "boolean"
},
"content_translation": {
"description": "Should we enable translation of user-generated content?",
"type": "boolean"
},
"official_collections": {
"description": "Should we enable Official Collections?",
"type": "boolean"
},
"snippet_collections": {
"description": "Should we enable SQL snippet folders?",
"type": "boolean"
},
"advanced_permissions": {
"description": "Should we enable extra knobs around permissions (block access, and in the future, moderator roles, feature-level permissions, etc.)?",
"type": "boolean"
},
"hosting": {
"description": "Is the Metabase instance running in the cloud?",
"type": [
"boolean",
"null"
]
}
}
},
"application_database": {
"description": "Application database type",
"type": [
"string",
"null"
],
"maxLength": 1024
},
"application_database_version": {
"description": "Application database version",
"type": [
"string",
"null"
],
"maxLength": 1024
}
},
"required": [
"id",
"version"
],
"additionalProperties": true
}

View File

@@ -821,6 +821,9 @@
{:name :config-text-file
:available (premium-features/enable-config-text-file?)
:enabled (some? (get env/env :mb-config-file-path))}
{:name :content-translation
:available (premium-features/enable-content-translation?)
:enabled (premium-features/enable-content-translation?)}
{:name :content-verification
:available (premium-features/enable-content-verification?)
:enabled (t2/exists? :model/ModerationReview)}

View File

@@ -0,0 +1,12 @@
(ns metabase.content-translation.api
"Public API for content translation functionality."
(:require
[metabase.content-translation.models :as models]))
(defn get-translations
"List the translations stored in the content_translation table.
Optionally filter by locale if a locale parameter is provided."
([]
(models/get-translations))
([locale]
(models/get-translations locale)))

View File

@@ -0,0 +1,22 @@
(ns metabase.content-translation.models
"A model representing dictionary entries for translations."
(:require
[methodical.core :as methodical]
[toucan2.core :as t2]))
(doto :model/ContentTranslation
(derive :metabase/model)
(derive :hook/labelled?))
(methodical/defmethod t2/table-name :model/ContentTranslation [_model]
:content_translation)
(defn get-translations
"List the translations stored in the content_translation table.
Optionally filter by locale if a locale parameter is provided."
([]
(get-translations nil))
([locale]
(if locale
(t2/select :model/ContentTranslation :locale locale)
(t2/select :model/ContentTranslation))))

View File

@@ -17,6 +17,7 @@
(:require
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.content-translation.api :as ct]
[metabase.eid-translation.core :as eid-translation]
[metabase.embedding.api.common :as api.embed.common]
[metabase.embedding.jwt :as embedding.jwt]
@@ -27,6 +28,7 @@
[metabase.query-processor.schema :as qp.schema]
[metabase.tiles.api :as api.tiles]
[metabase.util :as u]
[metabase.util.i18n :refer [tru]]
[metabase.util.json :as json]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
@@ -352,3 +354,16 @@
lat-field (json/decode+kw lat-field)
lon-field (json/decode+kw lon-field)]
(api.tiles/process-tiles-query-for-dashcard dashboard-id dashcard-id card-id parameters zoom x y lat-field lon-field)))
;;; ------------------------------------------- Content translation --------------------------------------------
(api.macros/defendpoint :get "/content-translation/dictionary/:token"
"Fetch the content translation dictionary via a JSON Web Token signed with the `embedding-secret-key`."
[{:keys [token]} :- [:map
[:token string?]]
{:keys [locale]}]
;; this will error if bad
(unsign-and-translate-ids token)
(if locale
{:data (ct/get-translations locale)}
(throw (ex-info (str (tru "Locale is required.")) {:status-code 400}))))

Some files were not shown because too many files have changed in this diff Show More