Compare commits
10 Commits
python-run
...
content-tr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
870e3dc29f | ||
|
|
3c97bd9c3c | ||
|
|
b97b3e831a | ||
|
|
ad3f72c58e | ||
|
|
b65f416ad5 | ||
|
|
67cff0b1bb | ||
|
|
d5a478f5ed | ||
|
|
7c18dc7299 | ||
|
|
3846821b21 | ||
|
|
6d9d90bb60 |
12
e2e/support/helpers/e2e-homepage-helpers.js
Normal file
12
e2e/support/helpers/e2e-homepage-helpers.js
Normal 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");
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -483,6 +483,7 @@ class Question {
|
||||
}
|
||||
|
||||
dashboardName(): string | undefined {
|
||||
// NOTE: We could localize the name later
|
||||
return this._card?.dashboard?.name ?? undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
55
frontend/src/metabase/api/content-translation.ts
Normal file
55
frontend/src/metabase/api/content-translation.ts
Normal 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;
|
||||
@@ -4,6 +4,7 @@ export const TAG_TYPES = [
|
||||
"action",
|
||||
"alert",
|
||||
"api-key",
|
||||
"content-translation",
|
||||
"bookmark",
|
||||
"card",
|
||||
"channel",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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]),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 })),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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("")}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -55,6 +55,7 @@ interface Props<Entity, EntityWrapper> {
|
||||
wrapped?: boolean;
|
||||
}
|
||||
|
||||
// NOTE: Perhaps we could localize here
|
||||
const defaultTransformResponse = (data: unknown, _query: EntityQuery) => data;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) ?? ""}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
15
frontend/src/metabase/i18n/types.ts
Normal file
15
frontend/src/metabase/i18n/types.ts
Normal 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;
|
||||
};
|
||||
55
frontend/src/metabase/i18n/utils.ts
Normal file
55
frontend/src/metabase/i18n/utils.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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…`
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -48,6 +48,7 @@ export function MultiAutocomplete({
|
||||
onChange?.(val);
|
||||
},
|
||||
});
|
||||
|
||||
const [searchValue, setSearchValue] = useUncontrolled({
|
||||
value: controlledSearchValue,
|
||||
finalValue: "",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 <<<<<<<<<<
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
94
src/metabase/api/dictionary.clj
Normal file
94
src/metabase/api/dictionary.clj
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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]))
|
||||
|
||||
79
src/metabase/models/content_translation.clj
Normal file
79
src/metabase/models/content_translation.clj
Normal 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))
|
||||
141
src/metabase/query_processor/middleware/translate_content.clj
Normal file
141
src/metabase/query_processor/middleware/translate_content.clj
Normal 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?
|
||||
;;;
|
||||
;;;
|
||||
;;; 💬:
|
||||
;;;
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user