Compare commits

...

10 Commits

Author SHA1 Message Date
Raphael Krut-Landau
870e3dc29f improvements to fe content translation 2025-04-03 23:06:44 -04:00
Raphael Krut-Landau
3c97bd9c3c localize categorical strings in more places 2025-04-03 17:03:35 -04:00
Raphael Krut-Landau
b97b3e831a localized strings in query builder 2025-04-03 14:58:12 -04:00
Raphael Krut-Landau
ad3f72c58e wip: column names are localized in the summary sidebar 2025-04-03 13:38:42 -04:00
Raphael Krut-Landau
b65f416ad5 wip 2025-04-03 12:51:04 -04:00
Raphael Krut-Landau
67cff0b1bb wip: continue localizing column names 2025-04-03 09:58:23 -04:00
Raphael Krut-Landau
d5a478f5ed increase migration version number 2025-04-03 09:58:23 -04:00
Raphael Krut-Landau
7c18dc7299 wip 2025-04-03 09:58:23 -04:00
Raphael Krut-Landau
3846821b21 squash 2025-04-03 09:58:23 -04:00
Raphael Krut-Landau
6d9d90bb60 Prototype translation of dashboard name 2025-04-03 09:58:23 -04:00
97 changed files with 2677 additions and 701 deletions

View File

@@ -0,0 +1,12 @@
import { navigationSidebar } from "./e2e-ui-elements-helpers";
export function visitHomepage({ homeText = "Home" } = {}) {
cy.visit("/");
cy.wait("@sessionProperties");
// make sure page is loaded
cy.findByText("loading").should("not.exist");
navigationSidebar().findByText(homeText).should("exist");
}

View File

@@ -1,13 +1,13 @@
export * from "./api";
export * from "./e2e-action-helpers";
export * from "./e2e-api-key-helpers";
export * from "./e2e-ad-hoc-question-helpers";
export * from "./e2e-api-key-helpers";
export * from "./e2e-bi-basics-helpers";
export * from "./e2e-boolean-helpers";
export * from "./e2e-browser-helpers";
export * from "./e2e-cloud-helpers";
export * from "./e2e-collection-helpers";
export * from "./e2e-command-palette-helpers";
export * from "./e2e-browser-helpers";
export * from "./e2e-custom-column-helpers";
export * from "./e2e-dashboard-helpers";
export * from "./e2e-database-metadata-helpers";
@@ -18,6 +18,7 @@ export * from "./e2e-email-helpers";
export * from "./e2e-embedding-helpers";
export * from "./e2e-enterprise-helpers";
export * from "./e2e-filter-helpers";
export * from "./e2e-homepage-helpers";
export * from "./e2e-ldap-helpers";
export * from "./e2e-misc-helpers";
export * from "./e2e-mock-app-settings-helpers";

View File

@@ -0,0 +1,211 @@
import path from "path";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { ADMIN_USER_ID } from "e2e/support/cypress_sample_instance_data";
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
import { checkNotNull } from "metabase/lib/types";
const { PRODUCTS_ID } = SAMPLE_DATABASE;
const { H } = cy;
const sampleDictionary = `
Language,String,Translation
es,Accounts,Cuentas
es,Analytic Events,Eventos Analíticos
es,Customers,Clientes
es,Invoices,Facturas
es,Orders,Órdenes
es,People,Personas
es,Products,Productos
es,Reviews,Reseñas
es,Enormous Aluminum Shirt,Enorme Camisa de Aluminio
es,Vendor,Vendedor
es,Sample Database,Base de Datos de Ejemplo
es,Count of Products,Conteo de productos
es,Metric about Products,Métrica sobre productos
es,An interesting collection,Una colección interesante
fr,Accounts,Comptes
fr,Analytic Events,Événements Analytiques
fr,Customers,Clients
fr,Invoices,Factures
fr,Orders,Ordres
fr,People,Personnes
fr,Products,Produits
fr,Reviews,Avis
fr,Enormous Aluminum Shirt,Chemise en Aluminium Énorme
fr,Vendor,Vendeur
fr,Sample Database,Base de Données d'Exemple
fr,Count of Products,Nombre de produits
fr,Metric about Products,Métrique sur les produits
fr,An interesting collection,Une collection intéressante
`;
describe("scenarios > admin > localization > content translation", () => {
before(() => {
H.restore();
cy.signInAsAdmin();
cy.intercept("GET", "/api/session/properties").as("sessionProperties");
});
describe("should be able to upload a translation dictionary and see the following string translated", () => {
before(() => {
cy.visit("/admin/settings/localization");
cy.get("#content-translation-dictionary-upload-input").selectFile(
{
contents: Cypress.Buffer.from(sampleDictionary),
fileName: "content_translations.csv",
mimeType: "text/csv",
},
{ force: true },
);
cy.findByText(/Dictionary uploaded/g).should("be.visible");
cy.request("PUT", `/api/user/${ADMIN_USER_ID}`, { locale: "es" });
cy.log("Create a collection");
H.createCollection({
name: "An interesting collection",
alias: "collectionId",
});
cy.get("@collectionId").then(collectionId => {
cy.log("Create a model");
H.createQuestion(
{
name: "Count of products",
database: SAMPLE_DB_ID,
type: "model",
query: {
"source-table": PRODUCTS_ID,
aggregation: [["count"]],
},
collection_id: collectionId,
},
{ wrapId: true, idAlias: "modelId" },
);
cy.log("Create a metric");
H.createQuestion({
name: "Metric about products",
database: SAMPLE_DB_ID,
type: "metric",
query: {
"source-table": PRODUCTS_ID,
aggregation: [["count"]],
},
collection_id: this.collectionId,
});
});
});
describe("context: query builder", () => {
before(() => {
cy.visit(`/browse/databases/${SAMPLE_DB_ID}`);
cy.log("Can see translation of table name in Browse databases");
cy.findByText("Productos").click();
});
it("a database name in the question breadcrumb header", () => {
cy.findByTestId("head-crumbs-container")
.findByText("Base de Datos de Ejemplo")
.should("be.visible");
});
it("a table name in the question breadcrumb header", () => {
cy.findByTestId("head-crumbs-container")
.findByText("Productos")
.should("be.visible");
});
it("cell data", () => {
cy.findAllByTestId("cell-data").should(
"contain",
"Enorme Camisa de Aluminio",
);
});
});
describe("Context: Browse models", () => {
before(() => {
cy.visit("/browse/models");
});
it("model name", () => {
cy.findByText("Conteo de productos").should("be.visible");
});
it("collection name", () => {
cy.findByText("Una colección interesante").should("be.visible");
});
});
describe("Context: Browse metrics", () => {
before(() => {
cy.visit("/browse/metrics");
});
it("metric name", () => {
cy.findByText("Métrica sobre productos").should("be.visible");
});
it("collection name", () => {
cy.findByText("Una colección interesante").should("be.visible");
});
});
describe("Context: Home page", () => {
it.only("home page recents section", () => {
cy.get("@modelId").then(theModelId => {
H.visitModel(checkNotNull(theModelId as unknown as number));
H.visitHomepage({ homeText: "Inicio" });
cy.findByTestId("home-page")
.findByText("Conteo de productos")
.should("be.visible");
});
});
});
});
describe("should be able to download a translation dictionary", () => {
let fileContents = "";
before(() => {
cy.visit("/admin/settings/localization");
cy.button(/Download translation dictionary/g).click();
H.modal().within(() => {
cy.findByText(/Download translation dictionary/g);
cy.findByText("French").click();
cy.findByText("Spanish").click();
cy.button("Download").click();
});
const downloadsFolder = Cypress.config("downloadsFolder");
cy.readFile(
path.join(downloadsFolder, "content-translations-es,fr.csv"),
).then(contents => {
fileContents = contents;
});
});
it("with raw table names", () => {
expect(fileContents).to.include(",Products,");
});
it("with raw column names", () => {
expect(fileContents).to.include(",Vendor,");
//
});
it("with raw database names", () => {
expect(fileContents).to.include(",Sample Database,");
// TODO: Add more database names
expect(fileContents).to.include(",Sample Database,");
});
it("with raw strings from results", () => {
expect(fileContents).to.include(",Enormous Aluminum Shirt,");
});
it("with field values", () => {
// TODO: I'm not sure what the difference is between raw strings and field values
// field values
});
});
// TODO: Test that uploaded translations appear when re-downloading
});

View File

@@ -18,7 +18,8 @@ export const getItemName = (
item: CacheableDashboard | Question,
) =>
model === "dashboard"
? (item as CacheableDashboard).name
? // NOTE: We could localize the name later
(item as CacheableDashboard).name
: ((item as Question).displayName() ?? t`Untitled question`);
export const getItemUrl = (

View File

@@ -5,21 +5,22 @@ import { QuestionLoaderHOC } from "metabase/containers/QuestionLoader";
import { getParameterMappingOptions } from "metabase/parameters/utils/mapping-options";
import ParameterTargetWidget from "../components/ParameterTargetWidget";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
class QuestionParameterTargetWidget extends Component {
render() {
const { question, ...props } = this.props;
const mappingOptions = question
? getParameterMappingOptions(question, null, question.card())
: [];
return (
<ParameterTargetWidget
{...props}
question={question}
mappingOptions={mappingOptions}
/>
);
}
}
export const QuestionParameterTargetWidget = ({ question, ...props }) => {
const tc = useTranslateContent2();
const mappingOptions = question
? getParameterMappingOptions(question, null, question.card(), tc)
: [];
console.log("@m9216654", "mappingOptions", mappingOptions);
return (
<ParameterTargetWidget
{...props}
question={question}
mappingOptions={mappingOptions}
/>
);
};
export default QuestionLoaderHOC(QuestionParameterTargetWidget);

View File

@@ -1,8 +1,8 @@
import * as ML from "cljs/metabase.lib.js";
import { removeClause } from "./query";
import { TCFunc } from "metabase/i18n/components/ContentTranslationContext";
import type { BreakoutClause, ColumnMetadata, Query } from "./types";
export function breakoutableColumns(
query: Query,
stageIndex: number,
@@ -40,6 +40,15 @@ export function breakoutColumn(
query: Query,
stageIndex: number,
breakout: BreakoutClause,
tc?: TCFunc,
): ColumnMetadata {
return ML.breakout_column(query, stageIndex, breakout);
const col = ML.breakout_column(query, stageIndex, breakout);
if (tc) {
return {
...col,
displayName: tc(col.displayName),
longDisplayName: tc(col.longDisplayName),
};
}
return col;
}

View File

@@ -50,6 +50,7 @@ import type {
} from "./types";
import type Field from "./v1/metadata/Field";
import type Metadata from "./v1/metadata/Metadata";
import { TCFunc } from "metabase/i18n/components/ContentTranslationContext";
export function metadataProvider(
databaseId: DatabaseId | null,
@@ -69,105 +70,163 @@ declare function DisplayInfoFn(
query: Query,
stageIndex: number,
columnMetadata: ColumnMetadata,
tc?: TCFunc,
): ColumnDisplayInfo;
declare function DisplayInfoFn(
query: Query,
stageIndex: number,
columnGroup: ColumnGroup,
tc?: TCFunc,
): ColumnGroupDisplayInfo;
declare function DisplayInfoFn(
query: Query,
stageIndex: number,
cardMetadata: CardMetadata,
tc?: TCFunc,
): CardDisplayInfo;
declare function DisplayInfoFn(
query: Query,
stageIndex: number,
tableMetadata: TableMetadata,
tc?: TCFunc,
): TableDisplayInfo;
declare function DisplayInfoFn(
query: Query,
stageIndex: number,
tableLike: CardMetadata | TableMetadata,
tc?: TCFunc,
): CardDisplayInfo | TableDisplayInfo;
declare function DisplayInfoFn(
query: Query,
stageIndex: number,
aggregationClause: AggregationClause,
tc?: TCFunc,
): AggregationClauseDisplayInfo;
declare function DisplayInfoFn(
query: Query,
stageIndex: number,
aggregationOperator: AggregationOperator,
tc?: TCFunc,
): AggregationOperatorDisplayInfo;
declare function DisplayInfoFn(
query: Query,
stageIndex: number,
breakoutClause: BreakoutClause,
tc?: TCFunc,
): BreakoutClauseDisplayInfo;
declare function DisplayInfoFn(
query: Query,
stageIndex: number,
orderByClause: OrderByClause,
tc?: TCFunc,
): OrderByClauseDisplayInfo;
declare function DisplayInfoFn(
query: Query,
stageIndex: number,
clause: Clause,
tc?: TCFunc,
): ClauseDisplayInfo;
declare function DisplayInfoFn(
query: Query,
stageIndex: number,
bucket: Bucket,
tc?: TCFunc,
): BucketDisplayInfo;
declare function DisplayInfoFn(
query: Query,
stageIndex: number,
metric: MetricMetadata,
tc?: TCFunc,
): MetricDisplayInfo;
declare function DisplayInfoFn(
query: Query,
stageIndex: number,
joinStrategy: JoinStrategy,
tc?: TCFunc,
): JoinStrategyDisplayInfo;
declare function DisplayInfoFn(
query: Query,
stageIndex: number,
joinConditionOperator: JoinConditionOperator,
tc?: TCFunc,
): JoinConditionOperatorDisplayInfo;
declare function DisplayInfoFn(
query: Query,
stageIndex: number,
drillThru: DrillThru,
tc?: TCFunc,
): DrillThruDisplayInfo;
declare function DisplayInfoFn(
query: Query,
stageIndex: number,
filterOperator: FilterOperator,
tc?: TCFunc,
): FilterOperatorDisplayInfo;
declare function DisplayInfoFn(
query: Query,
stageIndex: number,
segment: SegmentMetadata,
tc?: TCFunc,
): SegmentDisplayInfo;
declare function DisplayInfoFn(
query: Query,
stageIndex: number,
extraction: ColumnExtraction,
tc?: TCFunc,
): ColumnExtractionInfo;
// x can be any sort of opaque object, e.g. a clause or metadata map. Values returned depend on what you pass in, but it
// should always have display_name... see :metabase.lib.metadata.calculation/display-info schema
export const displayInfo: typeof DisplayInfoFn = ML.display_info;
export const displayInfo: typeof DisplayInfoFn = (...args) => {
const info = ML.display_info(...args);
if (args.length === 4 && typeof args[3] === "function") {
const tc = args[3] as TCFunc;
return {
...info,
...(info.displayName
? {
displayName: tc(info.displayName),
}
: {}),
...(info.longDisplayName
? {
longDisplayName: tc(info.longDisplayName),
}
: {}),
};
}
return info;
};
export function groupColumns(columns: ColumnMetadata[]): ColumnGroup[] {
return ML.group_columns(columns);
}
const hasDisplayNames = (
arr: ColumnMetadata[],
): arr is ColumnMetadataWithDisplayName[] => {
return "displayName" in arr[0];
};
type ColumnMetadataWithDisplayName = ColumnMetadata & {
displayName: string;
};
// FIXME: translation may not be needed here. i'm not sure this has display names in it
export function getColumnsFromColumnGroup(
group: ColumnGroup,
tc?: TCFunc,
): ColumnMetadata[] {
return ML.columns_group_columns(group);
const columns: ColumnMetadata[] = ML.columns_group_columns(group);
if (tc && hasDisplayNames(columns)) {
return columns
.map((col) => ({
...col,
displayName: tc(col.displayName),
}))
.toSorted((a, b) => a.displayName.localeCompare(b.displayName));
}
return columns;
}
export function describeTemporalUnit(

View File

@@ -483,6 +483,7 @@ class Question {
}
dashboardName(): string | undefined {
// NOTE: We could localize the name later
return this._card?.dashboard?.name ?? undefined;
}

View File

@@ -45,7 +45,9 @@ export interface Dashboard {
collection?: Collection | null;
collection_id: CollectionId | null;
name: string;
name_localized?: string | null;
description: string | null;
description_localized?: string | null;
model?: string;
dashcards: DashboardCard[];
tabs?: DashboardTab[];
@@ -172,6 +174,7 @@ export type DashboardTab = {
dashboard_id: DashboardId;
entity_id?: BaseEntityId;
name: string;
name_localized?: string;
position?: number;
created_at?: string;
updated_at?: string;

View File

@@ -0,0 +1,229 @@
import {
type ChangeEvent,
type Dispatch,
type SetStateAction,
useMemo,
useRef,
useState,
useCallback,
} from "react";
import { t } from "ttag";
import _ from "underscore";
import { getLocales } from "metabase/account/profile/selectors";
import { useUploadContentTranslationDictionaryMutation } from "metabase/api/content-translation";
import { useLocale } from "metabase/common/hooks";
import { UploadInput } from "metabase/components/upload";
import { openSaveDialog } from "metabase/lib/dom";
import { useSelector } from "metabase/lib/redux";
import {
Button,
type ComboboxItem,
Group,
Icon,
Modal,
Stack,
Text,
} from "metabase/ui";
import { LocaleCheckboxes } from "./LocaleCheckboxes";
export const ContentTranslationConfiguration = () => {
const [uploadContentTranslationDictionary] =
useUploadContentTranslationDictionaryMutation();
// This is used to reset the input so that the same file can be uploaded
// multiple times
const [uploadInputReactKey, setUploadInputReactKey] = useState(0);
const [didFileUploadRecentlyFail, setDidFileUploadFail] =
useState<boolean>(false);
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.files?.length) {
const file = event.target.files[0];
try {
await uploadFile(file);
} catch (error) {
setDidFileUploadFail(true);
}
setUploadInputReactKey(n => n + 1);
}
};
const uploadFile = useCallback(
async (file: File) => {
if (!file) {
console.error("No file selected");
return;
}
await uploadContentTranslationDictionary({ file });
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setDidUploadRecentlySucceed(true);
timeoutRef.current = setTimeout(() => {
setDidUploadRecentlySucceed(false);
}, 5000);
},
[uploadContentTranslationDictionary],
);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerUpload = () => {
const input = inputRef.current;
if (!input) {
return;
}
input.style.display = "block";
input.click();
input.style.display = "none";
};
const [didUploadRecentlySucceed, setDidUploadRecentlySucceed] =
useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const [selectedLocales, setSelectedLocales] = useState<string[]>([]);
const availableLocalesAsTuples = useSelector(getLocales);
const triggerDownload = async () => {
const localesString = selectedLocales.toSorted().join(",");
const response = await fetch("/api/dictionary/csv", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ locales: localesString }),
});
if (!response.ok) {
throw new Error("Network response was not ok");
}
const blob = await response.blob();
const filename =
_.compact(["content-dictionary", localesString]).join("-") + ".csv";
openSaveDialog(filename, blob);
};
const uiLocale = useLocale();
const availableLocales: ComboboxItem[] = useMemo(
() =>
availableLocalesAsTuples
?.map(([value, label]) => ({
value,
label,
}))
.sort((a, b) => a.label.localeCompare(b.label, uiLocale)) || [],
[availableLocalesAsTuples, uiLocale],
);
const [isDownloadModalShown, setIsDownloadModalShown] = useState(false);
if (!availableLocales?.length) {
throw new Error("No locales found");
}
return (
<Stack gap="md">
<Text
lh={1.25}
>{t`Download a CSV with three columns: Language, String, Translation. Add translations to the CSV (as many as you like), then re-upload it here.`}</Text>
<Group gap="sm">
<Button
onClick={() => setIsDownloadModalShown(true)}
leftSection={<Icon name="download" />}
maw="20rem"
>
{t`Download translation dictionary...`}
</Button>
{isDownloadModalShown && (
<DownloadDictionaryModal
setSelectedLocales={setSelectedLocales}
selectedLocales={selectedLocales}
availableLocales={availableLocales}
triggerDownload={triggerDownload}
setIsDownloadModalShown={setIsDownloadModalShown}
/>
)}
<Button
leftSection={
didUploadRecentlySucceed ? (
<Icon name="check" />
) : (
<Icon name="upload" />
)
}
onClick={triggerUpload}
maw="20rem"
c={didUploadRecentlySucceed ? "white" : undefined}
bg={didUploadRecentlySucceed ? "success" : undefined}
>
{didUploadRecentlySucceed
? t`Dictionary uploaded`
: t`Upload translation dictionary`}
</Button>
</Group>
<UploadInput
id="content-translation-dictionary-upload-input"
style={{ display: "none" }}
ref={inputRef}
accept="text/csv"
onChange={handleFileChange}
key={uploadInputReactKey}
/>
{didFileUploadRecentlyFail && (
<Text role="alert" c="error">
{t`Error uploading file`}
</Text>
)}
</Stack>
);
};
const DownloadDictionaryModal = ({
setSelectedLocales,
selectedLocales,
availableLocales,
triggerDownload,
setIsDownloadModalShown,
}: {
setSelectedLocales: Dispatch<SetStateAction<string[]>>;
selectedLocales: string[];
availableLocales: ComboboxItem[];
triggerDownload: () => void;
setIsDownloadModalShown: Dispatch<SetStateAction<boolean>>;
}) => {
return (
<Modal
title={t`Download translation dictionary`}
opened
onClose={() => setIsDownloadModalShown(false)}
size="xl"
>
<Stack pt="md" gap="lg" h="32rem" pos="relative">
<Stack gap="xs">
<Text>{t`Create dictionary entries for these languages:`}</Text>
<LocaleCheckboxes
setSelectedLocales={setSelectedLocales}
selectedLocales={selectedLocales}
availableLocales={availableLocales}
style={{ overflowY: "auto" }}
h="25rem"
p="md"
bd="1px solid var(--mb-color-border)"
/>
</Stack>
<Button
onClick={triggerDownload}
leftSection={<Icon name="download" />}
maw="15rem"
bottom={0}
>{t`Download`}</Button>
</Stack>
</Modal>
);
};

View File

@@ -0,0 +1,43 @@
import type { Dispatch, SetStateAction } from "react";
import {
Checkbox,
type ComboboxItem,
Stack,
type StackProps,
} from "metabase/ui";
export const LocaleCheckboxes = ({
setSelectedLocales,
selectedLocales,
availableLocales,
...stackProps
}: {
setSelectedLocales: Dispatch<SetStateAction<string[]>>;
selectedLocales: string[];
availableLocales: ComboboxItem[];
} & StackProps) => {
return (
<Stack gap="md" {...stackProps}>
{availableLocales.map(locale => {
const checked = selectedLocales.includes(locale.value);
return (
<Checkbox
key={locale.value}
checked={checked}
onChange={() => {
setSelectedLocales(selectedLocales => {
if (checked) {
return selectedLocales.filter(l => l !== locale.value);
} else {
return [...selectedLocales, locale.value];
}
});
}}
label={locale.label}
/>
);
})}
</Stack>
);
};

View File

@@ -2,6 +2,7 @@ import { createSelector } from "@reduxjs/toolkit";
import { jt, t } from "ttag";
import _ from "underscore";
import ErrorBoundary from "metabase/ErrorBoundary";
import { SMTPConnectionForm } from "metabase/admin/settings/components/Email/SMTPConnectionForm";
import { UpsellWhitelabel } from "metabase/admin/upsells";
import { DashboardSelector } from "metabase/components/DashboardSelector";
@@ -23,6 +24,7 @@ import {
trackTrackingPermissionChanged,
} from "../analytics";
import { CloudPanel } from "../components/CloudPanel";
import { ContentTranslationConfiguration } from "../components/ContentTranslationConfiguration/ContentTranslationConfiguration";
import { BccToggleWidget } from "../components/Email/BccToggleWidget";
import { SettingsEmailForm } from "../components/Email/SettingsEmailForm";
import {
@@ -357,6 +359,16 @@ export const ADMIN_SETTINGS_SECTIONS = {
}
},
},
{
display_name: t`Content localization`,
description: "",
key: "content-localization",
widget: () => (
<ErrorBoundary>
<ContentTranslationConfiguration />
</ErrorBoundary>
),
},
{
key: "report-timezone",
display_name: t`Report Timezone`,
@@ -409,7 +421,6 @@ export const ADMIN_SETTINGS_SECTIONS = {
},
],
},
"public-sharing": {
name: t`Public Sharing`,
order: 90,

View File

@@ -0,0 +1,55 @@
import type { ContentTranslationDictionary } from "metabase/i18n/types";
import { Api } from "./api";
import { invalidateTags, listTag } from "./tags";
type ListContentTranslationsResponse = { data: ContentTranslationDictionary };
type ListContentTranslationsRequest = {
locale: string;
};
type UploadContentTranslationDictionaryRequest = {
file: File;
};
export const contentTranslationApi = Api.injectEndpoints({
endpoints: builder => {
return {
listContentTranslations: builder.query<
ListContentTranslationsResponse,
ListContentTranslationsRequest | void
>({
query: params => ({
method: "GET",
url: "/api/dictionary/",
params,
}),
// providesTags: () => listTag("content-translation"),
}),
uploadContentTranslationDictionary: builder.mutation<
ContentTranslationDictionary,
UploadContentTranslationDictionaryRequest
>({
query: ({ file }) => {
const formData = new FormData();
formData.append("file", file);
return {
method: "POST",
url: "/api/dictionary/upload",
body: { formData },
formData: true,
fetch: true,
};
},
invalidatesTags: (_, error) =>
invalidateTags(error, [listTag("content-translation")]),
}),
};
},
});
export const {
useListContentTranslationsQuery,
useUploadContentTranslationDictionaryMutation,
} = contentTranslationApi;

View File

@@ -4,6 +4,7 @@ export const TAG_TYPES = [
"action",
"alert",
"api-key",
"content-translation",
"bookmark",
"card",
"channel",

View File

@@ -2,6 +2,10 @@ import cx from "classnames";
import { Link } from "react-router";
import { t } from "ttag";
import {
ContentTranslationProvider,
useTranslateContent2,
} from "metabase/i18n/components/ContentTranslationContext";
import NoResults from "assets/img/no_results.svg";
import { useListDatabasesQuery } from "metabase/api";
import { LoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper";
@@ -33,12 +37,14 @@ import { BrowseDataHeader } from "../components/BrowseDataHeader";
import DB from "./BrowseDatabases.module.css";
import { trackAddDatabaseDBList } from "./analytics";
import { useTranslateContent } from "metabase/i18n/components/ContentTranslationContext";
export const BrowseDatabases = () => {
const isAdmin = useSelector(getUserIsAdmin);
const { data, isLoading, error } = useListDatabasesQuery();
const databases = data?.data;
const tc = useTranslateContent2();
if (error) {
return <LoadingAndErrorWrapper error />;
@@ -62,55 +68,64 @@ export const BrowseDatabases = () => {
}
return (
<BrowseContainer>
<BrowseDataHeader />
<BrowseMain>
<BrowseSection>
<SimpleGrid data-testid="database-browser" cols={3}>
{databases &&
databases.length > 0 &&
databases.map((database) => (
<Link to={Urls.browseDatabase(database)} key={database.id}>
<ContentTranslationProvider>
<BrowseContainer>
<BrowseDataHeader />
<BrowseMain>
<BrowseSection>
<SimpleGrid data-testid="database-browser" cols={3}>
{databases &&
databases.length > 0 &&
databases.map((database) => (
<Link to={Urls.browseDatabase(database)} key={database.id}>
<DatabaseCard
bg="bg-white"
className={cx(CS.rounded, CS.bordered, DB.dbCard)}
>
<Icon
name="database"
color={color("accent2")}
size={32}
/>
<Title order={2} size="md" lh={1.2} c="inherit">
{tc(database.name)}
</Title>
</DatabaseCard>
</Link>
))}
{isAdmin && (
<Link
to={newDatabase()}
onClick={() => trackAddDatabaseDBList()}
>
<DatabaseCard
bg="bg-white"
className={cx(CS.rounded, CS.bordered, DB.dbCard)}
className={cx(CS.rounded, CS.bordered, DB.addCard)}
>
<Icon name="database" color={color("accent2")} size={32} />
<Title order={2} size="md" lh={1.2} c="inherit">
{database.name}
</Title>
<Group gap="xs">
<CardImageWrapper database={"postgres"} />
<CardImageWrapper database={"mysql"} />
<CardImageWrapper database={"snowflake"} />
</Group>
<div>
<Title order={2} size="md" lh={1.2} c="inherit">
{t`Add a database`}
</Title>
<Text
color="inherit"
fz="sm"
lh={1}
span
>{t`20+ data connectors. Start exploring in minutes.`}</Text>
</div>
</DatabaseCard>
</Link>
))}
{isAdmin && (
<Link to={newDatabase()} onClick={() => trackAddDatabaseDBList()}>
<DatabaseCard
className={cx(CS.rounded, CS.bordered, DB.addCard)}
>
<Group gap="xs">
<CardImageWrapper database={"postgres"} />
<CardImageWrapper database={"mysql"} />
<CardImageWrapper database={"snowflake"} />
</Group>
<div>
<Title order={2} size="md" lh={1.2} c="inherit">
{t`Add a database`}
</Title>
<Text
color="inherit"
fz="sm"
lh={1}
span
>{t`20+ data connectors. Start exploring in minutes.`}</Text>
</div>
</DatabaseCard>
</Link>
)}
</SimpleGrid>
</BrowseSection>
</BrowseMain>
</BrowseContainer>
)}
</SimpleGrid>
</BrowseSection>
</BrowseMain>
</BrowseContainer>
</ContentTranslationProvider>
);
};

View File

@@ -34,6 +34,11 @@ import {
import { MetricsTable } from "./MetricsTable";
import type { MetricFilterSettings, MetricResult } from "./types";
import { mode } from "d3-array";
import { filters } from "dc";
import { ContentTranslationProvider } from "metabase/i18n/components/ContentTranslationContext";
import { data } from "metabase/static-viz/components/ComboChart/stories-data";
import url from "postcss-url";
const {
contentVerificationEnabled,
@@ -50,54 +55,56 @@ export function BrowseMetrics() {
const titleId = useMemo(() => _.uniqueId("browse-metrics"), []);
return (
<BrowseContainer aria-labelledby={titleId}>
<BrowseHeader role="heading" data-testid="browse-metrics-header">
<BrowseSection>
<Flex
w="100%"
h="2.25rem"
direction="row"
justify="space-between"
align="center"
>
<Title order={1} c="text-dark" id={titleId}>
<Group gap="sm">
<Icon
size={24}
color="var(--mb-color-icon-primary)"
name="metric"
<ContentTranslationProvider>
<BrowseContainer aria-labelledby={titleId}>
<BrowseHeader role="heading" data-testid="browse-metrics-header">
<BrowseSection>
<Flex
w="100%"
h="2.25rem"
direction="row"
justify="space-between"
align="center"
>
<Title order={1} c="text-dark" id={titleId}>
<Group gap="sm">
<Icon
size={24}
color="var(--mb-color-icon-primary)"
name="metric"
/>
{t`Metrics`}
</Group>
</Title>
{hasVerifiedMetrics && (
<MetricFilterControls
metricFilters={metricFilters}
setMetricFilters={setMetricFilters}
/>
{t`Metrics`}
</Group>
</Title>
{hasVerifiedMetrics && (
<MetricFilterControls
metricFilters={metricFilters}
setMetricFilters={setMetricFilters}
/>
)}
</Flex>
</BrowseSection>
</BrowseHeader>
<BrowseMain>
<BrowseSection>
<Stack mb="lg" gap="md" w="100%">
{isEmpty ? (
<MetricsEmptyState />
) : (
<DelayedLoadingAndErrorWrapper
error={error}
loading={isLoading}
style={{ flex: 1 }}
loader={<MetricsTable skeleton />}
>
<MetricsTable metrics={metrics} />
</DelayedLoadingAndErrorWrapper>
)}
</Stack>
</BrowseSection>
</BrowseMain>
</BrowseContainer>
)}
</Flex>
</BrowseSection>
</BrowseHeader>
<BrowseMain>
<BrowseSection>
<Stack mb="lg" gap="md" w="100%">
{isEmpty ? (
<MetricsEmptyState />
) : (
<DelayedLoadingAndErrorWrapper
error={error}
loading={isLoading}
style={{ flex: 1 }}
loader={<MetricsTable skeleton />}
>
<MetricsTable metrics={metrics} />
</DelayedLoadingAndErrorWrapper>
)}
</Stack>
</BrowseSection>
</BrowseMain>
</BrowseContainer>
</ContentTranslationProvider>
);
}

View File

@@ -31,6 +31,7 @@ import Collections from "metabase/entities/collections";
import Search from "metabase/entities/search";
import { useListSelect } from "metabase/hooks/use-list-select";
import { useToggle } from "metabase/hooks/use-toggle";
import { ContentTranslationProvider } from "metabase/i18n/components/ContentTranslationContext";
import { useDispatch } from "metabase/lib/redux";
import { addUndo } from "metabase/redux/undo";
import type Database from "metabase-lib/v1/metadata/Database";
@@ -50,6 +51,7 @@ import UploadOverlay from "../UploadOverlay";
import { CollectionMain, CollectionRoot } from "./CollectionContent.styled";
import { CollectionItemsTable } from "./CollectionItemsTable";
import { getComposedDragProps } from "./utils";
import { PLUGIN_COLLECTIONS } from "metabase/plugins";
const itemKeyFn = (item: CollectionItem) => `${item.id}:${item.model}`;
@@ -194,106 +196,113 @@ const CollectionContentViewInner = ({
const actionId = { id: collectionId };
return (
<CollectionRoot {...dropzoneProps}>
{canCreateUpload && (
<>
<ModelUploadModal
collectionId={collectionId}
opened={isModelUploadModalOpen}
onClose={closeModelUploadModal}
onUpload={handleUploadFile}
/>
<UploadOverlay isDragActive={isDragActive} collection={collection} />
</>
)}
<ContentTranslationProvider>
<CollectionRoot {...dropzoneProps}>
{canCreateUpload && (
<>
<ModelUploadModal
collectionId={collectionId}
opened={isModelUploadModalOpen}
onClose={closeModelUploadModal}
onUpload={handleUploadFile}
/>
<UploadOverlay
isDragActive={isDragActive}
collection={collection}
/>
</>
)}
{collection.archived && (
<ArchivedEntityBanner
name={collection.name}
entityType="collection"
canMove={collection.can_write}
canRestore={collection.can_restore}
canDelete={collection.can_delete}
onUnarchive={async () => {
const input = { ...actionId, name: collection.name };
await dispatch(Collections.actions.setArchived(input, false));
await dispatch(Bookmarks.actions.invalidateLists());
}}
onMove={({ id }) =>
dispatch(Collections.actions.setCollection(actionId, { id }))
}
onDeletePermanently={() =>
dispatch(deletePermanently(Collections.actions.delete(actionId)))
}
{collection.archived && (
<ArchivedEntityBanner
name={collection.name}
entityType="collection"
canMove={collection.can_write}
canRestore={collection.can_restore}
canDelete={collection.can_delete}
onUnarchive={async () => {
const input = { ...actionId, name: collection.name };
await dispatch(Collections.actions.setArchived(input, false));
await dispatch(Bookmarks.actions.invalidateLists());
}}
onMove={({ id }) =>
dispatch(Collections.actions.setCollection(actionId, { id }))
}
onDeletePermanently={() =>
dispatch(deletePermanently(Collections.actions.delete(actionId)))
}
/>
)}
<CollectionMain>
<ErrorBoundary>
<Header
collection={collection}
isAdmin={isAdmin}
isBookmarked={isBookmarked}
isPersonalCollectionChild={isPersonalCollectionChild(
collection,
collectionList,
)}
onCreateBookmark={handleCreateBookmark}
onDeleteBookmark={handleDeleteBookmark}
canUpload={canCreateUpload}
uploadsEnabled={uploadsEnabled}
saveFile={saveFile}
/>
</ErrorBoundary>
<ErrorBoundary>
<PLUGIN_COLLECTIONS.cleanUpAlert collection={collection} />
</ErrorBoundary>
<ErrorBoundary>
<PinnedItemOverview
databases={databases}
bookmarks={bookmarks}
createBookmark={createBookmark}
deleteBookmark={deleteBookmark}
items={pinnedItems}
collection={collection}
onMove={handleMove}
onCopy={handleCopy}
/>
</ErrorBoundary>
<ErrorBoundary>
<CollectionItemsTable
collectionId={collectionId}
collection={collection}
getIsSelected={getIsSelected}
selectOnlyTheseItems={selectOnlyTheseItems}
databases={databases}
bookmarks={bookmarks}
createBookmark={createBookmark}
deleteBookmark={deleteBookmark}
loadingPinnedItems={loading}
hasPinnedItems={hasPinnedItems}
selected={selected}
toggleItem={toggleItem}
clear={clear}
handleMove={handleMove}
handleCopy={handleCopy}
/>
<CollectionBulkActions
collection={collection}
selected={selected}
clearSelected={clear}
selectedItems={selectedItems}
setSelectedItems={setSelectedItems}
selectedAction={selectedAction}
setSelectedAction={setSelectedAction}
/>
</ErrorBoundary>
</CollectionMain>
<ItemsDragLayer
selectedItems={selected}
pinnedItems={pinnedItems}
collection={collection}
visibleColumnsMap={visibleColumnsMap}
/>
)}
<CollectionMain>
<ErrorBoundary>
<Header
collection={collection}
isAdmin={isAdmin}
isBookmarked={isBookmarked}
isPersonalCollectionChild={isPersonalCollectionChild(
collection,
collectionList,
)}
onCreateBookmark={handleCreateBookmark}
onDeleteBookmark={handleDeleteBookmark}
canUpload={canCreateUpload}
uploadsEnabled={uploadsEnabled}
saveFile={saveFile}
/>
</ErrorBoundary>
<ErrorBoundary>
<PinnedItemOverview
databases={databases}
bookmarks={bookmarks}
createBookmark={createBookmark}
deleteBookmark={deleteBookmark}
items={pinnedItems}
collection={collection}
onMove={handleMove}
onCopy={handleCopy}
/>
</ErrorBoundary>
<ErrorBoundary>
<CollectionItemsTable
collectionId={collectionId}
collection={collection}
getIsSelected={getIsSelected}
selectOnlyTheseItems={selectOnlyTheseItems}
databases={databases}
bookmarks={bookmarks}
createBookmark={createBookmark}
deleteBookmark={deleteBookmark}
loadingPinnedItems={loading}
hasPinnedItems={hasPinnedItems}
selected={selected}
toggleItem={toggleItem}
clear={clear}
handleMove={handleMove}
handleCopy={handleCopy}
/>
<CollectionBulkActions
collection={collection}
selected={selected}
clearSelected={clear}
selectedItems={selectedItems}
setSelectedItems={setSelectedItems}
selectedAction={selectedAction}
setSelectedAction={setSelectedAction}
/>
</ErrorBoundary>
</CollectionMain>
<ItemsDragLayer
selectedItems={selected}
pinnedItems={pinnedItems}
collection={collection}
visibleColumnsMap={visibleColumnsMap}
/>
</CollectionRoot>
</CollectionRoot>
</ContentTranslationProvider>
);
};

View File

@@ -6,6 +6,7 @@ import {
isInstanceAnalyticsCollection,
isRootTrashCollection,
} from "metabase/collections/utils";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
import { color } from "metabase/lib/colors";
import {
PLUGIN_COLLECTIONS,
@@ -47,13 +48,15 @@ export const CollectionCaption = ({
[collection, onUpdateCollection],
);
const tc = useTranslateContent2();
return (
<CaptionRoot data-testid="collection-caption">
<CaptionTitleContainer>
<CollectionCaptionIcon collection={collection} />
<CaptionTitle
key={collection.id}
initialValue={collection.name}
initialValue={tc(collection.name)}
placeholder={t`Add title`}
isDisabled={!isEditable}
data-testid="collection-name-heading"

View File

@@ -197,6 +197,7 @@ export const CollectionPickerInner = (
const newSelectedItem = {
id: currentDashboard.id,
model: "dashboard" as const,
// NOTE: We could localize the name later
name: currentDashboard.name,
};

View File

@@ -192,6 +192,7 @@ const DashboardPickerInner = (
newPath[newPath.length - 1].selectedItem = currentDashboard
? {
id: currentDashboard.id,
// NOTE: We could localize the name later
name: currentDashboard.name,
model: "dashboard",
}

View File

@@ -4,6 +4,7 @@ import { useAreAnyTruncated } from "metabase/hooks/use-is-truncated";
import { Tooltip } from "metabase/ui";
import S from "./EllipsifiedPath.module.css";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
type EllipsifiedPathProps = { items: string[]; tooltip: string };
@@ -17,6 +18,7 @@ type EllipsifiedPathProps = { items: string[]; tooltip: string };
* A tooltip is shown if any items are truncated.
*/
export const EllipsifiedPath = ({ items, tooltip }: EllipsifiedPathProps) => {
const tc = useTranslateContent2();
const { areAnyTruncated, ref } = useAreAnyTruncated<HTMLDivElement>();
return (
@@ -35,7 +37,7 @@ export const EllipsifiedPath = ({ items, tooltip }: EllipsifiedPathProps) => {
ref={(el) => el && ref.current.set(key, el)}
className={S.item}
>
{item}
{tc(item)}
</div>
{index < items.length - 1 && <div className={S.slash}>/</div>}
</React.Fragment>

View File

@@ -14,6 +14,7 @@ import * as Lib from "metabase-lib";
import { BucketPickerPopover } from "./BucketPickerPopover";
import S from "./QueryColumnPicker.module.css";
import { StyledAccordionList } from "./QueryColumnPicker.styled";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
export type ColumnListItem = Lib.ColumnDisplayInfo & {
column: Lib.ColumnMetadata;
@@ -65,15 +66,18 @@ export function QueryColumnPicker({
alwaysExpanded,
disableSearch,
}: QueryColumnPickerProps) {
const tc = useTranslateContent2();
const sections: Sections[] = useMemo(
() =>
columnGroups.map((group) => {
const groupInfo = Lib.displayInfo(query, stageIndex, group);
const items = Lib.getColumnsFromColumnGroup(group).map((column) => ({
...Lib.displayInfo(query, stageIndex, column),
column,
}));
const items = Lib.getColumnsFromColumnGroup(group, tc)
.map((column) => ({
...Lib.displayInfo(query, stageIndex, column, tc),
column,
}))
.toSorted((a, b) => a.displayName.localeCompare(b.displayName));
return {
name: groupInfo.displayName,
@@ -81,7 +85,7 @@ export function QueryColumnPicker({
items,
};
}),
[query, stageIndex, columnGroups],
[query, stageIndex, columnGroups, tc],
);
const handleSelect = useCallback(
@@ -190,6 +194,7 @@ export function QueryColumnPicker({
),
[query, stageIndex],
);
console.log("@m92076vs", "sections", sections);
return (
<DelayGroup>

View File

@@ -32,7 +32,10 @@ export const DashboardSelector = ({
return (
<Flex>
<DashboardPickerButton onClick={() => setIsOpen(true)}>
{dashboard?.name || t`Select a dashboard`}
{
// NOTE: Could be localized
dashboard?.name || t`Select a dashboard`
}
</DashboardPickerButton>
{isOpen && (
<DashboardPickerModal

View File

@@ -38,6 +38,11 @@ import {
EntityItemWrapper,
EntityMenuContainer,
} from "./EntityItem.styled";
import {
useTranslateContent,
useTranslateContent2,
} from "metabase/i18n/components/ContentTranslationContext";
import { translateString } from "metabase/i18n/utils";
type EntityIconCheckBoxProps = {
variant?: string;
@@ -105,6 +110,7 @@ function EntityItemName({
name: string;
variant?: string;
} & React.HTMLAttributes<HTMLHeadingElement>) {
const tc = useTranslateContent2();
return (
<h3
className={cx(CS.overflowHidden, {
@@ -112,7 +118,7 @@ function EntityItemName({
})}
{...props}
>
<Ellipsified>{name}</Ellipsified>
<Ellipsified>{tc(name)}</Ellipsified>
</h3>
);
}
@@ -310,6 +316,7 @@ const EntityItem = ({
disabled?: boolean;
}) => {
const icon = useMemo(() => ({ name: iconName }), [iconName]);
const tc = useTranslateContent2();
return (
<EntityItemWrapper
@@ -330,7 +337,7 @@ const EntityItem = ({
/>
<div className={CS.overflowHidden}>
<EntityItemName name={name} />
<EntityItemName name={tc(name)} />
<div>{extraInfo && extraInfo}</div>
</div>

View File

@@ -55,6 +55,7 @@ import {
shouldList,
showRemapping,
} from "./utils";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
const MAX_SEARCH_RESULTS = 100;
@@ -142,6 +143,7 @@ export const FieldValuesWidgetInner = forwardRef<
},
ref,
) {
const tc = useTranslateContent2();
const [options, setOptions] = useState<FieldValue[]>([]);
const [loadingState, setLoadingState] = useState<LoadingStateType>("INIT");
const [lastValue, setLastValue] = useState<string>("");
@@ -240,6 +242,7 @@ export const FieldValuesWidgetInner = forwardRef<
entityId: field.getUniqueId(),
}),
);
console.log("@m925uhk0", "fieldValues", fieldValues);
return dedupeValues(fieldValues);
} else {
@@ -374,7 +377,7 @@ export const FieldValuesWidgetInner = forwardRef<
value,
autoLoad: true,
compact: false,
displayValue: option?.[1],
displayValue: tc(option?.[1]),
});
};
}
@@ -386,7 +389,7 @@ export const FieldValuesWidgetInner = forwardRef<
formatOptions,
value: option[0],
autoLoad: false,
displayValue: option[1],
displayValue: tc(option[1]),
});
}

View File

@@ -8,12 +8,13 @@ import EntityItem from "metabase/components/EntityItem";
import CheckBox from "metabase/core/components/CheckBox";
import { Ellipsified } from "metabase/core/components/Ellipsified";
import Markdown from "metabase/core/components/Markdown";
import Tooltip from "metabase/core/components/Tooltip";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
import { useSelector } from "metabase/lib/redux";
import { getUserName } from "metabase/lib/user";
import { PLUGIN_MODERATION } from "metabase/plugins";
import { getIsEmbeddingSdk } from "metabase/selectors/embed";
import type { IconProps } from "metabase/ui";
import { Tooltip } from "metabase/ui";
import type { CollectionItem, SearchResult } from "metabase-types/api";
import type { SortableColumnHeaderProps } from "./BaseItemsTable";
@@ -158,10 +159,11 @@ export const Columns = {
includeDescription?: boolean;
onClick?: (item: CollectionItem) => void;
}) => {
const tc = useTranslateContent2();
return (
<ItemNameCell data-testid={`${testIdPrefix}-name`}>
<ItemLinkComponent onClick={onClick} item={item}>
<EntityItem.Name name={item.name} variant="list" />
<EntityItem.Name name={tc(item.name)} variant="list" />
<PLUGIN_MODERATION.ModerationStatusIcon
size={16}
status={item.moderated_status}

View File

@@ -1,32 +1,25 @@
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import { type ChangeEventHandler, forwardRef } from "react";
import { forwardRef } from "react";
import type { InputProps } from "metabase/core/components/Input";
import { DEFAULT_UPLOAD_INPUT_ID } from "./constants";
const StyledUploadInput = styled.input`
display: none;
`;
interface IUploadInputProps {
id?: string;
onChange: ChangeEventHandler<HTMLInputElement>;
}
export const UploadInput = forwardRef<HTMLInputElement, IUploadInputProps>(
function UploadInputRef(
{ id = DEFAULT_UPLOAD_INPUT_ID, onChange }: IUploadInputProps,
ref,
) {
return (
<StyledUploadInput
data-testid={id}
id={id}
ref={ref}
type="file"
accept="text/csv,text/tab-separated-values"
onChange={onChange}
/>
);
},
);
export const UploadInput = forwardRef<
HTMLInputElement,
Omit<InputProps, "size">
>(function UploadInput(inputProps, ref) {
return (
<input
data-testid={inputProps.id}
ref={ref}
type="file"
style={{ display: "none" }}
{...{
id: DEFAULT_UPLOAD_INPUT_ID,
accept: "text/csv,text/tab-separated-values",
...inputProps,
}}
/>
);
});

View File

@@ -11,6 +11,7 @@ import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
import { usePrevious } from "react-use";
import Markdown from "metabase/core/components/Markdown";
import { MaybeTranslationCannotBeEditedHoverCard } from "metabase/i18n/MaybeTranslationCannotBeEditedHoverCard";
import { Box } from "metabase/ui";
import { EditableTextArea, EditableTextRoot } from "./EditableText.styled";
@@ -22,6 +23,7 @@ export type EditableTextAttributes = Omit<
export interface EditableTextProps extends EditableTextAttributes {
initialValue?: string | null;
isLocalized?: boolean;
placeholder?: string;
maxLength?: number;
isEditing?: boolean;
@@ -38,6 +40,7 @@ export interface EditableTextProps extends EditableTextAttributes {
const EditableText = forwardRef(function EditableText(
{
initialValue,
isLocalized = false,
placeholder,
maxLength,
isEditing = false,
@@ -56,7 +59,8 @@ const EditableText = forwardRef(function EditableText(
const [inputValue, setInputValue] = useState(initialValue ?? "");
const [submitValue, setSubmitValue] = useState(initialValue ?? "");
const [isInFocus, setIsInFocus] = useState(isEditing);
const displayValue = inputValue ? inputValue : placeholder;
const displayValue = initialValue || placeholder || "";
const submitOnBlur = useRef(true);
const inputRef = useRef<HTMLTextAreaElement>(null);
const previousInitialValue = usePrevious(initialValue);
@@ -124,51 +128,65 @@ const EditableText = forwardRef(function EditableText(
};
const shouldShowMarkdown = isMarkdown && !isInFocus && inputValue;
// NOTE: Idea: have the EditableText show a warning when editing a localized
// value, "You are about to edit the untranslated string. To edit the
// translated string, contact your administrator."
// For now, disable editing of localized values
if (isLocalized) {
isDisabled = true;
}
return (
<Box
component={EditableTextRoot}
onClick={isMarkdown ? handleRootElementClick : undefined}
{...props}
ref={ref}
isEditing={isEditing}
isDisabled={isDisabled}
isEditingMarkdown={!shouldShowMarkdown}
data-value={`${displayValue}\u00A0`}
data-testid="editable-text"
tabIndex={0}
// For a11y, allow typing to activate the textarea
onKeyDown={(e: React.KeyboardEvent) => {
if (shouldPassKeyToTextarea(e.key)) {
(e.currentTarget as HTMLTextAreaElement).click();
}
}}
onKeyUp={(e: React.KeyboardEvent) => {
if (!shouldPassKeyToTextarea(e.key)) {
(e.currentTarget as HTMLTextAreaElement).click();
}
}}
lh={1.57}
>
{shouldShowMarkdown ? (
<Markdown>{inputValue}</Markdown>
) : (
<EditableTextArea
ref={inputRef}
value={inputValue}
placeholder={placeholder}
maxLength={maxLength}
disabled={isDisabled}
data-testid={dataTestId}
onFocus={onFocus}
onBlur={handleBlur}
onChange={handleChange}
onKeyDown={handleKeyDown}
// This is used to stop sidesheets from closing when escape is pressed
data-mantine-stop-propagation
/>
)}
</Box>
<MaybeTranslationCannotBeEditedHoverCard isLocalized={isLocalized}>
<Box
component={EditableTextRoot}
onClick={isMarkdown ? handleRootElementClick : undefined}
{...props}
ref={ref}
isEditing={isEditing}
isDisabled={isDisabled}
isEditingMarkdown={!shouldShowMarkdown}
data-value={`${displayValue}\u00A0`}
data-testid="editable-text"
tabIndex={0}
// For a11y, allow typing to activate the textarea
onKeyDown={(e: React.KeyboardEvent) => {
if (shouldPassKeyToTextarea(e.key)) {
(e.currentTarget as HTMLTextAreaElement).click();
}
}}
onKeyUp={(e: React.KeyboardEvent) => {
if (!shouldPassKeyToTextarea(e.key)) {
(e.currentTarget as HTMLTextAreaElement).click();
}
}}
lh={1.57}
>
{shouldShowMarkdown ? (
<Markdown>{inputValue}</Markdown>
) : (
<EditableTextArea
ref={inputRef}
value={
inputRef.current === document.activeElement
? inputValue
: displayValue
}
placeholder={placeholder}
maxLength={maxLength}
disabled={isDisabled}
data-testid={dataTestId}
onFocus={onFocus}
onBlur={handleBlur}
onChange={handleChange}
onKeyDown={handleKeyDown}
// This is used to stop sidesheets from closing when escape is pressed
data-mantine-stop-propagation
/>
)}
</Box>
</MaybeTranslationCannotBeEditedHoverCard>
);
});

View File

@@ -15,22 +15,39 @@ export const SET_ARCHIVED_DASHBOARD =
"metabase/dashboard/SET_ARCHIVED_DASHBOARD";
export const setArchivedDashboard = createThunkAction(
SET_ARCHIVED_DASHBOARD,
function (archived = true, undoing = false) {
function (
archived = true,
undoing = false,
{
name,
}: {
/** We can pass in a localized name */
name?: string;
},
) {
return async function (dispatch, getState) {
const { dashboardId, dashboards, dashcards } = getState().dashboard;
const dashboard = dashboardId
? dashboards[dashboardId]
: { name: "Dashboard" };
: {
name:
// FIXME: This should be localized, right?
t`Dashboard`,
name_localized: undefined,
};
await dispatch(
Dashboards.actions.update({ id: dashboardId }, { archived }),
);
name ??= dashboard.name;
if (!undoing) {
dispatch(
addUndo({
message: getTrashUndoMessage(dashboard.name, archived),
action: () => dispatch(setArchivedDashboard(!archived, true)),
message: getTrashUndoMessage(name, archived),
action: () =>
dispatch(setArchivedDashboard(!archived, true, { name })),
}),
);
}

View File

@@ -29,6 +29,7 @@ import type { State } from "metabase-types/store";
import { DashCardCardParameterMapperContent } from "./DashCardCardParameterMapperContent";
import S from "./DashCardParameterMapper.module.css";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
const mapStateToProps = (
state: State,
@@ -71,14 +72,22 @@ export function DashCardCardParameterMapper({
mappingOptions,
isRecentlyAutoConnected,
}: DashcardCardParameterMapperProps) {
const tc = useTranslateContent2();
const isQuestion = isQuestionDashCard(dashcard);
const hasSeries = isQuestion && dashcard.series && dashcard.series.length > 0;
const isAction = isActionDashCard(dashcard);
const isDisabled = mappingOptions.length === 0 || isAction;
const isNative = isQuestion && isNativeDashCard(dashcard);
const translatedMappingOptions = mappingOptions
.map((opt) => ({
...opt,
name: tc(opt.name),
}))
.toSorted((a, b) => a.name.localeCompare(b.name));
const selectedMappingOption = getMappingOptionByTarget(
mappingOptions,
translatedMappingOptions,
target,
question,
editingParameter ?? undefined,
@@ -112,7 +121,7 @@ export function DashCardCardParameterMapper({
dashcard={dashcard}
question={question}
editingParameter={editingParameter}
mappingOptions={mappingOptions}
mappingOptions={translatedMappingOptions}
isQuestion={isQuestion}
card={card}
selectedMappingOption={selectedMappingOption}

View File

@@ -6,7 +6,7 @@ import TippyPopover from "metabase/components/Popover/TippyPopover";
import Button from "metabase/core/components/Button";
import { Ellipsified } from "metabase/core/components/Ellipsified";
import DeprecatedTooltip from "metabase/core/components/Tooltip";
import ParameterTargetList from "metabase/parameters/components/ParameterTargetList";
import { ParameterTargetList } from "metabase/parameters/components/ParameterTargetList";
import type { ParameterMappingOption } from "metabase/parameters/utils/mapping-options";
import { Box, Flex, Icon } from "metabase/ui";
import * as Lib from "metabase-lib";

View File

@@ -23,6 +23,11 @@ import type {
} from "metabase/dashboard/types";
import Bookmarks from "metabase/entities/bookmarks";
import Dashboards from "metabase/entities/dashboards";
import {
ContentTranslationProvider,
useTranslateContent,
useTranslateContent2,
} from "metabase/i18n/components/ContentTranslationContext";
import { useDispatch } from "metabase/lib/redux";
import { FullWidthContainer } from "metabase/styled-components/layout/FullWidthContainer";
import { Box, Flex } from "metabase/ui";
@@ -203,6 +208,8 @@ function Dashboard(props: DashboardProps) {
const dispatch = useDispatch();
const tc = useTranslateContent2();
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState<unknown>(null);
@@ -227,7 +234,8 @@ function Dashboard(props: DashboardProps) {
const canRestore = Boolean(dashboard?.can_restore);
const canDelete = Boolean(dashboard?.can_delete);
const tabHasCards = currentTabDashcards.length > 0;
const dashboardHasCards = dashboard && dashboard.dashcards.length > 0;
const dashboardHasCards =
dashboard && dashboard.dashcards && dashboard.dashcards.length > 0;
const shouldRenderAsNightMode = isNightMode && isFullscreen;
@@ -383,165 +391,173 @@ function Dashboard(props: DashboardProps) {
};
return (
<DashboardLoadingAndErrorWrapper
isFullHeight={isEditing || isSharing}
isFullscreen={isFullscreen}
isNightMode={shouldRenderAsNightMode}
loading={!dashboard}
error={error}
noWrapper={noLoaderWrapper}
>
{() => {
if (!dashboard) {
return null;
}
<ContentTranslationProvider>
<DashboardLoadingAndErrorWrapper
isFullHeight={isEditing || isSharing}
isFullscreen={isFullscreen}
isNightMode={shouldRenderAsNightMode}
loading={!dashboard}
error={error}
noWrapper={noLoaderWrapper}
>
{() => {
if (!dashboard) {
return null;
}
const isEmpty =
!dashboardHasCards || (dashboardHasCards && !tabHasCards);
const isEmpty =
!dashboardHasCards || (dashboardHasCards && !tabHasCards);
return (
<Flex direction="column" mih="100%" w="100%" flex="1 0 auto">
{dashboard.archived && (
<ArchivedEntityBanner
name={dashboard.name}
entityType="dashboard"
canMove={canWrite}
canRestore={canRestore}
canDelete={canDelete}
onUnarchive={async () => {
await dispatch(setArchivedDashboard(false));
await dispatch(Bookmarks.actions.invalidateLists());
}}
onMove={({ id }) => dispatch(moveDashboardToCollection({ id }))}
onDeletePermanently={() => {
const { id } = dashboard;
const deleteAction = Dashboards.actions.delete({ id });
dispatch(deletePermanently(deleteAction));
}}
/>
)}
return (
<Flex direction="column" mih="100%" w="100%" flex="1 0 auto">
{dashboard.archived && (
<ArchivedEntityBanner
name={tc(dashboard.name)}
entityType="dashboard"
canMove={canWrite}
canRestore={canRestore}
canDelete={canDelete}
onUnarchive={async () => {
await dispatch(
setArchivedDashboard(false, false, {
name: tc(dashboard.name),
}),
);
await dispatch(Bookmarks.actions.invalidateLists());
}}
onMove={({ id }) =>
dispatch(moveDashboardToCollection({ id }))
}
onDeletePermanently={() => {
const { id } = dashboard;
const deleteAction = Dashboards.actions.delete({ id });
dispatch(deletePermanently(deleteAction));
}}
/>
)}
<Box
component="header"
className={cx(S.DashboardHeaderContainer, {
[S.isFullscreen]: isFullscreen,
[S.isNightMode]: shouldRenderAsNightMode,
})}
data-element-id="dashboard-header-container"
data-testid="dashboard-header-container"
>
{/**
* Do not conditionally render `<DashboardHeader />` as it calls
* `useDashboardTabs` under the hood. This hook sets `selectedTabId`
* in Redux state which kicks off a fetch for the dashboard cards.
*/}
<DashboardHeader
parameterQueryParams={parameterQueryParams}
dashboard={dashboard}
isNightMode={shouldRenderAsNightMode}
isFullscreen={isFullscreen}
onRefreshPeriodChange={onRefreshPeriodChange}
dashboardBeforeEditing={props.dashboardBeforeEditing}
onFullscreenChange={props.onFullscreenChange}
isAdditionalInfoVisible={props.isAdditionalInfoVisible}
hasNightModeToggle={props.hasNightModeToggle}
onNightModeChange={props.onNightModeChange}
refreshPeriod={props.refreshPeriod}
setRefreshElapsedHook={props.setRefreshElapsedHook}
/>
</Box>
<Flex
pos="relative"
miw={0}
mih={0}
className={cx(S.DashboardBody, {
[S.isEditingOrSharing]: isEditing || isSharing,
})}
>
<Box
className={cx(S.ParametersAndCardsContainer, {
[S.shouldMakeDashboardHeaderStickyAfterScrolling]:
!isFullscreen && (isEditing || isSharing),
[S.notEmpty]: !isEmpty,
component="header"
className={cx(S.DashboardHeaderContainer, {
[S.isFullscreen]: isFullscreen,
[S.isNightMode]: shouldRenderAsNightMode,
})}
id={DASHBOARD_PDF_EXPORT_ROOT_ID}
data-element-id="dashboard-parameters-and-cards"
data-testid="dashboard-parameters-and-cards"
data-element-id="dashboard-header-container"
data-testid="dashboard-header-container"
>
<DashboardParameterPanel isFullscreen={isFullscreen} />
{isEmpty ? (
renderEmptyStates()
) : (
<FullWidthContainer
className={S.CardsContainer}
data-element-id="dashboard-cards-container"
>
<DashboardGridConnected
clickBehaviorSidebarDashcard={
props.clickBehaviorSidebarDashcard
}
isNightMode={shouldRenderAsNightMode}
isFullscreen={props.isFullscreen}
isEditingParameter={props.isEditingParameter}
isEditing={props.isEditing}
dashboard={dashboard}
slowCards={props.slowCards}
navigateToNewCardFromDashboard={
props.navigateToNewCardFromDashboard
}
selectedTabId={selectedTabId}
onEditingChange={handleSetEditing}
downloadsEnabled={downloadsEnabled}
autoScrollToDashcardId={autoScrollToDashcardId}
reportAutoScrolledToDashcard={
reportAutoScrolledToDashcard
}
/>
</FullWidthContainer>
)}
{/**
* Do not conditionally render `<DashboardHeader />` as it calls
* `useDashboardTabs` under the hood. This hook sets `selectedTabId`
* in Redux state which kicks off a fetch for the dashboard cards.
*/}
<DashboardHeader
parameterQueryParams={parameterQueryParams}
dashboard={dashboard}
isNightMode={shouldRenderAsNightMode}
isFullscreen={isFullscreen}
onRefreshPeriodChange={onRefreshPeriodChange}
dashboardBeforeEditing={props.dashboardBeforeEditing}
onFullscreenChange={props.onFullscreenChange}
isAdditionalInfoVisible={props.isAdditionalInfoVisible}
hasNightModeToggle={props.hasNightModeToggle}
onNightModeChange={props.onNightModeChange}
refreshPeriod={props.refreshPeriod}
setRefreshElapsedHook={props.setRefreshElapsedHook}
/>
</Box>
<DashboardSidebars
dashboard={dashboard}
showAddParameterPopover={props.showAddParameterPopover}
removeParameter={props.removeParameter}
addCardToDashboard={props.addCardToDashboard}
clickBehaviorSidebarDashcard={
props.clickBehaviorSidebarDashcard
}
onReplaceAllDashCardVisualizationSettings={
props.onReplaceAllDashCardVisualizationSettings
}
onUpdateDashCardVisualizationSettings={
props.onUpdateDashCardVisualizationSettings
}
onUpdateDashCardColumnSettings={
props.onUpdateDashCardColumnSettings
}
setParameterName={props.setParameterName}
setParameterType={props.setParameterType}
setParameterDefaultValue={props.setParameterDefaultValue}
setParameterIsMultiSelect={props.setParameterIsMultiSelect}
setParameterQueryType={props.setParameterQueryType}
setParameterSourceType={props.setParameterSourceType}
setParameterSourceConfig={props.setParameterSourceConfig}
setParameterFilteringParameters={
props.setParameterFilteringParameters
}
setParameterRequired={props.setParameterRequired}
setParameterTemporalUnits={props.setParameterTemporalUnits}
isFullscreen={props.isFullscreen}
sidebar={props.sidebar}
closeSidebar={props.closeSidebar}
selectedTabId={selectedTabId}
onCancel={() => setSharing(false)}
/>
<Flex
pos="relative"
miw={0}
mih={0}
className={cx(S.DashboardBody, {
[S.isEditingOrSharing]: isEditing || isSharing,
})}
>
<Box
className={cx(S.ParametersAndCardsContainer, {
[S.shouldMakeDashboardHeaderStickyAfterScrolling]:
!isFullscreen && (isEditing || isSharing),
[S.notEmpty]: !isEmpty,
})}
id={DASHBOARD_PDF_EXPORT_ROOT_ID}
data-element-id="dashboard-parameters-and-cards"
data-testid="dashboard-parameters-and-cards"
>
<DashboardParameterPanel isFullscreen={isFullscreen} />
{isEmpty ? (
renderEmptyStates()
) : (
<FullWidthContainer
className={S.CardsContainer}
data-element-id="dashboard-cards-container"
>
<DashboardGridConnected
clickBehaviorSidebarDashcard={
props.clickBehaviorSidebarDashcard
}
isNightMode={shouldRenderAsNightMode}
isFullscreen={props.isFullscreen}
isEditingParameter={props.isEditingParameter}
isEditing={props.isEditing}
dashboard={dashboard}
slowCards={props.slowCards}
navigateToNewCardFromDashboard={
props.navigateToNewCardFromDashboard
}
selectedTabId={selectedTabId}
onEditingChange={handleSetEditing}
downloadsEnabled={downloadsEnabled}
autoScrollToDashcardId={autoScrollToDashcardId}
reportAutoScrolledToDashcard={
reportAutoScrolledToDashcard
}
/>
</FullWidthContainer>
)}
</Box>
<DashboardSidebars
dashboard={dashboard}
showAddParameterPopover={props.showAddParameterPopover}
removeParameter={props.removeParameter}
addCardToDashboard={props.addCardToDashboard}
clickBehaviorSidebarDashcard={
props.clickBehaviorSidebarDashcard
}
onReplaceAllDashCardVisualizationSettings={
props.onReplaceAllDashCardVisualizationSettings
}
onUpdateDashCardVisualizationSettings={
props.onUpdateDashCardVisualizationSettings
}
onUpdateDashCardColumnSettings={
props.onUpdateDashCardColumnSettings
}
setParameterName={props.setParameterName}
setParameterType={props.setParameterType}
setParameterDefaultValue={props.setParameterDefaultValue}
setParameterIsMultiSelect={props.setParameterIsMultiSelect}
setParameterQueryType={props.setParameterQueryType}
setParameterSourceType={props.setParameterSourceType}
setParameterSourceConfig={props.setParameterSourceConfig}
setParameterFilteringParameters={
props.setParameterFilteringParameters
}
setParameterRequired={props.setParameterRequired}
setParameterTemporalUnits={props.setParameterTemporalUnits}
isFullscreen={props.isFullscreen}
sidebar={props.sidebar}
closeSidebar={props.closeSidebar}
selectedTabId={selectedTabId}
onCancel={() => setSharing(false)}
/>
</Flex>
</Flex>
</Flex>
);
}}
</DashboardLoadingAndErrorWrapper>
);
}}
</DashboardLoadingAndErrorWrapper>
</ContentTranslationProvider>
);
}

View File

@@ -31,6 +31,11 @@ import type {
DashboardNightModeControls,
DashboardRefreshPeriodControls,
} from "metabase/dashboard/types";
import {
useTranslateContent,
useTranslateContent2,
useTranslateContent2,
} from "metabase/i18n/components/ContentTranslationContext";
import { color } from "metabase/lib/colors";
import { useDispatch, useSelector } from "metabase/lib/redux";
import {
@@ -159,6 +164,9 @@ export function DashboardHeaderView({
return () => clearTimeout(timerId);
}, [isLastEditInfoVisible]);
const tc = useTranslateContent2();
const translatedName = tc(dashboard.name);
return (
<div>
{isEditing && <EditBar title={editingTitle} buttons={editingButtons} />}
@@ -202,7 +210,8 @@ export function DashboardHeaderView({
<EditableText
className={S.HeaderCaption}
key={dashboard.name}
initialValue={dashboard.name}
initialValue={translatedName}
isLocalized={translatedName !== dashboard.name}
placeholder={t`Add title`}
isDisabled={!dashboard.can_write}
data-testid="dashboard-name-heading"

View File

@@ -0,0 +1,195 @@
import { css } from "@emotion/react";
import styled from "@emotion/styled";
import LastEditInfoLabel from "metabase/components/LastEditInfoLabel";
import EditableText from "metabase/core/components/EditableText";
import { FullWidthContainer } from "metabase/styled-components/layout/FullWidthContainer";
import {
breakpointMaxMedium,
breakpointMaxSmall,
breakpointMinSmall,
} from "metabase/styled-components/theme";
import { FixedWidthContainer } from "./Dashboard/Dashboard.styled";
import { SIDEBAR_WIDTH } from "./Sidebar";
interface TypeForItemsThatRespondToNavBarOpen {
isNavBarOpen: boolean;
}
export const HeaderFixedWidthContainer = styled(
FixedWidthContainer,
)<TypeForItemsThatRespondToNavBarOpen>`
display: flex;
align-items: center;
justify-items: apart;
${breakpointMaxMedium} {
${props =>
props.isNavBarOpen &&
css`
flex-direction: column;
align-items: baseline;
`}
}
${breakpointMaxSmall} {
flex-direction: column;
align-items: baseline;
}
`;
export const HeaderContainer = styled.div<{
offsetSidebar: boolean;
isFixedWidth: boolean;
}>`
${props =>
props.isFixedWidth &&
props.offsetSidebar &&
css`
margin-right: ${SIDEBAR_WIDTH}px;
`}
${breakpointMaxMedium} {
margin-right: 0;
}
`;
export const HeaderRow = styled(FullWidthContainer)`
${breakpointMaxSmall} {
padding-left: 0;
padding-right: 0;
}
`;
export const HeaderCaptionContainer = styled.div`
position: relative;
transition: top 400ms ease;
padding-right: 2rem;
right: 0.25rem;
display: flex;
align-items: center;
`;
export const HeaderCaption = styled(EditableText)`
font-size: 1.25rem;
font-weight: 700;
line-height: 1.5rem;
`;
export const HeaderBadges = styled.div`
display: flex;
align-items: center;
border-left: 1px solid transparent;
${breakpointMaxSmall} {
flex-direction: column;
align-items: baseline;
}
`;
export const HeaderLastEditInfoLabel = styled(LastEditInfoLabel)`
transition: opacity 400ms ease;
${breakpointMaxSmall} {
margin-top: 4px;
}
`;
export const EditWarning = styled.div`
display: flex;
align-items: center;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
`;
interface HeaderContentProps {
showSubHeader: boolean;
hasSubHeader: boolean;
}
export const HeaderContent = styled.div<HeaderContentProps>`
padding-top: 1rem;
padding-bottom: 0.75rem;
${HeaderCaptionContainer} {
top: ${props => (props.showSubHeader ? "0px" : "10px")};
}
${HeaderLastEditInfoLabel} {
opacity: ${props => (props.showSubHeader ? "1x" : "0")};
}
${({ hasSubHeader }) =>
hasSubHeader &&
css`
&:hover,
&:focus-within {
${HeaderCaptionContainer} {
top: 0;
}
${HeaderLastEditInfoLabel} {
opacity: 1;
}
}
`}
${breakpointMaxSmall} {
padding-top: 0;
padding-left: 1rem;
padding-right: 1rem;
${HeaderCaptionContainer} {
top: 0;
}
${HeaderLastEditInfoLabel} {
opacity: 1;
}
}
`;
export const HeaderButtonsContainer = styled.div<TypeForItemsThatRespondToNavBarOpen>`
display: flex;
align-items: center;
color: var(--mb-color-text-dark);
${breakpointMinSmall} {
margin-left: auto;
}
${breakpointMaxMedium} {
${props =>
props.isNavBarOpen &&
css`
width: 100%;
margin-bottom: 6px;
`}
}
${breakpointMaxSmall} {
width: 100%;
margin-bottom: 6px;
padding-top: 0.375rem;
padding-left: 1rem;
padding-right: 1rem;
border-top: 1px solid var(--mb-color-border);
}
`;
export const HeaderButtonSection = styled.div<TypeForItemsThatRespondToNavBarOpen>`
display: flex;
align-items: center;
gap: 0.5rem;
${breakpointMaxMedium} {
${props =>
props.isNavBarOpen &&
css`
width: 100%;
justify-content: space-between;
`}
}
${breakpointMaxSmall} {
width: 100%;
justify-content: space-between;
}
`;

View File

@@ -18,6 +18,7 @@ import { getTimelineEvents } from "metabase/common/components/Timeline/utils";
import { useRevisionListQuery } from "metabase/common/hooks";
import { revertToRevision, updateDashboard } from "metabase/dashboard/actions";
import { DASHBOARD_DESCRIPTION_MAX_LENGTH } from "metabase/dashboard/constants";
import { useTranslateContent } from "metabase/i18n/components/ContentTranslationContext";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { PLUGIN_MODERATION } from "metabase/plugins";
import { getUser } from "metabase/selectors/user";
@@ -167,11 +168,14 @@ const OverviewTab = ({
setDescriptionError: (error: string | null) => void;
canWrite: boolean;
}) => {
const tc = useTranslateContent();
const translatedDescription = tc(dashboard, "description");
return (
<Stack gap="lg">
<SidesheetCard title={t`Description`} pb="md">
<SidesheetEditableDescription
description={dashboard.description}
description={translatedDescription}
isLocalized={translatedDescription !== dashboard.description}
onChange={handleDescriptionChange}
canWrite={canWrite}
onFocus={() => setDescriptionError("")}

View File

@@ -5,6 +5,8 @@ import { Sortable } from "metabase/core/components/Sortable";
import type { TabButtonMenuItem } from "metabase/core/components/TabButton";
import { TabButton } from "metabase/core/components/TabButton";
import { TabRow } from "metabase/core/components/TabRow";
import { MaybeTranslationCannotBeEditedHoverCard } from "metabase/i18n/MaybeTranslationCannotBeEditedHoverCard";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
import { Flex } from "metabase/ui";
import type { DashboardId } from "metabase-types/api";
import type { SelectedTabId } from "metabase-types/store";
@@ -36,6 +38,7 @@ export function DashboardTabs({
const hasMultipleTabs = tabs.length > 1;
const showTabs = hasMultipleTabs || isEditing;
const showPlaceholder = tabs.length === 0 && isEditing;
const tc = useTranslateContent2();
if (!showTabs) {
return null;
@@ -71,23 +74,31 @@ export function DashboardTabs({
menuItems={menuItems}
/>
) : (
tabs.map((tab) => (
<Sortable
key={tab.id}
id={tab.id}
className={S.tabButton}
disabled={!isEditing}
>
<TabButton.Renameable
value={tab.id}
label={tab.name}
onRename={(name) => renameTab(tab.id, name)}
canRename={isEditing && hasMultipleTabs}
showMenu={isEditing}
menuItems={menuItems}
/>
</Sortable>
))
tabs.map((tab) => {
const localizedName = tc(tab.name);
const isNameLocalized = localizedName !== tab.name;
return (
<Sortable
key={tab.id}
id={tab.id}
className={S.tabButton}
disabled={!isEditing}
>
<MaybeTranslationCannotBeEditedHoverCard
isLocalized={isNameLocalized}
>
<TabButton.Renameable
value={tab.id}
label={localizedName}
onRename={(name) => renameTab(tab.id, name)}
canRename={isEditing && hasMultipleTabs && !isNameLocalized}
showMenu={isEditing}
menuItems={menuItems}
/>
</MaybeTranslationCannotBeEditedHoverCard>
</Sortable>
);
})
)}
{isEditing && (
<Button

View File

@@ -58,6 +58,7 @@ import { useFavicon } from "metabase/hooks/use-favicon";
import { useLoadingTimer } from "metabase/hooks/use-loading-timer";
import { useUniqueId } from "metabase/hooks/use-unique-id";
import { useWebNotification } from "metabase/hooks/use-web-notification";
import { ContentTranslationProvider } from "metabase/i18n/components/ContentTranslationContext";
import { parseHashOptions } from "metabase/lib/browser";
import { connect, useDispatch } from "metabase/lib/redux";
import * as Urls from "metabase/lib/urls";
@@ -334,79 +335,81 @@ const DashboardApp = (props: DashboardAppProps) => {
isDirty={isDirty}
isEditing={isEditing}
/>
<Dashboard
dashboardId={dashboardId}
editingOnLoad={editingOnLoad}
addCardOnLoad={addCardOnLoad}
autoScrollToDashcardId={autoScrollToDashcardId}
reportAutoScrolledToDashcard={reportAutoScrolledToDashcard}
isFullscreen={isFullscreen}
refreshPeriod={refreshPeriod}
isNightMode={isNightMode}
hasNightModeToggle={hasNightModeToggle}
setRefreshElapsedHook={setRefreshElapsedHook}
onNightModeChange={onNightModeChange}
onFullscreenChange={onFullscreenChange}
onRefreshPeriodChange={onRefreshPeriodChange}
parameterQueryParams={parameterQueryParams}
canManageSubscriptions={canManageSubscriptions}
isAdmin={isAdmin}
isNavbarOpen={isNavbarOpen}
isEditing={isEditing}
isSharing={isSharing}
dashboardBeforeEditing={dashboardBeforeEditing}
isEditingParameter={isEditingParameter}
isDirty={isDirty}
dashboard={dashboard}
slowCards={slowCards}
parameterValues={parameterValues}
loadingStartTime={loadingStartTime}
clickBehaviorSidebarDashcard={clickBehaviorSidebarDashcard}
isAddParameterPopoverOpen={isAddParameterPopoverOpen}
sidebar={sidebar}
isHeaderVisible={isHeaderVisible}
isAdditionalInfoVisible={isAdditionalInfoVisible}
selectedTabId={selectedTabId}
isNavigatingBackToDashboard={isNavigatingBackToDashboard}
initialize={initialize}
cancelFetchDashboardCardData={cancelFetchDashboardCardData}
addCardToDashboard={addCardToDashboard}
addHeadingDashCardToDashboard={addHeadingDashCardToDashboard}
addMarkdownDashCardToDashboard={addMarkdownDashCardToDashboard}
addLinkDashCardToDashboard={addLinkDashCardToDashboard}
setEditingDashboard={setEditingDashboard}
setDashboardAttributes={setDashboardAttributes}
setSharing={setSharing}
toggleSidebar={toggleSidebar}
closeSidebar={closeSidebar}
closeNavbar={closeNavbar}
setErrorPage={setErrorPage}
setParameterName={setParameterName}
setParameterType={setParameterType}
navigateToNewCardFromDashboard={navigateToNewCardFromDashboard}
setParameterDefaultValue={setParameterDefaultValue}
setParameterRequired={setParameterRequired}
setParameterTemporalUnits={setParameterTemporalUnits}
setParameterIsMultiSelect={setParameterIsMultiSelect}
setParameterQueryType={setParameterQueryType}
setParameterSourceType={setParameterSourceType}
setParameterSourceConfig={setParameterSourceConfig}
setParameterFilteringParameters={setParameterFilteringParameters}
showAddParameterPopover={showAddParameterPopover}
removeParameter={removeParameter}
onReplaceAllDashCardVisualizationSettings={
onReplaceAllDashCardVisualizationSettings
}
onUpdateDashCardVisualizationSettings={
onUpdateDashCardVisualizationSettings
}
onUpdateDashCardColumnSettings={onUpdateDashCardColumnSettings}
updateDashboardAndCards={updateDashboardAndCards}
setSidebar={setSidebar}
hideAddParameterPopover={hideAddParameterPopover}
fetchDashboard={fetchDashboard}
fetchDashboardCardData={fetchDashboardCardData}
/>
<ContentTranslationProvider>
<Dashboard
dashboardId={dashboardId}
editingOnLoad={editingOnLoad}
addCardOnLoad={addCardOnLoad}
autoScrollToDashcardId={autoScrollToDashcardId}
reportAutoScrolledToDashcard={reportAutoScrolledToDashcard}
isFullscreen={isFullscreen}
refreshPeriod={refreshPeriod}
isNightMode={isNightMode}
hasNightModeToggle={hasNightModeToggle}
setRefreshElapsedHook={setRefreshElapsedHook}
onNightModeChange={onNightModeChange}
onFullscreenChange={onFullscreenChange}
onRefreshPeriodChange={onRefreshPeriodChange}
parameterQueryParams={parameterQueryParams}
canManageSubscriptions={canManageSubscriptions}
isAdmin={isAdmin}
isNavbarOpen={isNavbarOpen}
isEditing={isEditing}
isSharing={isSharing}
dashboardBeforeEditing={dashboardBeforeEditing}
isEditingParameter={isEditingParameter}
isDirty={isDirty}
dashboard={dashboard}
slowCards={slowCards}
parameterValues={parameterValues}
loadingStartTime={loadingStartTime}
clickBehaviorSidebarDashcard={clickBehaviorSidebarDashcard}
isAddParameterPopoverOpen={isAddParameterPopoverOpen}
sidebar={sidebar}
isHeaderVisible={isHeaderVisible}
isAdditionalInfoVisible={isAdditionalInfoVisible}
selectedTabId={selectedTabId}
isNavigatingBackToDashboard={isNavigatingBackToDashboard}
initialize={initialize}
cancelFetchDashboardCardData={cancelFetchDashboardCardData}
addCardToDashboard={addCardToDashboard}
addHeadingDashCardToDashboard={addHeadingDashCardToDashboard}
addMarkdownDashCardToDashboard={addMarkdownDashCardToDashboard}
addLinkDashCardToDashboard={addLinkDashCardToDashboard}
setEditingDashboard={setEditingDashboard}
setDashboardAttributes={setDashboardAttributes}
setSharing={setSharing}
toggleSidebar={toggleSidebar}
closeSidebar={closeSidebar}
closeNavbar={closeNavbar}
setErrorPage={setErrorPage}
setParameterName={setParameterName}
setParameterType={setParameterType}
navigateToNewCardFromDashboard={navigateToNewCardFromDashboard}
setParameterDefaultValue={setParameterDefaultValue}
setParameterRequired={setParameterRequired}
setParameterTemporalUnits={setParameterTemporalUnits}
setParameterIsMultiSelect={setParameterIsMultiSelect}
setParameterQueryType={setParameterQueryType}
setParameterSourceType={setParameterSourceType}
setParameterSourceConfig={setParameterSourceConfig}
setParameterFilteringParameters={setParameterFilteringParameters}
showAddParameterPopover={showAddParameterPopover}
removeParameter={removeParameter}
onReplaceAllDashCardVisualizationSettings={
onReplaceAllDashCardVisualizationSettings
}
onUpdateDashCardVisualizationSettings={
onUpdateDashCardVisualizationSettings
}
onUpdateDashCardColumnSettings={onUpdateDashCardColumnSettings}
updateDashboardAndCards={updateDashboardAndCards}
setSidebar={setSidebar}
hideAddParameterPopover={hideAddParameterPopover}
fetchDashboard={fetchDashboard}
fetchDashboardCardData={fetchDashboardCardData}
/>
</ContentTranslationProvider>
{/* For rendering modal urls */}
{props.children}
</div>

View File

@@ -10,7 +10,11 @@ import type { Dashboard } from "metabase-types/api";
const handleClick = async (dashboard: Dashboard) => {
const cardNodeSelector = `#${DASHBOARD_PDF_EXPORT_ROOT_ID}`;
await saveDashboardPdf(cardNodeSelector, dashboard.name).then(() => {
await saveDashboardPdf(
cardNodeSelector,
// NOTE: We could localize the name later
dashboard.name,
).then(() => {
trackExportDashboardToPDF({
dashboardId: dashboard.id,
dashboardAccessedVia: isWithinIframe()

View File

@@ -55,6 +55,7 @@ interface Props<Entity, EntityWrapper> {
wrapped?: boolean;
}
// NOTE: Perhaps we could localize here
const defaultTransformResponse = (data: unknown, _query: EntityQuery) => data;
/**

View File

@@ -15,6 +15,7 @@ import { HomeHelpCard } from "../HomeHelpCard";
import { HomeModelCard } from "../HomeModelCard";
import { SectionBody } from "./HomeRecentSection.styled";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
export const HomeRecentSection = () => {
const { data: recentItems = [], isLoading, error } = useListRecentsQuery();
@@ -22,6 +23,7 @@ export const HomeRecentSection = () => {
const hasHelpCard =
user != null && user.is_installer && isWithinWeeks(user.first_login, 2);
const tc = useTranslateContent2();
if (isLoading || error) {
return <LoadingAndErrorWrapper loading={isLoading} error={error} />;
}
@@ -33,7 +35,7 @@ export const HomeRecentSection = () => {
{recentsFilter(recentItems).map((item, index) => (
<HomeModelCard
key={index}
title={getName(item)}
title={tc(getName(item))}
icon={getIcon(item)}
url={Urls.modelToUrl(item) ?? ""}
/>

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import { t } from "ttag";
import { Box, HoverCard } from "metabase/ui";
export const MaybeTranslationCannotBeEditedHoverCard = ({
isLocalized,
children,
}: {
isLocalized?: boolean;
children: ReactNode;
}) => {
return (
<HoverCard offset={0} position="bottom-start" disabled={!isLocalized}>
<HoverCard.Target>{children}</HoverCard.Target>
<HoverCard.Dropdown>
<Box
bg="var(--mb-color-background-inverse)"
c="white"
p="md"
>{t`This translation cannot be edited here`}</Box>
</HoverCard.Dropdown>
</HoverCard>
);
};

View File

@@ -0,0 +1,80 @@
import { type ReactNode, createContext, useContext } from "react";
import { useListContentTranslationsQuery } from "metabase/api/content-translation";
import { useLocale } from "metabase/common/hooks";
import type { ContentTranslationContextObject } from "../types";
import { translateProperty, translateString } from "../utils";
export const ContentTranslationContext =
createContext<ContentTranslationContextObject>({
shouldLocalize: true,
dictionary: [],
locale: "en",
});
export const ContentTranslationProvider = ({
children,
}: {
children: ReactNode;
}) => {
const locale = useLocale();
console.log("@m91rnpfs", "locale", locale);
const {
data,
error,
// TODO: the loading state is not represented
isLoading: _isLoading,
} = useListContentTranslationsQuery({
locale,
});
if (error) {
console.error("Error while retrieving content translations", error);
}
const contextValue = {
dictionary: data?.data || [],
locale,
shouldLocalize: true,
};
return (
<ContentTranslationContext.Provider value={contextValue}>
{children}
</ContentTranslationContext.Provider>
);
};
export const useTranslateContent = () => {
const context = useContext(ContentTranslationContext);
return getContentTranslationFunction(context);
};
export type TCFunc = <TypeOfArgument>(msgid?: TypeOfArgument) => TypeOfArgument;
export const useTranslateContent2 = () => {
const context = useContext(ContentTranslationContext);
// FIXME: Useful in development but let's remove this later
if (!context.dictionary?.length) {
console.log("Dictionary is empty");
}
const tcFunc: TCFunc = <TypeOfArgument,>(msgid?: TypeOfArgument) =>
(msgid && typeof msgid === "string"
? translateString(msgid, context)
: msgid) as TypeOfArgument;
return tcFunc;
};
export const getContentTranslationFunction = (
context: ContentTranslationContextObject,
) => {
return (obj: any, property: string) =>
translateProperty(obj, property, (msgid: string) =>
translateString(msgid, context),
);
};

View File

@@ -0,0 +1,15 @@
/** Entry in a dictionary used for content translation */
export type ContentTranslationDictionaryEntry = {
locale: string;
msgid: string;
msgstr: string;
};
/** A dictionary used for content translation */
export type ContentTranslationDictionary = ContentTranslationDictionaryEntry[];
export type ContentTranslationContextObject = {
shouldLocalize: boolean;
locale?: string;
dictionary?: ContentTranslationDictionary;
};

View File

@@ -0,0 +1,55 @@
import type { ContentTranslationContextObject } from "./types";
export const translateString = (
msgid: string,
context: ContentTranslationContextObject,
) => {
const { shouldLocalize = true, dictionary = [], locale } = context;
if (!shouldLocalize || !dictionary.length) {
return msgid;
}
const matches = dictionary.filter(entry => {
const { locale: entryLocaleCode, msgid: entryMsgid } = entry;
return locale === entryLocaleCode && msgid === entryMsgid;
});
if (matches.length > 1) {
console.error("Multiple matches for:", locale, msgid);
}
return matches[0]?.msgstr || msgid;
};
type LocalizedProperty<T, K extends keyof T> =
T extends Record<string, any>
? K extends string
? T[`${K}_localized`]
: never
: never;
/** Return the translated version of a property if it exists. Otherwise return the property */
export const translateProperty = <
T extends Record<string, any>,
K extends keyof T & string,
>(
obj: T,
property: K,
translateString?: (msgid: string) => string,
): NonNullable<LocalizedProperty<T, K>> | T[K] => {
if (!obj) {
return obj;
}
const localizedKey = `${property}_localized`;
const msgid = obj[property] as T[K];
const translated = translateString?.(msgid) as NonNullable<
LocalizedProperty<T, K>
>;
try {
(obj as Record<typeof localizedKey, any>)[localizedKey] ??= translated;
} catch (e) {
console.error("Couldn't cache translation", e);
}
return translated || msgid;
};

View File

@@ -27,7 +27,11 @@ function FormattedParameterValue({
placeholder,
isPopoverOpen = false,
}: FormattedParameterValueProps) {
console.log("fpv");
console.log("@m91r5600", "value", value)
if (parameterHasNoDisplayValue(value)) {
console.log('returning placeholder');
return placeholder;
}

View File

@@ -4,36 +4,37 @@ import _ from "underscore";
import AccordionList from "metabase/core/components/AccordionList";
import CS from "metabase/css/core/index.css";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
import { Icon } from "metabase/ui";
export default class ParameterTargetList extends Component {
props;
export const ParameterTargetList = ({
mappingOptions,
selectedMappingOption,
maxHeight,
onChange,
}) => {
const tc = useTranslateContent2();
const mappingOptionSections = _.groupBy(mappingOptions, "sectionName");
render() {
const { mappingOptions, selectedMappingOption } = this.props;
const hasForeignOption = _.any(mappingOptions, (o) => !!o.isForeign);
const mappingOptionSections = _.groupBy(mappingOptions, "sectionName");
const sections = _.map(mappingOptionSections, (options) => ({
name: options[0].sectionName,
items: options.map((opt) => ({ ...opt, name: tc(opt.name) })),
}));
const hasForeignOption = _.any(mappingOptions, (o) => !!o.isForeign);
const sections = _.map(mappingOptionSections, (options) => ({
name: options[0].sectionName,
items: options,
}));
return (
<AccordionList
className={CS.textBrand}
maxHeight={this.props.maxHeight || 600}
sections={sections}
onChange={(item) => this.props.onChange(item.target)}
itemIsSelected={(item) => item === selectedMappingOption}
renderItemIcon={(item) => (
<Icon name={item.icon || "unknown"} size={18} />
)}
alwaysExpanded={true}
hideSingleSectionTitle={!hasForeignOption}
/>
);
}
}
return (
<AccordionList
className={CS.textBrand}
maxHeight={maxHeight || 600}
sections={sections}
onChange={(item) => onChange(item.target)}
itemIsSelected={(item) => item === selectedMappingOption}
renderItemIcon={(item) => (
<Icon name={item.icon || "unknown"} size={18} />
)}
alwaysExpanded={true}
hideSingleSectionTitle={!hasForeignOption}
/>
);
};

View File

@@ -70,6 +70,8 @@ export const ParameterValueWidget = ({
}: ParameterValueWidgetProps) => {
const [isFocused, setIsFocused] = useState(false);
console.log("@m91r7vzs", "placeholder", placeholder);
const hasValue = !parameterHasNoDisplayValue(value);
const hasDefaultValue = !parameterHasNoDisplayValue(parameter.default);
const fieldHasValueOrFocus = parameter.value != null || isFocused;
@@ -229,6 +231,8 @@ export const ParameterValueWidget = ({
);
}
console.log("@m91r5xs0", "placeholder", placeholder);
const placeholderText = isEditing
? isDateParameter(parameter)
? t`Select a default value…`

View File

@@ -15,6 +15,7 @@ import type { Dashboard, Parameter, ParameterId } from "metabase-types/api";
import { ParameterValueWidget } from "../ParameterValueWidget";
import S from "./ParameterWidget.module.css";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
type ParameterWidgetProps = PropsWithChildren<
{
@@ -99,10 +100,13 @@ export const ParameterWidget = ({
children,
dragHandle,
}: ParameterWidgetProps) => {
const tc = useTranslateContent2();
const [isFocused, setIsFocused] = useState(false);
const isEditingParameter = editingParameter?.id === parameter.id;
const fieldHasValueOrFocus = parameter.value != null || isFocused;
const legend = fieldHasValueOrFocus ? parameter.name : "";
const legend = fieldHasValueOrFocus ? tc(parameter.name) : "";
if (!isEditing || !setEditingParameter) {
return (
@@ -133,7 +137,7 @@ export const ParameterWidget = ({
value={parameter.value}
setValue={(value) => setValue?.(value)}
isEditing={isEditingParameter}
placeholder={parameter.name}
placeholder={tc(parameter.name)}
focusChanged={setIsFocused}
isFullscreen={isFullscreen}
commitImmediately={commitImmediately}

View File

@@ -14,6 +14,7 @@ import { Icon } from "metabase/ui";
import type { Parameter, ParameterId } from "metabase-types/api";
import { ParameterWidget } from "../ParameterWidget";
import { ContentTranslationProvider } from "metabase/i18n/components/ContentTranslationContext";
const getId = (valuePopulatedParameter: Parameter) =>
valuePopulatedParameter.id;
@@ -106,13 +107,15 @@ export const ParametersList = ({
vertical ? CS.flexColumn : CS.flexRow,
)}
>
<SortableList
items={visibleValuePopulatedParameters}
getId={getId}
renderItem={renderItem}
onSortEnd={handleSortEnd}
sensors={[pointerSensor]}
/>
<ContentTranslationProvider>
<SortableList
items={visibleValuePopulatedParameters}
getId={getId}
renderItem={renderItem}
onSortEnd={handleSortEnd}
sensors={[pointerSensor]}
/>
</ContentTranslationProvider>
</div>
) : null;
};

View File

@@ -35,6 +35,7 @@ import type {
WritebackParameter,
} from "metabase-types/api";
import { isStructuredDimensionTarget } from "metabase-types/guards";
import { TCFunc } from "metabase/i18n/components/ContentTranslationContext";
export type StructuredQuerySectionOption = {
sectionName: string;
@@ -49,11 +50,12 @@ function buildStructuredQuerySectionOptions(
stageIndex: number,
group: Lib.ColumnGroup,
columns: Lib.ColumnMetadata[],
tc?: TCFunc,
): StructuredQuerySectionOption[] {
const groupInfo = Lib.displayInfo(query, stageIndex, group);
return columns.map((column) => {
const columnInfo = Lib.displayInfo(query, stageIndex, column);
const columnInfo = Lib.displayInfo(query, stageIndex, column, tc);
return {
sectionName: getGroupName(groupInfo, stageIndex) ?? t`Summaries`,
@@ -131,6 +133,7 @@ export function getParameterMappingOptions(
parameter: Parameter | null | undefined = null,
card: Card,
dashcard: BaseDashboardCard | null | undefined = null,
tc?: TCFunc,
): ParameterMappingOption[] {
if (dashcard && isVirtualDashCard(dashcard)) {
if (["heading", "text"].includes(card.display)) {
@@ -184,7 +187,8 @@ export function getParameterMappingOptions(
query,
stageIndex,
group,
Lib.getColumnsFromColumnGroup(group),
Lib.getColumnsFromColumnGroup(group, tc),
tc,
),
);
},

View File

@@ -199,17 +199,22 @@ export const EmbedFrame = ({
</TitleAndButtonsContainer>
</TitleAndDescriptionContainer>
)}
{dashboardTabs && (
<DashboardTabsContainer>
<FixedWidthContainer
data-testid="fixed-width-dashboard-tabs"
isFixedWidth={dashboard?.width === "fixed"}
>
{dashboardTabs}
</FixedWidthContainer>
</DashboardTabsContainer>
)}
{
// Here we can localize tabs for embedding
// I think the best way is to teach the DashboardTab component
// how to localize, but then have it only localize when a certain
// contextual flag is on. Perhaps React context could help.
dashboardTabs && (
<DashboardTabsContainer>
<FixedWidthContainer
data-testid="fixed-width-dashboard-tabs"
isFixedWidth={dashboard?.width === "fixed"}
>
{dashboardTabs}
</FixedWidthContainer>
</DashboardTabsContainer>
)
}
<Separator className={EmbedFrameS.Separator} />
</Header>
)}

View File

@@ -28,6 +28,7 @@ import type {
FetchDashboardResult,
SuccessfulFetchDashboardResult,
} from "metabase/dashboard/types";
import { ContentTranslationProvider } from "metabase/i18n/components/ContentTranslationContext";
import { connect } from "metabase/lib/redux";
import { type DispatchFn, useDispatch } from "metabase/lib/redux";
import { LocaleProvider } from "metabase/public/LocaleProvider";
@@ -222,35 +223,37 @@ const PublicOrEmbeddedDashboardInner = ({
return (
<LocaleProvider locale={locale} shouldWaitForLocale>
<PublicOrEmbeddedDashboardView
dashboard={dashboard}
hasNightModeToggle={hasNightModeToggle}
isFullscreen={isFullscreen}
isNightMode={isNightMode}
onFullscreenChange={onFullscreenChange}
onNightModeChange={onNightModeChange}
onRefreshPeriodChange={onRefreshPeriodChange}
refreshPeriod={refreshPeriod}
setRefreshElapsedHook={setRefreshElapsedHook}
selectedTabId={selectedTabId}
parameters={parameters}
parameterValues={parameterValues}
draftParameterValues={draftParameterValues}
setParameterValue={setParameterValue}
setParameterValueToDefault={setParameterValueToDefault}
dashboardId={dashboardId}
background={background}
bordered={bordered}
titled={titled}
theme={theme}
getClickActionMode={getClickActionMode}
hideParameters={hideParameters}
navigateToNewCardFromDashboard={navigateToNewCardFromDashboard}
slowCards={slowCards}
cardTitled={cardTitled}
downloadsEnabled={downloadsEnabled}
withFooter={withFooter}
/>
<ContentTranslationProvider>
<PublicOrEmbeddedDashboardView
dashboard={dashboard}
hasNightModeToggle={hasNightModeToggle}
isFullscreen={isFullscreen}
isNightMode={isNightMode}
onFullscreenChange={onFullscreenChange}
onNightModeChange={onNightModeChange}
onRefreshPeriodChange={onRefreshPeriodChange}
refreshPeriod={refreshPeriod}
setRefreshElapsedHook={setRefreshElapsedHook}
selectedTabId={selectedTabId}
parameters={parameters}
parameterValues={parameterValues}
draftParameterValues={draftParameterValues}
setParameterValue={setParameterValue}
setParameterValueToDefault={setParameterValueToDefault}
dashboardId={dashboardId}
background={background}
bordered={bordered}
titled={titled}
theme={theme}
getClickActionMode={getClickActionMode}
hideParameters={hideParameters}
navigateToNewCardFromDashboard={navigateToNewCardFromDashboard}
slowCards={slowCards}
cardTitled={cardTitled}
downloadsEnabled={downloadsEnabled}
withFooter={withFooter}
/>
</ContentTranslationProvider>
</LocaleProvider>
);
};

View File

@@ -26,6 +26,10 @@ import type {
EmbedHideParameters,
} from "metabase/dashboard/types";
import { isActionDashCard } from "metabase/dashboard/utils";
import {
useTranslateContent,
useTranslateContent2,
} from "metabase/i18n/components/ContentTranslationContext";
import { isWithinIframe } from "metabase/lib/dom";
import ParametersS from "metabase/parameters/components/ParameterValueWidget.module.css";
import type { DisplayTheme } from "metabase/public/lib/types";
@@ -126,6 +130,8 @@ export function PublicOrEmbeddedDashboardView({
/>
) : null;
const tc = useTranslateContent2();
const visibleDashcards = (dashboard?.dashcards ?? []).filter(
(dashcard) => !isActionDashCard(dashcard),
);
@@ -162,8 +168,8 @@ export function PublicOrEmbeddedDashboardView({
return (
<EmbedFrame
name={dashboard && dashboard.name}
description={dashboard && dashboard.description}
name={dashboard && tc(dashboard.name)}
description={dashboard && tc(dashboard.description)}
dashboard={dashboard}
parameters={parameters}
parameterValues={parameterValues}

View File

@@ -2,6 +2,7 @@ import { t } from "ttag";
import EditableText from "metabase/core/components/EditableText";
import { QUESTION_NAME_MAX_LENGTH } from "metabase/questions/constants";
import { useTranslateContent } from "metabase/i18n/components/ContentTranslationContext";
import { Flex } from "metabase/ui";
import type Question from "metabase-lib/v1/Question";
@@ -17,12 +18,13 @@ function SavedQuestionHeaderButton({
question,
onSave,
}: SavedQuestionHeaderButtonProps) {
const tc = useTranslateContent();
return (
<Flex align="center" gap="0.25rem">
<EditableText
className={SavedQuestionHeaderButtonS.HeaderTitle}
isDisabled={!question.canWrite() || question.isArchived()}
initialValue={question.displayName()}
initialValue={tc(question._card, "name")}
placeholder={t`Add title`}
maxLength={QUESTION_NAME_MAX_LENGTH}
onChange={onSave}

View File

@@ -2,6 +2,7 @@ import type { HTMLAttributes } from "react";
import { Link } from "react-router";
import { t } from "ttag";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
import { useDispatch, useSelector } from "metabase/lib/redux";
import * as Urls from "metabase/lib/urls";
import { navigateBackToDashboard } from "metabase/query_builder/actions";
@@ -30,11 +31,13 @@ export function DashboardBackButton({
onClick?.();
};
const tc = useTranslateContent2();
if (!dashboard) {
return null;
}
const label = t`Back to ${dashboard.name}`;
const label = t`Back to ${tc(dashboard.name)}`;
return (
<Tooltip label={label}>

View File

@@ -12,6 +12,7 @@ import { Box, Flex } from "metabase/ui";
import type { DataSourcePart } from "../QuestionDataSource/utils";
import HeaderBreadcrumbsS from "./HeaderBreadcrumbs.module.css";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
const HeaderBadge = (props: ComponentProps<typeof Badge>) => (
<Badge
@@ -47,6 +48,7 @@ export function HeadBreadcrumbs({
inactiveColor,
...props
}: HeadBreadcrumbsProps) {
const tc = useTranslateContent2();
return (
<Flex
align="center"
@@ -61,6 +63,7 @@ export function HeadBreadcrumbs({
const isLast = index === parts.length - 1;
const badgeInactiveColor =
inactiveColor || getBadgeInactiveColor({ variant, isLast });
return (
<Fragment key={index}>
{isDataSourceReactElement(part) ? (
@@ -71,7 +74,7 @@ export function HeadBreadcrumbs({
icon={part.icon}
inactiveColor={badgeInactiveColor}
>
{part.name}
{tc(part.name)}
</HeaderBadge>
)}
{!isLast &&

View File

@@ -1,5 +1,6 @@
import { t } from "ttag";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
import * as Urls from "metabase/lib/urls";
import type Question from "metabase-lib/v1/Question";
@@ -8,9 +9,10 @@ import { HeadBreadcrumbs } from "../HeaderBreadcrumbs/HeaderBreadcrumbs";
export function HeaderCollectionBadge({ question }: { question: Question }) {
const { collection } = question.card();
const icon = question.type();
const tc = useTranslateContent2();
return (
<HeadBreadcrumbs.Badge to={Urls.collection(collection)} icon={icon}>
{collection?.name || t`Our analytics`}
{tc(collection?.name) || t`Our analytics`}
</HeadBreadcrumbs.Badge>
);
}

View File

@@ -20,6 +20,7 @@ import { HeadBreadcrumbs } from "../HeaderBreadcrumbs/HeaderBreadcrumbs";
import HeaderS from "../HeaderBreadcrumbs/HeaderBreadcrumbs.module.css";
import S from "./QuestionDataSource.module.css";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
export type DataSourcePart = ReactElement | DataSourceBadgePart;
@@ -149,6 +150,7 @@ function QuestionTableBadges({
isLast,
}: QuestionTableBadgesProps) {
const badgeInactiveColor = isLast && !subHead ? "text-dark" : "text-light";
const tc = useTranslateContent2();
const parts = tables.map((table) => (
<HeadBreadcrumbs.Badge
@@ -157,7 +159,7 @@ function QuestionTableBadges({
inactiveColor={badgeInactiveColor}
>
<span>
{table.displayName()}
{tc(table.displayName())}
{!subHead && (
<span className={S.IconWrapper}>
<TableInfoIcon

View File

@@ -2,6 +2,7 @@ import { c, msgid } from "ttag";
import { SidesheetCard } from "metabase/common/components/Sidesheet";
import { QueryColumnInfoIcon } from "metabase/components/MetadataInfo/ColumnInfoIcon";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
import { Box, Group, Stack } from "metabase/ui";
import * as Lib from "metabase-lib";
import type Question from "metabase-lib/v1/Question";
@@ -50,6 +51,7 @@ const Column = ({
columnMetadata: Lib.ColumnMetadata;
columnDisplayInfo: Lib.ColumnDisplayInfo;
}) => {
const tc = useTranslateContent2();
return (
<Group gap="sm" role="listitem">
<QueryColumnInfoIcon
@@ -58,7 +60,7 @@ const Column = ({
column={columnMetadata}
stageIndex={-1}
/>
{columnInfo.displayName}
{tc(columnInfo.displayName)}
</Group>
);
};

View File

@@ -14,6 +14,7 @@ import * as Lib from "metabase-lib";
import BreakoutColumnListS from "./BreakoutColumnList.module.css";
import { BreakoutColumnListItem } from "./BreakoutColumnListItem";
import { getBreakoutListItem, getColumnSections, isPinnedColumn } from "./util";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
export type BreakoutColumnListProps = UpdateQueryHookProps;
@@ -29,6 +30,8 @@ export function BreakoutColumnList({
onReplaceBreakouts,
} = useBreakoutQueryHandlers({ query, onQueryChange, stageIndex });
const tc = useTranslateContent2();
const [searchQuery, setSearchQuery] = useState("");
const debouncedSearchQuery = useDebouncedValue(
searchQuery,
@@ -43,8 +46,10 @@ export function BreakoutColumnList({
() =>
breakouts
.slice(0, pinnedItemCount)
.map((breakout) => getBreakoutListItem(query, stageIndex, breakout)),
[query, stageIndex, breakouts, pinnedItemCount],
.map((breakout) =>
getBreakoutListItem(query, stageIndex, breakout, tc),
),
[query, stageIndex, breakouts, pinnedItemCount, tc],
);
const allColumns = useMemo(
@@ -67,6 +72,7 @@ export function BreakoutColumnList({
stageIndex,
isSearching ? allColumns : unpinnedColumns,
debouncedSearchQuery,
tc,
),
[
query,
@@ -75,6 +81,7 @@ export function BreakoutColumnList({
unpinnedColumns,
isSearching,
debouncedSearchQuery,
tc,
],
);

View File

@@ -15,6 +15,7 @@ import { Box, type BoxProps, Flex } from "metabase/ui";
import * as Lib from "metabase-lib";
import BreakoutColumnListItemS from "./BreakoutColumnListItem.module.css";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
interface BreakoutColumnListItemProps {
query: Lib.Query;
@@ -82,6 +83,7 @@ export function BreakoutColumnListItem({
);
const displayName = isPinned ? item.longDisplayName : item.displayName;
const tc = useTranslateContent2();
return (
<HoverParent
@@ -114,7 +116,7 @@ export function BreakoutColumnListItem({
className={BreakoutColumnListItemS.Title}
data-testid="dimension-list-item-name"
>
{displayName}
{tc(displayName)}
</Box>
</Flex>
<BucketPickerPopover

View File

@@ -1,14 +1,19 @@
import * as Lib from "metabase-lib";
import type { ListItem, ListSection } from "./types";
import {
TCFunc,
useTranslateContent2,
} from "metabase/i18n/components/ContentTranslationContext";
export function getBreakoutListItem(
query: Lib.Query,
stageIndex: number,
breakout: Lib.BreakoutClause,
tc?: TCFunc,
): ListItem {
const column = Lib.breakoutColumn(query, stageIndex, breakout);
const columnInfo = Lib.displayInfo(query, stageIndex, column);
const columnInfo = Lib.displayInfo(query, stageIndex, column, tc);
return { ...columnInfo, column, breakout };
}
@@ -17,8 +22,11 @@ function getColumnListItems(
stageIndex: number,
breakouts: Lib.BreakoutClause[],
column: Lib.ColumnMetadata,
tc?: TCFunc,
): ListItem[] {
const columnInfo = Lib.displayInfo(query, stageIndex, column);
const columnInfo = Lib.displayInfo(query, stageIndex, column, tc);
console.log("@m91xhouo", "columnInfo", columnInfo);
const { breakoutPositions = [] } = columnInfo;
if (breakoutPositions.length === 0) {
return [{ ...columnInfo, column }];
@@ -39,6 +47,7 @@ export function getColumnSections(
stageIndex: number,
columns: Lib.ColumnMetadata[],
searchQuery: string,
tc: TCFunc,
): ListSection[] {
const breakouts = Lib.breakouts(query, stageIndex);
const formattedSearchQuery = searchQuery.trim().toLowerCase();
@@ -46,7 +55,12 @@ export function getColumnSections(
const filteredColumns =
formattedSearchQuery.length > 0
? columns.filter((column) => {
const { displayName } = Lib.displayInfo(query, stageIndex, column);
const { displayName } = Lib.displayInfo(
query,
stageIndex,
column,
tc,
);
return displayName.toLowerCase().includes(formattedSearchQuery);
})
: columns;
@@ -54,9 +68,11 @@ export function getColumnSections(
return Lib.groupColumns(filteredColumns).map((group) => {
const groupInfo = Lib.displayInfo(query, stageIndex, group);
const items = Lib.getColumnsFromColumnGroup(group).flatMap((column) =>
getColumnListItems(query, stageIndex, breakouts, column),
);
const items = Lib.getColumnsFromColumnGroup(group, tc)
.flatMap((column) =>
getColumnListItems(query, stageIndex, breakouts, column, tc),
)
.toSorted((a, b) => a.displayName.localeCompare(b.displayName));
return {
name: groupInfo.displayName,

View File

@@ -18,6 +18,7 @@ import { useFavicon } from "metabase/hooks/use-favicon";
import { useForceUpdate } from "metabase/hooks/use-force-update";
import { useLoadingTimer } from "metabase/hooks/use-loading-timer";
import { useWebNotification } from "metabase/hooks/use-web-notification";
import { ContentTranslationProvider } from "metabase/i18n/components/ContentTranslationContext";
import { connect, useSelector } from "metabase/lib/redux";
import { closeNavbar } from "metabase/redux/app";
import { getIsNavbarOpen } from "metabase/selectors/app";
@@ -413,26 +414,28 @@ function QueryBuilderInner(props: QueryBuilderInnerProps) {
return (
<>
<View
{...props}
modal={uiControls.modal}
recentlySaved={uiControls.recentlySaved}
onOpenModal={openModal}
onCloseModal={closeModal}
onSave={handleSave}
onCreate={handleCreate}
handleResize={forceUpdateDebounced}
toggleBookmark={onClickBookmark}
onDismissToast={onDismissToast}
onConfirmToast={onConfirmToast}
isShowingToaster={isShowingToaster}
/>
<ContentTranslationProvider>
<View
{...props}
modal={uiControls.modal}
recentlySaved={uiControls.recentlySaved}
onOpenModal={openModal}
onCloseModal={closeModal}
onSave={handleSave}
onCreate={handleCreate}
handleResize={forceUpdateDebounced}
toggleBookmark={onClickBookmark}
onDismissToast={onDismissToast}
onConfirmToast={onConfirmToast}
isShowingToaster={isShowingToaster}
/>
<LeaveRouteConfirmModal
isEnabled={shouldShowUnsavedChangesWarning && !isCallbackScheduled}
isLocationAllowed={isLocationAllowed}
route={route}
/>
<LeaveRouteConfirmModal
isEnabled={shouldShowUnsavedChangesWarning && !isCallbackScheduled}
isLocationAllowed={isLocationAllowed}
route={route}
/>
</ContentTranslationProvider>
</>
);
}

View File

@@ -50,7 +50,10 @@ export function FilterPanelPopover({
onClick={() => setIsOpened((isOpened) => !isOpened)}
onRemoveClick={handleRemove}
>
{filterInfo.longDisplayName}
{
// This long display name must be localized in metabase-lib
filterInfo.longDisplayName
}
</FilterPill>
</Popover.Target>
<Popover.Dropdown data-testid="filter-picker-dropdown">

View File

@@ -7,6 +7,7 @@ import * as Lib from "metabase-lib";
import { DatePicker } from "../../DatePicker";
import type { FilterPickerWidgetProps } from "../types";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
export function DateFilterPicker({
query,
@@ -17,9 +18,10 @@ export function DateFilterPicker({
onChange,
onBack,
}: FilterPickerWidgetProps) {
const tc = useTranslateContent2();
const columnInfo = useMemo(() => {
return Lib.displayInfo(query, stageIndex, column);
}, [query, stageIndex, column]);
return Lib.displayInfo(query, stageIndex, column, tc);
}, [query, stageIndex, column, tc]);
const { value, availableOperators, availableUnits, getFilterClause } =
useDateFilter({

View File

@@ -8,6 +8,7 @@ import {
QueryColumnInfoIcon,
} from "metabase/components/MetadataInfo/ColumnInfoIcon";
import AccordionList from "metabase/core/components/AccordionList";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
import { getGroupName } from "metabase/querying/filters/utils/groups";
import type { IconName } from "metabase/ui";
import { DelayGroup, Icon } from "metabase/ui";
@@ -70,6 +71,8 @@ export function FilterColumnPicker({
withColumnGroupIcon = true,
withColumnItemIcon = true,
}: FilterColumnPickerProps) {
console.log("fcp");
const tc = useTranslateContent2();
const sections = useMemo(
() =>
getSections(
@@ -77,8 +80,9 @@ export function FilterColumnPicker({
stageIndexes,
withColumnGroupIcon,
withCustomExpression,
tc,
),
[query, stageIndexes, withColumnGroupIcon, withCustomExpression],
[query, stageIndexes, withColumnGroupIcon, withCustomExpression, tc],
);
const handleSectionChange = (section: Section) => {
@@ -127,7 +131,9 @@ function getSections(
stageIndexes: number[],
withColumnGroupIcon: boolean,
withCustomExpression: boolean,
tc: (msgid: string) => string,
) {
console.log("get s");
const withMultipleStages = stageIndexes.length > 1;
const columnSections = stageIndexes.flatMap((stageIndex) => {
const columns = Lib.filterableColumns(query, stageIndex);
@@ -139,13 +145,16 @@ function getSections(
const columnInfo = Lib.displayInfo(query, stageIndex, column);
return {
name: columnInfo.name,
displayName: columnInfo.displayName,
displayName: tc(columnInfo.displayName),
filterPositions: columnInfo.filterPositions,
column,
query,
stageIndex,
};
});
const sortedColumnItems = sortedByDisplayName(columnItems);
const segments = groupInfo.isSourceTable
? Lib.availableSegments(query, stageIndex)
: [];
@@ -153,7 +162,7 @@ function getSections(
const segmentInfo = Lib.displayInfo(query, stageIndex, segment);
return {
name: segmentInfo.name,
displayName: segmentInfo.displayName,
displayName: tc(segmentInfo.displayName),
filterPositions: segmentInfo.filterPositions,
segment,
stageIndex,
@@ -165,7 +174,10 @@ function getSections(
? getGroupName(groupInfo, stageIndex)
: groupInfo.displayName,
icon: withColumnGroupIcon ? getColumnGroupIcon(groupInfo) : null,
items: [...segmentItems, ...columnItems],
items: [
...sortedByDisplayName(segmentItems),
...sortedByDisplayName(columnItems),
],
};
});
});
@@ -205,3 +217,11 @@ function renderItemIcon(
function renderItemWrapper(content: ReactNode) {
return <HoverParent>{content}</HoverParent>;
}
/** Sort an array by display name, since some display names may be translated */
const sortedByDisplayName = (arr: { displayName: string }[]) => {
return arr.toSorted((a, b) =>
// FIXME: Remove locale
a.displayName.localeCompare(b.displayName, "fr"),
);
};

View File

@@ -17,6 +17,7 @@ import { FilterPickerFooter } from "../FilterPickerFooter";
import { FilterPickerHeader } from "../FilterPickerHeader";
import { WIDTH } from "../constants";
import type { FilterPickerWidgetProps } from "../types";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
export function NumberFilterPicker({
query,
@@ -27,9 +28,10 @@ export function NumberFilterPicker({
onChange,
onBack,
}: FilterPickerWidgetProps) {
const tc = useTranslateContent2();
const columnInfo = useMemo(
() => Lib.displayInfo(query, stageIndex, column),
[query, stageIndex, column],
() => Lib.displayInfo(query, stageIndex, column, tc),
[query, stageIndex, column, tc],
);
const {

View File

@@ -15,6 +15,7 @@ import { FilterPickerFooter } from "../FilterPickerFooter";
import { FilterPickerHeader } from "../FilterPickerHeader";
import { WIDTH } from "../constants";
import type { FilterPickerWidgetProps } from "../types";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
export function StringFilterPicker({
query,
@@ -29,6 +30,7 @@ export function StringFilterPicker({
() => Lib.displayInfo(query, stageIndex, column),
[query, stageIndex, column],
);
const tc = useTranslateContent2();
const {
type,
@@ -71,7 +73,7 @@ export function StringFilterPicker({
onSubmit={handleSubmit}
>
<FilterPickerHeader
columnName={columnInfo.longDisplayName}
columnName={tc(columnInfo.longDisplayName)}
onBack={onBack}
>
<FilterOperatorPicker

View File

@@ -18,6 +18,7 @@ import {
canSearchFieldValues,
isKeyColumn,
} from "./utils";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
interface FilterValuePickerProps<T> {
query: Lib.Query;
@@ -54,6 +55,8 @@ function FilterValuePicker({
[query, column],
);
const tc = useTranslateContent2();
const { data: fieldData, isLoading } = useGetFieldValuesQuery(
fieldInfo.fieldId ?? skipToken,
{ skip: !canLoadFieldValues(fieldInfo) },
@@ -85,14 +88,14 @@ function FilterValuePicker({
if (canSearchFieldValues(fieldInfo, fieldData)) {
const columnInfo = Lib.displayInfo(query, stageIndex, column);
const localizedDisplayName = tc(columnInfo.displayName);
return (
<SearchValuePicker
fieldId={checkNotNull(fieldInfo.fieldId)}
searchFieldId={checkNotNull(fieldInfo.searchFieldId)}
fieldValues={fieldData?.values ?? []}
selectedValues={selectedValues}
columnDisplayName={columnInfo.displayName}
columnDisplayName={localizedDisplayName}
shouldCreate={shouldCreate}
autoFocus={autoFocus}
onChange={onChange}

View File

@@ -18,6 +18,7 @@ import { getEffectiveOptions, getFieldOptions } from "../utils";
import S from "./ListValuePicker.module.css";
import { LONG_OPTION_LENGTH, MAX_INLINE_OPTIONS } from "./constants";
import { searchOptions } from "./utils";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
interface ListValuePickerProps {
fieldValues: FieldValue[];
@@ -50,13 +51,15 @@ function CheckboxListPicker({
autoFocus,
onChange,
}: ListValuePickerProps) {
const tc = useTranslateContent2();
const [searchValue, setSearchValue] = useState("");
const [elevatedValues] = useState(selectedValues);
const availableOptions = getEffectiveOptions(
fieldValues,
selectedValues,
elevatedValues,
);
).map((opt) => ({ ...opt, label: tc(opt.label) }));
const filteredOptions = searchOptions(availableOptions, searchValue);
const selectedValuesSet = new Set(selectedValues);
const selectedFilteredOptions = filteredOptions.filter((option) =>

View File

@@ -30,7 +30,10 @@ export function StaticValuePicker({
autoFocus={autoFocus}
aria-label={t`Filter value`}
shouldCreate={shouldCreate}
onChange={onChange}
//fixme revert
onChange={values => {
onChange(values);
}}
onFocus={onFocus}
onBlur={onBlur}
/>

View File

@@ -6,6 +6,7 @@ import * as Lib from "metabase-lib";
import type { NotebookStepProps } from "../../types";
import { ClauseStep } from "../ClauseStep";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
export function BreakoutStep({
query,
@@ -31,9 +32,10 @@ export function BreakoutStep({
const hasAddButton = !readOnly && (!isMetric || breakouts.length === 0);
const isAddButtonDisabled = isMetric && metricColumns.length === 0;
const tc = useTranslateContent2();
const renderBreakoutName = (clause: Lib.BreakoutClause) =>
Lib.displayInfo(query, stageIndex, clause).longDisplayName;
Lib.displayInfo(query, stageIndex, clause, tc).longDisplayName;
const handleAddBreakout = (column: Lib.ColumnMetadata) => {
const nextQuery = Lib.breakout(query, stageIndex, column);

View File

@@ -94,7 +94,16 @@ export const ClauseStep = <T,>({
{items.map((item, index) => (
<ClausePopover
key={index}
renderItem={(onOpen) => renderItem({ item, index, onOpen })}
renderItem={(onOpen) =>
renderItem({
item: (() => {
console.log("@m920b8ps", "item", item);
return item;
})(),
index,
onOpen,
})
}
renderPopover={(onClose) => renderPopover({ item, index, onClose })}
/>
))}

View File

@@ -9,6 +9,7 @@ import type { NotebookStepProps } from "../../types";
import { ClauseStep } from "../ClauseStep";
import S from "./SortStep.module.css";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
export function SortStep({
query,
@@ -20,6 +21,8 @@ export function SortStep({
}: NotebookStepProps) {
const { stageIndex } = step;
const tc = useTranslateContent2();
const clauses = useMemo(() => {
return Lib.orderBys(query, stageIndex);
}, [query, stageIndex]);
@@ -69,7 +72,7 @@ export function SortStep({
isLastOpened={isLastOpened}
renderName={(clause) => (
<SortDisplayName
displayInfo={Lib.displayInfo(query, stageIndex, clause)}
displayInfo={Lib.displayInfo(query, stageIndex, clause, tc)}
onToggleSortDirection={() => handleToggleOrderByDirection(clause)}
/>
)}

View File

@@ -7,6 +7,7 @@ import { Button, Flex, Icon, Popover } from "metabase/ui";
import * as Lib from "metabase-lib";
import { ClauseStep } from "../ClauseStep";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
type FilterStepProps = {
query: Lib.Query | undefined;
@@ -64,10 +65,12 @@ function FilterPopover({
hasFilters,
onChange,
}: FilterPopoverProps) {
const tc = useTranslateContent2();
const [isOpened, setIsOpened] = useState(false);
const filterInfo = filter
? Lib.displayInfo(query, stageIndex, filter)
? Lib.displayInfo(query, stageIndex, filter, tc)
: undefined;
console.log("@m920f908", "filterInfo", filterInfo);
const handleSelect = (newFilter: Lib.Filterable) => {
const newQuery = filter
@@ -92,7 +95,7 @@ function FilterPopover({
onClick={() => setIsOpened(!isOpened)}
onRemoveClick={handleRemove}
>
{filterInfo.displayName}
{tc(filterInfo.displayName)}
</FilterPill>
) : (
<AddFilterButton

View File

@@ -6,6 +6,8 @@ import { PublicOrEmbeddedQuestion } from "metabase/public/containers/PublicOrEmb
import { PublicOrEmbeddedDashboardPage } from "./public/containers/PublicOrEmbeddedDashboard";
// embedding routes are in this file
export const getRoutes = (store) => (
<Route>
<Route path="embed" component={PublicApp}>

View File

@@ -38,6 +38,7 @@ export const getInfoText = (result: WrappedResult): InfoTextData => {
const getCardInfoText = (result: WrappedResult): InfoTextData => {
if (result.dashboard) {
return {
// NOTE: We could localize the name later
label: result.dashboard.name,
icon: (
<Box ml="-1.5px" display="inherit" pos="relative" top="-0.5px">

View File

@@ -25,6 +25,9 @@ const StatusListing = () => {
useCheckActiveDownloadsBeforeUnload();
// FIXME: temporary work around for silly problem
return null;
return (
<StatusListingRoot data-testid="status-root-container">
{isAdmin && <DatabaseStatus />}

View File

@@ -1,3 +1,3 @@
export { Autocomplete } from "@mantine/core";
export type { AutocompleteProps } from "@mantine/core";
export type { AutocompleteProps, ComboboxItem } from "@mantine/core";
export { autocompleteOverrides } from "./Autocomplete.config";

View File

@@ -48,6 +48,7 @@ export function MultiAutocomplete({
onChange?.(val);
},
});
const [searchValue, setSearchValue] = useUncontrolled({
value: controlledSearchValue,
finalValue: "",

View File

@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { useCallback } from "react";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
import type { IconProps } from "metabase/ui";
import type { OnChangeCardAndRun } from "metabase/visualizations/types";
import type {
@@ -32,7 +33,8 @@ const ChartCaption = ({
getHref,
width,
}: ChartCaptionProps) => {
const title = settings["card.title"] ?? series?.[0].card.name ?? "";
const tc = useTranslateContent2();
const description = settings["card.description"];
const data = (series as TransformedSeries)._raw || series;
const card = data[0].card;
@@ -47,7 +49,7 @@ const ChartCaption = ({
return (
<ChartCaptionRoot
title={title}
title={tc(settings["card.title"] || series?.[0].card.name) ?? ""}
description={description}
getHref={getHref}
icon={icon}

View File

@@ -41,6 +41,7 @@ import {
useMemoizedCallback,
} from "metabase/hooks/use-memoized-callback";
import { getScrollBarSize } from "metabase/lib/dom";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
import { formatValue } from "metabase/lib/formatting";
import { useDispatch } from "metabase/lib/redux";
import EmbedFrameS from "metabase/public/components/EmbedFrame/EmbedFrame.module.css";
@@ -163,6 +164,8 @@ export const TableInteractiveInner = forwardRef(function TableInteractiveInner(
const isClientSideSortingEnabled = isDashboard;
const [sorting, setSorting] = useState<SortingState>([]);
const tc = useTranslateContent2();
const { rows, cols } = data;
const getColumnSortDirection = useMemo(() => {
@@ -230,7 +233,8 @@ export const TableInteractiveInner = forwardRef(function TableInteractiveInner(
return memoize((value, rowIndex) => {
const clicked = getCellClickedObject(columnIndex, rowIndex);
return formatValue(value, {
return formatValue(tc(value), {
...columnSettings,
type: "cell",
jsx: true,
@@ -239,7 +243,7 @@ export const TableInteractiveInner = forwardRef(function TableInteractiveInner(
});
});
});
}, [cols, settings, getCellClickedObject]);
}, [cols, settings, getCellClickedObject, tc]);
const handleBodyCellClick = useCallback(
(
@@ -447,9 +451,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) =>
@@ -473,7 +479,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}
@@ -534,6 +540,7 @@ export const TableInteractiveInner = forwardRef(function TableInteractiveInner(
settings,
tableTheme,
isDashboard,
tc,
]);
const handleColumnResize = useCallback(

View File

@@ -0,0 +1,281 @@
import cx from "classnames";
import { getIn } from "icepick";
import {
forwardRef,
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import _ from "underscore";
import ExplicitSize from "metabase/components/ExplicitSize";
import { Ellipsified } from "metabase/core/components/Ellipsified";
import CS from "metabase/css/core/index.css";
import DashboardS from "metabase/css/dashboard.module.css";
import { isPositiveInteger } from "metabase/lib/number";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
import { isPositiveInteger } from "metabase/lib/number";
import EmbedFrameS from "metabase/public/components/EmbedFrame/EmbedFrame.module.css";
import { isColumnRightAligned } from "metabase/visualizations/lib/table";
import type { ClickObject } from "metabase-lib";
import { isID } from "metabase-lib/v1/types/utils/isa";
import type {
Card,
DatasetColumn,
DatasetData,
RowValue,
Series,
VisualizationSettings,
} from "metabase-types/api";
import { TableCell } from "./TableCell";
import TableFooter from "./TableFooter";
import {
ContentContainer,
Root,
SortIcon,
Table,
TableContainer,
TableHeaderCellContent,
} from "./TableSimple.styled";
function getBoundingClientRectSafe(ref: {
current?: HTMLElement | null;
}): Partial<DOMRect> {
return ref.current?.getBoundingClientRect?.() ?? {};
}
function formatCellValueForSorting(value: RowValue, column: DatasetColumn) {
if (typeof value === "string") {
if (isID(column) && /^\d+$/.test(value)) {
return parseInt(value, 10);
}
// for strings we should be case insensitive
return value.toLowerCase();
}
if (value === null) {
return undefined;
}
return value;
}
interface TableSimpleProps {
card: Card;
data: DatasetData;
series: Series;
settings: VisualizationSettings;
height: number;
isDashboard?: boolean;
isEditing?: boolean;
isPivoted: boolean;
className?: string;
getColumnTitle: (colIndex: number) => string;
getExtraDataForClick: (clickObject: ClickObject) => Record<string, unknown>;
onVisualizationClick?: (clickObject: ClickObject) => void;
visualizationIsClickable?: (clickObject: ClickObject) => boolean;
}
const TableSimpleInner = forwardRef<HTMLDivElement, TableSimpleProps>(
function TableSimpleInner(
{
card,
data,
series,
settings,
height,
isPivoted,
className,
onVisualizationClick,
visualizationIsClickable,
getColumnTitle,
getExtraDataForClick,
}: TableSimpleProps,
ref,
) {
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(1);
const [sortColumn, setSortColumn] = useState<number | null>(null);
const [sortDirection, setSortDirection] = useState("asc");
const headerRef = useRef(null);
const footerRef = useRef(null);
const firstRowRef = useRef(null);
useLayoutEffect(() => {
const { height: headerHeight = 0 } = getBoundingClientRectSafe(headerRef);
const { height: footerHeight = 0 } = getBoundingClientRectSafe(footerRef);
const { height: rowHeight = 0 } = getBoundingClientRectSafe(firstRowRef);
const currentPageSize = Math.floor(
(height - headerHeight - footerHeight) / (rowHeight + 1),
);
const normalizedPageSize = Math.max(1, currentPageSize);
if (pageSize !== normalizedPageSize) {
setPageSize(normalizedPageSize);
}
}, [height, pageSize]);
const setSort = useCallback(
(colIndex: number) => {
if (sortColumn === colIndex) {
setSortDirection(direction => (direction === "asc" ? "desc" : "asc"));
} else {
setSortColumn(colIndex);
}
},
[sortColumn],
);
const checkIsVisualizationClickable = useCallback(
(clickedItem: ClickObject) => {
return Boolean(
onVisualizationClick &&
visualizationIsClickable &&
visualizationIsClickable(clickedItem),
);
},
[onVisualizationClick, visualizationIsClickable],
);
const { rows, cols } = data;
const limit = getIn(card, ["dataset_query", "query", "limit"]) || undefined;
const getCellBackgroundColor = settings["table._cell_background_getter"];
const start = pageSize * page;
const end = Math.min(rows.length - 1, pageSize * (page + 1) - 1);
const handlePreviousPage = useCallback(() => {
setPage(p => p - 1);
}, []);
const handleNextPage = useCallback(() => {
setPage(p => p + 1);
}, []);
const rowIndexes = useMemo(() => {
let indexes = _.range(0, rows.length);
if (sortColumn != null) {
indexes = _.sortBy(indexes, rowIndex => {
const value = rows[rowIndex][sortColumn];
const column = cols[sortColumn];
return formatCellValueForSorting(value, column);
});
}
if (sortDirection === "desc") {
indexes.reverse();
}
return indexes;
}, [cols, rows, sortColumn, sortDirection]);
const paginatedRowIndexes = useMemo(
() => rowIndexes.slice(start, end + 1),
[rowIndexes, start, end],
);
const renderColumnHeader = useCallback(
(col: DatasetColumn, colIndex: number) => {
const iconName = sortDirection === "desc" ? "chevrondown" : "chevronup";
const onClick = () => setSort(colIndex);
return (
<th key={colIndex} data-testid="column-header">
<TableHeaderCellContent
isSorted={colIndex === sortColumn}
onClick={onClick}
isRightAligned={isColumnRightAligned(col)}
>
<Ellipsified>{getColumnTitle(colIndex)}</Ellipsified>
<SortIcon name={iconName} />
</TableHeaderCellContent>
</th>
);
},
[sortColumn, sortDirection, getColumnTitle, setSort],
);
const tc = useTranslateContent2();
const renderRow = useCallback(
(rowIndex: number, index: number) => {
const ref = index === 0 ? firstRowRef : null;
return (
<tr
key={rowIndex}
ref={ref}
data-testid="table-row"
data-allow-page-break-after
>
{data.rows[rowIndex].map((value, columnIndex) => {
return (
<TableCell
key={`${rowIndex}-${columnIndex}`}
value={tc(value)}
data={data}
series={series}
settings={settings}
rowIndex={rowIndex}
columnIndex={columnIndex}
isPivoted={isPivoted}
getCellBackgroundColor={getCellBackgroundColor}
getExtraDataForClick={getExtraDataForClick}
checkIsVisualizationClickable={checkIsVisualizationClickable}
onVisualizationClick={onVisualizationClick}
/>
);
})}
</tr>
);
},
[
data,
series,
settings,
isPivoted,
checkIsVisualizationClickable,
getCellBackgroundColor,
getExtraDataForClick,
onVisualizationClick,
tc,
],
);
return (
<Root className={className} ref={ref}>
<ContentContainer>
<TableContainer className={cx(CS.scrollShow, CS.scrollShowHover)}>
<Table
className={cx(
DashboardS.fullscreenNormalText,
DashboardS.fullscreenNightText,
EmbedFrameS.fullscreenNightText,
)}
>
<thead ref={headerRef}>
<tr>{cols.map(renderColumnHeader)}</tr>
</thead>
<tbody>{paginatedRowIndexes.map(renderRow)}</tbody>
</Table>
</TableContainer>
</ContentContainer>
{pageSize < rows.length && (
<TableFooter
start={start}
end={end}
limit={limit}
total={rows.length}
onPreviousPage={handlePreviousPage}
onNextPage={handleNextPage}
ref={footerRef}
/>
)}
</Root>
);
},
);
export const TableSimple = ExplicitSize<TableSimpleProps>({
refreshMode: props =>
props.isDashboard && !props.isEditing ? "debounceLeading" : "throttle",
})(TableSimpleInner);

View File

@@ -10,6 +10,7 @@ import type { AccentColorOptions } from "metabase/lib/colors/types";
import type { IconProps } from "metabase/ui";
import { ColumnItem } from "../ColumnItem";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
export interface SortableItem {
enabled: boolean;

View File

@@ -17,6 +17,7 @@ import {
moveColumnInSettings,
toggleColumnInSettings,
} from "./utils";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
interface TableColumnPanelProps {
columns: DatasetColumn[];
@@ -33,15 +34,16 @@ export const TableColumnPanel = ({
onChange,
onShowWidget,
}: TableColumnPanelProps) => {
const tc = useTranslateContent2();
const columnItems = useMemo(() => {
return getColumnItems(columns, columnSettings);
}, [columns, columnSettings]);
return getColumnItems(columns, columnSettings, tc);
}, [columns, columnSettings, tc]);
const getItemName = useCallback(
(columnItem: ColumnItem) => {
return getColumnName(columnItem.column);
return tc(getColumnName(columnItem.column));
},
[getColumnName],
[getColumnName, tc],
);
const handleEnableColumn = useCallback(

View File

@@ -14,6 +14,7 @@ import type { ColumnItem } from "./types";
export function getColumnItems(
columns: DatasetColumn[],
columnSettings: TableColumnOrderSetting[],
tc?: TCFunc,
): ColumnItem[] {
const columnIndexes = findColumnIndexesForColumnSettings(
columns,
@@ -24,8 +25,10 @@ export function getColumnItems(
const columnIndex = columnIndexes[columnSettingIndex];
const column = columns[columnIndex];
column.display_name = tc ? tc(column.display_name) : column.display_name;
return {
name: column.name,
name: tc ? column.name : column.name,
enabled: columnSetting.enabled,
index: columnSettingIndex,
icon: getIconForField(column) as IconName,

View File

@@ -22,6 +22,7 @@ import { getScaledMinAndMax } from "../model/axis";
import { isNumericAxis, isTimeSeriesAxis } from "../model/guards";
import { getTicksOptions } from "./ticks";
import { TCFunc } from "metabase/i18n/components/ContentTranslationContext";
const NORMALIZED_RANGE = { min: 0, max: 1 };
@@ -209,7 +210,10 @@ export const buildDimensionAxis = (
chartMeasurements: ChartMeasurements,
hasTimelineEvents: boolean,
renderingContext: RenderingContext,
tc?: TCFunc,
): XAXisOption => {
console.log("@m9251beg", "tc in dimensionaxis", tc);
const xAxisModel = chartModel.xAxisModel;
if (isNumericAxis(xAxisModel)) {
@@ -237,6 +241,7 @@ export const buildDimensionAxis = (
settings,
chartMeasurements,
renderingContext,
tc,
);
};
@@ -330,6 +335,7 @@ export const buildCategoricalDimensionAxis = (
originalSettings: ComputedVisualizationSettings,
chartMeasurements: ChartMeasurements,
renderingContext: RenderingContext,
tc?: TCFunc,
): XAXisOption => {
const {
xAxisModel: { formatter },
@@ -364,8 +370,8 @@ export const buildCategoricalDimensionAxis = (
if (isNumericBaseType(column) && numberValue !== null) {
return ` ${formatter(numberValue)} `;
}
return ` ${formatter(value)} `; // spaces force padding between ticks
const translatedValue = tc?.(value) || value;
return ` ${formatter(translatedValue)} `; // spaces force padding between ticks
},
},
};
@@ -473,6 +479,7 @@ export const buildAxes = (
settings: ComputedVisualizationSettings,
hasTimelineEvents: boolean,
renderingContext: RenderingContext,
tc?: TCFunc,
) => {
return {
xAxis: buildDimensionAxis(
@@ -482,6 +489,7 @@ export const buildAxes = (
chartMeasurements,
hasTimelineEvents,
renderingContext,
tc,
),
yAxis: buildMetricsAxes(
chartModel,

View File

@@ -23,6 +23,7 @@ import { getBarSeriesDataLabelKey } from "../model/util";
import { getGoalLineSeriesOption } from "./goal-line";
import { getTrendLinesOption } from "./trend-line";
import { TCFunc } from "metabase/i18n/components/ContentTranslationContext";
export const getSharedEChartsOptions = (isAnimated: boolean) => ({
useUTC: true,
@@ -49,6 +50,7 @@ export const getCartesianChartOption = (
chartWidth: number,
isAnimated: boolean,
renderingContext: RenderingContext,
tc?: TCFunc,
): EChartsCoreOption => {
const hasTimelineEvents = timelineEventsModel != null;
const timelineEventsSeries = hasTimelineEvents
@@ -66,6 +68,7 @@ export const getCartesianChartOption = (
chartMeasurements,
renderingContext,
);
const goalSeriesOption = getGoalLineSeriesOption(
chartModel,
settings,
@@ -130,6 +133,7 @@ export const getCartesianChartOption = (
settings,
hasTimelineEvents,
renderingContext,
tc,
),
};
};

View File

@@ -10,6 +10,7 @@ import {
import { useSet } from "react-use";
import { isWebkit } from "metabase/lib/browser";
import { useTranslateContent } from "metabase/i18n/components/ContentTranslationContext";
import { ChartRenderingErrorBoundary } from "metabase/visualizations/components/ChartRenderingErrorBoundary";
import { ResponsiveEChartsRenderer } from "metabase/visualizations/components/EChartsRenderer";
import { LegendCaption } from "metabase/visualizations/components/legend/LegendCaption";
@@ -80,6 +81,8 @@ function _CartesianChart(props: VisualizationProps) {
const chartRef = useRef<EChartsType>();
const tc = useTranslateContent();
const title = settings["card.title"] || tc(card, "name");
const description = settings["card.description"];
const legendItems = useMemo(
@@ -128,6 +131,8 @@ function _CartesianChart(props: VisualizationProps) {
props,
);
console.log("@m92432dk", "chartModel", chartModel);
const handleResize = useCallback((width: number, height: number) => {
setChartSize({ width, height });
}, []);
@@ -140,11 +145,13 @@ function _CartesianChart(props: VisualizationProps) {
useCloseTooltipOnScroll(chartRef);
console.log("@m924deag", "title", title);
return (
<CartesianChartRoot isQueryBuilder={isQueryBuilder}>
{showTitle && (
<LegendCaption
title={settings["card.title"]}
title={title}
description={description}
icon={headerIcon}
actionButtons={actionButtons}

View File

@@ -19,6 +19,7 @@ import { getWaterfallChartOption } from "metabase/visualizations/echarts/cartesi
import { useBrowserRenderingContext } from "metabase/visualizations/hooks/use-browser-rendering-context";
import type { VisualizationProps } from "metabase/visualizations/types";
import type { CardDisplayType } from "metabase-types/api";
import { useTranslateContent2 } from "metabase/i18n/components/ContentTranslationContext";
export function useModelsAndOption(
{
@@ -38,6 +39,9 @@ export function useModelsAndOption(
}: VisualizationProps,
containerRef: React.RefObject<HTMLDivElement>,
) {
const tc = useTranslateContent2();
console.log("@m9252gao", "tc in usemodelsandoption", tc);
const renderingContext = useBrowserRenderingContext({ fontFamily });
const rawSeriesWithRemappings = useMemo(
@@ -69,13 +73,19 @@ export function useModelsAndOption(
getModel = getScatterPlotModel;
}
return getModel(
const model = getModel(
seriesToRender,
settings,
Array.from(hiddenSeries),
renderingContext,
showWarning,
);
model.xAxisModel.label = tc(model.xAxisModel.label);
model.dimensionModel.column.display_name = tc(
model.dimensionModel.column.display_name,
);
return model;
}, [
card.display,
seriesToRender,
@@ -83,6 +93,7 @@ export function useModelsAndOption(
hiddenSeries,
renderingContext,
showWarning,
tc,
]);
const chartMeasurements = useMemo(
@@ -174,6 +185,7 @@ export function useModelsAndOption(
width,
shouldAnimate,
renderingContext,
tc,
);
}
@@ -193,6 +205,7 @@ export function useModelsAndOption(
selectedOrHoveredTimelineEventIds,
settings,
renderingContext,
tc,
]);
return { chartModel, timelineEventsModel, option };

View File

@@ -11021,6 +11021,46 @@ databaseChangeLog:
name: card_schema
indexName: idx_report_card_card_schema
- changeSet:
id: v55.2025-04-02T11: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(10)
constraints:
nullable: false
- column:
name: msgid
remarks: The raw string
type: varchar(255)
constraints:
nullable: false
- column:
name: msgstr
remarks: The translation
type: varchar(255)
constraints:
nullable: true
# >>>>>>>>>> DO NOT ADD NEW MIGRATIONS BELOW THIS LINE! ADD THEM ABOVE <<<<<<<<<<
########################################################################################################################

View File

@@ -0,0 +1,94 @@
(ns metabase.api.dictionary
"Endpoints relating to the translation of user-generated content"
(:require
[clojure.data.csv :as csv]
[clojure.java.io :as io]
[clojure.string :as str]
[java-time.api :as t]
[metabase.api.macros :as api.macros]
[metabase.db.query :as mdb.query]
[metabase.models.content-translation :as ct]
[metabase.server.streaming-response :as sr]
[metabase.util.date-2 :as u.date]
[metabase.util.json :as json]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2])
(:import
(java.io BufferedWriter OutputStreamWriter)
(java.nio.charset StandardCharsets)))
(defn- format-csv-to-stream [os display-names locales]
(let [; If no locales were provided, we'll write all the rows out once with an empty locale
locales (if (empty? locales) [""] locales)
writer (BufferedWriter. (OutputStreamWriter. os StandardCharsets/UTF_8))
headers ["Language" "String" "Translation"]]
(try
(csv/write-csv writer [headers])
(doseq [locale locales]
(doseq [display-name display-names]
(csv/write-csv writer [[locale display-name ""]])))
(.flush writer)
(finally
(.close writer)))))
(api.macros/defendpoint :post "/csv"
"Provides content translation dictionary in CSV"
[_route-params
_query-params
{:keys [locales] :as _body} :- [:map
[:locales ms/NonBlankString]]]
(let [locales-list (str/split locales #"-")]
(sr/streaming-response {:content-type "text/csv; charset=utf-8"
:status 200
:headers {"Content-Disposition" (format "attachment; filename=\"content_dictionary_%s.csv\""
(u.date/format (t/zoned-date-time)))}}
[os canceled-chan]
(format-csv-to-stream os (ct/get-all-display-names) locales-list))))
(defn import-translations!
"Import translations from CSV and insert or update rows in the content_translation table."
; TODO: Let the user specify the table should be truncated before new translations are inserted
[{:keys [_filename file]}]
(with-open [reader (io/reader file)]
(let [csv-data (rest (csv/read-csv reader))]
(doseq [[locale msgid msgstr] csv-data]
(let [trimmed-msgstr (str/trim msgstr)
trimmed-msgid (str/trim msgid)]
(when-not (str/blank? trimmed-msgstr)
(t2/with-transaction [_tx]
(mdb.query/update-or-insert! :model/ContentTranslation
{:locale locale :msgid trimmed-msgid}
(constantly {:locale locale
:msgid trimmed-msgid
:msgstr trimmed-msgstr})))))))))
(api.macros/defendpoint :post
"/upload"
"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)]]]]]]]
(import-translations! {:filename (get-in multipart-params ["file" :filename])
:file (get-in multipart-params ["file" :tempfile])})
(do
{:status 200
:headers {"Content-Type" "application/json"}
; TODO: Describe exactly what was changed
:body (json/encode {:success true
:message "Import was successful"})}))
#_{:clj-kondo/ignore [:deprecated-var]}
(api.macros/defendpoint :get "/"
"Provides content translations stored in the content_translations table"
[]
{:data (ct/get-translations)})
(set! *warn-on-reflection* true)

View File

@@ -11,6 +11,7 @@
[metabase.api.dashboard]
[metabase.api.database]
[metabase.api.dataset]
[metabase.api.dictionary]
[metabase.api.docs]
[metabase.api.embed]
[metabase.api.field]
@@ -143,6 +144,7 @@
"/dashboard" (+auth 'metabase.api.dashboard)
"/database" (+auth 'metabase.api.database)
"/dataset" 'metabase.api.dataset
"/dictionary" (+auth 'metabase.api.dictionary)
"/docs" (metabase.api.docs/make-routes #'routes)
"/email" metabase.channel.api/email-routes
"/embed" (+message-only-exceptions 'metabase.api.embed)

View File

@@ -25,6 +25,7 @@
[metabase.lib.util.match :as lib.util.match]
[metabase.util :as u]
[metabase.util.i18n :as i18n]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.number :as u.number]
[metabase.util.time :as u.time]))

View File

@@ -0,0 +1,79 @@
(def ContentTranslationModel
"Model for dictionary entries containing translations"
[:enum "language" "msgid" "translation"])
(ns metabase.models.content-translation
"A model representing dictionary entries for translations."
(:require
[metabase.util.json :as json]
[metabase.util.log :as log]
[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-column-display-names
"List the display names of all columns"
[]
(distinct (map :display_name
(t2/query {:select [:f.display_name]
:from [[(t2/table-name :model/Field) :f]]}))))
(defn get-table-display-names
"List the display names of all tables"
[]
(distinct (map :display_name
(t2/query {:select [:t.display_name]
:from [[(t2/table-name :model/Table) :t]]}))))
(defn get-field-values
"List all field values"
[]
(let [field-values (t2/query {:select [:fv.values]
:from [[(t2/table-name :model/FieldValues) :fv]]})]
(->> field-values
(map (comp json/decode str :values))
(mapcat identity)
(distinct))))
(defn get-all-display-names
"Concatenate and return display names from columns, tables, and field values."
[]
(let [column-names (get-column-display-names)
table-names (get-table-display-names)
field-values (get-field-values)
all-names (concat column-names table-names field-values)]
(distinct (filter string? all-names))))
(defn get-translations
"List the translations stored in the content_translation table"
[]
(t2/query {:select [:*]
:from [[(t2/table-name :model/ContentTranslation) :t]]}))
; (defn translate-string
; [msgid]
; (let [translations (get-translations)
; translation (some (fn [entry]
; (when (= msgid (:msgid entry))
; (:translation entry)))
; translations)]
; (or translation msgid)))
(defn translate-string
"Return the translation of the given string from the database, or if there is no translation, return the string itself. Currently the locale is hard-coded to French"
[msgid]
(let [result (t2/query {:select [:translation]
:from [[(t2/table-name :model/ContentTranslation) :t]]
:where [:and
[:= :msgid msgid]
[:= :language "fr"]]})
translation (first result)
ret (or (:translation translation) msgid)]
(log/info (str "Translation lookup for msgid \"" msgid "\" resulted in: \"" ret "\""))
ret))

View File

@@ -0,0 +1,141 @@
(ns metabase.query-processor.middleware.translate-content
"Middleware for handling conversion of integers to strings for proper display of large numbers"
(:require
[metabase.models.content-translation :as ct]
[metabase.util.i18n.impl :as i18n.impl]
[metabase.util.log :as log]
[metabase.util.performance :as perf]))
(set! *warn-on-reflection* true)
(defn- translate-row
"Converts all large integer row values to strings."
[rf translation-map]
(let [lookup-translation
(fn [msgid]
(or (get translation-map msgid) msgid))]
((map (fn [row]
(perf/mapv lookup-translation row)))
rf)))
(defn build-translation-map
"Build a translation map from the content_translation table"
[]
(let [translations (ct/get-translations)
locale (i18n.impl/site-locale-from-setting) ; TODO: support user locale
translations-for-locale (filter #(= (:locale %) locale) translations)]
(log/info "locale" locale)
(log/info "Translations for locale:" translations-for-locale)
(reduce (fn [acc {:keys [msgid msgstr]}]
(assoc acc msgid msgstr))
{}
translations-for-locale)))
(defn translate-content
"Translate content"
[_query rff]
(let [translation-map (build-translation-map)
rff' (fn [metadata] (translate-row (rff metadata) translation-map))]
(log/info "translation map:" translation-map)
(log/info "Translating content")
(or rff' rff)))
;;; # topic: Translation lookup implementation
;;;
;;; - file: 2025-03-24.16-07-05.907.md
;;;
;;; Write your queries after 💬:. Use `<C-g><C-g>` or :GpChatRespond to generate a response.
;;; Response generation can be terminated by using `<C-g>s` or :GpChatStop command.
;;; Chats are saved automatically. To delete this chat, use `<C-g>d` or :GpChatDelete.
;;; Be cautious of very long chats. Start a fresh chat by using `<C-g>c` or :GpChatNew.
;;;
;;; ---
;;;
;;; 💬:
;;;
;;; I have this clojure function that retrieves translations.
;;;
;;; (defn get-translations
;;; "List the translations stored in the content_translation table"
;;; []
;;; (t2/query {:select [:*]
;;; :from [[(t2/table-name :model/ContentTranslation) :t]]}))
;;;
;;; Each row in content translation table just has these fields:
;;;
;;; locale (e.g. "fr")
;;; msgid
;;; msgstr
;;;
;;; I now have a translation function that currently doesn't do anything, it just adds "a" to the string
;;;
;;; (defn translate-value
;;; "Converts large integer values to strings and leaves other values unchanged."
;;; [x]
;;; (if (string? x)
;;; (str x "a")
;;; x))
;;;
;;; Let's change this function so that it takes the result of get-translations and then looks up the translation in it
;;;
;;; 🤖:[ChatGPT4o]
;;; To make use of the translations retrieved by `get-translations`, you should first consider how you would like to store and access these translations. A common approach is to build a lookup map, where the keys are the `msgid` and the values are `msgstr`, perhaps indexed by `locale`. Here’s a step-by-step approach to achieve this.
;;;
;;; 1. Fetch the translations from the `content_translation` table.
;;; 2. Construct a map (or a nested map) to facilitate quick lookup of translations using both `locale` and `msgid`.
;;; 3. Enhance the `translate-value` function to use this map to look up the appropriate translation based on a provided locale and message id.
;;;
;;; Let’s outline the detailed steps and code for this transformation:
;;;
;;; ### Step 1: Modify `get-translations` to Return the Translations
;;;
;;; Ensure the `get-translations` function is converted to a map structure that's optimized for lookups.
;;;
;;; ### Step 2: Create the Translation Map
;;;
;;; We'll generate a nested map in the form `{locale {msgid msgstr}}`.
;;;
;;; ```clojure
;;; (defn build-translation-map []
;;; (let [translations (get-translations)]
;;; (reduce (fn [acc {:keys [locale msgid msgstr]}]
;;; (assoc-in acc [locale msgid] msgstr))
;;; {}
;;; translations)))
;;; ```
;;;
;;; ### Step 3: Refactor `translate-value` to Utilize the Translation Map
;;;
;;; Let’s update `translate-value` to use this map. It will take an additional argument, `locale`, to decide which translation to use.
;;;
;;; ```clojure
;;; (def translations (build-translation-map))
;;;
;;; (defn translate-value
;;; "Translates a message string based on its translation id (msgid) and a specified locale
;;; using pre-fetched translations, leaves non-string or untranslated values unchanged."
;;; [locale msgid]
;;; (get-in translations [locale msgid] msgid))
;;; ```
;;;
;;; ### Explanation of `translate-value` Function:
;;;
;;; - **Parameters**
;;; - `locale`: The locale for which translation is desired.
;;; - `msgid`: The original message ID or string you want to translate.
;;;
;;; - **Functionality**:
;;; - It attempts to look up the translation using `get-in` on the nested map.
;;; - If a translation is found, it returns the translated string. If not, it provides the original `msgid`.
;;; - This setup assumes `translations` is initialized once and reused in your application lifecycle.
;;;
;;; ### Considerations
;;;
;;; - Ensure that `get-translations` retrieves the latest translations whenever updates to translations can happen. You might need to periodically regenerate `translations` or recreate it each time `translate-value` is called.
;;; - You may need extra functions to support cases like handling default locales or fallbacks when a particular translation is missing.
;;;
;;; Would you like to process alternative approaches or further modifications?
;;;
;;;
;;; 💬:
;;;

View File

@@ -14,6 +14,7 @@
[metabase.query-processor.middleware.limit :as limit]
[metabase.query-processor.middleware.pivot-export :as pivot-export]
[metabase.query-processor.middleware.results-metadata :as results-metadata]
[metabase.query-processor.middleware.translate-content :as translate-content]
[metabase.query-processor.middleware.visualization-settings :as viz-settings]
[metabase.query-processor.setup :as qp.setup]
[metabase.util :as u]