Compare commits
57 Commits
python-sec
...
graphite-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc6fe81522 | ||
|
|
ec84fbce2b | ||
|
|
e82cdb5b27 | ||
|
|
bfe0fff3e2 | ||
|
|
4c3e2e72b6 | ||
|
|
14627933cd | ||
|
|
c1cffca1c2 | ||
|
|
272f8e74ce | ||
|
|
4515483b79 | ||
|
|
0ba0c81916 | ||
|
|
e76759c27d | ||
|
|
67330da9f3 | ||
|
|
537723449f | ||
|
|
7da5cf3ccb | ||
|
|
bf1f15bc69 | ||
|
|
9480ee33a6 | ||
|
|
e18432ab0f | ||
|
|
eda2c39c34 | ||
|
|
6019b4679b | ||
|
|
49d0167e0f | ||
|
|
b6b8a463b3 | ||
|
|
a5f2e79643 | ||
|
|
e81dff6f3b | ||
|
|
a999a268f4 | ||
|
|
517e88d65a | ||
|
|
f5ad4f431f | ||
|
|
6315580a4c | ||
|
|
0892c77823 | ||
|
|
72155a464e | ||
|
|
4755851132 | ||
|
|
cabc2c5ba9 | ||
|
|
485b961595 | ||
|
|
281bc441f9 | ||
|
|
53108d739c | ||
|
|
b9dc283cde | ||
|
|
2ddf13eeb8 | ||
|
|
f77abe486f | ||
|
|
2584ee62fb | ||
|
|
c5de0b8f0f | ||
|
|
2b6567a503 | ||
|
|
6223bd7672 | ||
|
|
c7ef7eda0f | ||
|
|
cf9fbe64ca | ||
|
|
a7274bb7e4 | ||
|
|
54a70d90a5 | ||
|
|
550ca1dd45 | ||
|
|
e56ea9ea28 | ||
|
|
83b1ae1be0 | ||
|
|
21c3c36219 | ||
|
|
a308a4127a | ||
|
|
b1755c066b | ||
|
|
cab5b75955 | ||
|
|
afe3f679ff | ||
|
|
ba1225b753 | ||
|
|
d0611a7bd7 | ||
|
|
20b65de4a3 | ||
|
|
4945e2c65d |
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))))))
|
||||
@@ -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))
|
||||
@@ -51,6 +51,7 @@
|
||||
"CollectionBookmark"
|
||||
"CollectionPermissionGraphRevision"
|
||||
"ConnectionImpersonation"
|
||||
"ContentTranslation"
|
||||
"DatabaseRouter"
|
||||
"DashboardBookmark"
|
||||
"DataPermissions"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
:model/CardBookmark
|
||||
:model/ChannelTemplate
|
||||
:model/CollectionBookmark
|
||||
:model/ContentTranslation
|
||||
:model/DashboardBookmark
|
||||
:model/DataPermissions
|
||||
:model/DatabaseRouter
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { ContentTranslationConfiguration } from "./ContentTranslationConfiguration";
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import "./sandboxes";
|
||||
import "./auth";
|
||||
import "./caching";
|
||||
import "./collections";
|
||||
import "./content_translation";
|
||||
import "./content_verification";
|
||||
import "./database_routing";
|
||||
import "./whitelabel";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
33
frontend/src/metabase-types/api/content-translation.ts
Normal file
33
frontend/src/metabase-types/api/content-translation.ts
Normal 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);
|
||||
};
|
||||
@@ -189,6 +189,7 @@ export type VirtualDashboardCard = BaseDashboardCard & {
|
||||
visualization_settings: BaseDashboardCard["visualization_settings"] & {
|
||||
virtual_card: VirtualCard;
|
||||
link?: LinkCardSettings;
|
||||
text?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -15,7 +15,7 @@ export const isVirtualCardDisplayType = (
|
||||
typeof value === "string" &&
|
||||
virtualCardDisplayTypes.includes(value as VirtualCardDisplay);
|
||||
|
||||
const cardDisplayTypes = [
|
||||
export const cardDisplayTypes = [
|
||||
"table",
|
||||
"bar",
|
||||
"line",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -37,6 +37,7 @@ export const FormSubmitButton = forwardRef(function FormSubmitButton(
|
||||
successLabel,
|
||||
failedLabel,
|
||||
});
|
||||
|
||||
const submitColor = getSubmitButtonColor(status, { color });
|
||||
|
||||
return (
|
||||
|
||||
27
frontend/src/metabase/i18n/hooks.ts
Normal file
27
frontend/src/metabase/i18n/hooks.ts
Normal 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],
|
||||
);
|
||||
};
|
||||
7
frontend/src/metabase/i18n/types.ts
Normal file
7
frontend/src/metabase/i18n/types.ts
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -20,7 +20,7 @@ type SetupOpts = {
|
||||
function setup({
|
||||
value = [],
|
||||
options = [],
|
||||
optionRenderer = ([value]) => value,
|
||||
optionRenderer = ([value]) => <>{value}</>,
|
||||
placeholder = "Search the list",
|
||||
checkedColor,
|
||||
isDashboardFilter,
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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;
|
||||
|
||||
@@ -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, "");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from "__support__/ui";
|
||||
|
||||
import ValueComponent from "../../Value";
|
||||
import { Value as ValueComponent } from "../../Value";
|
||||
|
||||
import SingleSelectListField from "./index";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function isValidOptionItem(optionItem: any, filter: string): boolean {
|
||||
return String(optionItem).toLowerCase().includes(filter);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
}
|
||||
};
|
||||
@@ -1 +1 @@
|
||||
export { default } from "./Value";
|
||||
export { Value } from "./Value";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -228,6 +228,7 @@ const ViewInner = forwardRef(function _ViewInner(props, ref) {
|
||||
showLeftSidebar={showLeftSidebar}
|
||||
showRightSidebar={showRightSidebar}
|
||||
{...props}
|
||||
rawSeries={rawSeries}
|
||||
/>
|
||||
<ViewSidebar
|
||||
side="right"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
@@ -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(
|
||||
@@ -0,0 +1 @@
|
||||
export { LegendCaption } from "./LegendCaption";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 <<<<<<<<<<
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
12
src/metabase/content_translation/api.clj
Normal file
12
src/metabase/content_translation/api.clj
Normal 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)))
|
||||
22
src/metabase/content_translation/models.clj
Normal file
22
src/metabase/content_translation/models.clj
Normal 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))))
|
||||
@@ -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
Reference in New Issue
Block a user