From efc1783f11faa0a67e1924fe747a82b2dcc04381 Mon Sep 17 00:00:00 2001 From: Luiz Braz Date: Fri, 8 May 2026 17:50:44 -0300 Subject: [PATCH 01/19] Add Switch language to the user's profile --- app/package.json | 3 + app/src/databricks_labs_dqx_app/_metadata.py | 2 +- .../ui/components/AIAssistantProvider.tsx | 9 +- .../ui/components/AICheckGenerator.tsx | 36 +- .../ui/components/AuthGuard.tsx | 27 +- .../ui/components/CatalogBrowser.tsx | 38 +- .../ui/components/ColumnDiscoveryPanel.tsx | 52 +- .../ui/components/CommentThread.tsx | 14 +- .../ui/components/DryRunResults.tsx | 49 +- .../ui/components/Labels.tsx | 69 +- .../ui/components/RoleManagement.tsx | 115 +- .../ui/components/RulesReview.tsx | 30 +- .../ui/components/apx/HeaderUserMenu.tsx | 6 +- .../ui/components/apx/LanguageSelector.tsx | 65 + .../ui/components/apx/PageBreadcrumb.tsx | 4 +- .../ui/components/apx/SidebarLayout.tsx | 4 +- .../ui/components/apx/SidebarUserFooter.tsx | 4 +- .../ui/components/apx/mode-toggle.tsx | 4 +- .../ui/lib/i18n/index.ts | 44 + .../ui/lib/i18n/locales/en.json | 1253 +++++++++++++++++ .../ui/lib/i18n/locales/es.json | 1253 +++++++++++++++++ .../ui/lib/i18n/locales/it.json | 1253 +++++++++++++++++ .../ui/lib/i18n/locales/pt-BR.json | 1253 +++++++++++++++++ app/src/databricks_labs_dqx_app/ui/main.tsx | 1 + .../ui/routes/_sidebar/config.tsx | 92 +- .../ui/routes/_sidebar/discovery.tsx | 75 +- .../ui/routes/_sidebar/home.tsx | 23 +- .../ui/routes/_sidebar/profile.tsx | 106 +- .../ui/routes/_sidebar/profiler.tsx | 226 +-- .../ui/routes/_sidebar/route.tsx | 20 +- .../ui/routes/_sidebar/rules.active.tsx | 124 +- .../ui/routes/_sidebar/rules.create-sql.tsx | 145 +- .../ui/routes/_sidebar/rules.create.tsx | 62 +- .../ui/routes/_sidebar/rules.drafts.tsx | 251 ++-- .../ui/routes/_sidebar/rules.import.tsx | 123 +- .../ui/routes/_sidebar/rules.single-table.tsx | 475 +++---- .../ui/routes/_sidebar/runs-history.tsx | 126 +- .../ui/routes/_sidebar/runs.tsx | 494 ++++--- 38 files changed, 6604 insertions(+), 1326 deletions(-) create mode 100644 app/src/databricks_labs_dqx_app/ui/components/apx/LanguageSelector.tsx create mode 100644 app/src/databricks_labs_dqx_app/ui/lib/i18n/index.ts create mode 100644 app/src/databricks_labs_dqx_app/ui/lib/i18n/locales/en.json create mode 100644 app/src/databricks_labs_dqx_app/ui/lib/i18n/locales/es.json create mode 100644 app/src/databricks_labs_dqx_app/ui/lib/i18n/locales/it.json create mode 100644 app/src/databricks_labs_dqx_app/ui/lib/i18n/locales/pt-BR.json diff --git a/app/package.json b/app/package.json index fce6c3d01..7756594a4 100644 --- a/app/package.json +++ b/app/package.json @@ -25,12 +25,15 @@ "axios": "^1.13.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "i18next": "^26.0.8", + "i18next-browser-languagedetector": "^8.2.1", "js-yaml": "^4.1.1", "lucide-react": "^0.548.0", "motion": "^12.23.24", "react": "^19.2.0", "react-dom": "^19.2.0", "react-error-boundary": "^6.0.0", + "react-i18next": "^17.0.6", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tw-animate-css": "^1.4.0" diff --git a/app/src/databricks_labs_dqx_app/_metadata.py b/app/src/databricks_labs_dqx_app/_metadata.py index b517c9e11..c4f660c7f 100644 --- a/app/src/databricks_labs_dqx_app/_metadata.py +++ b/app/src/databricks_labs_dqx_app/_metadata.py @@ -1,4 +1,4 @@ app_name = "DQX Studio" app_module = "databricks_labs_dqx_app.backend.app:app" app_slug = "databricks_labs_dqx_app" -api_prefix = "/api" +api_prefix = "/api" \ No newline at end of file diff --git a/app/src/databricks_labs_dqx_app/ui/components/AIAssistantProvider.tsx b/app/src/databricks_labs_dqx_app/ui/components/AIAssistantProvider.tsx index f8555bf39..493048529 100644 --- a/app/src/databricks_labs_dqx_app/ui/components/AIAssistantProvider.tsx +++ b/app/src/databricks_labs_dqx_app/ui/components/AIAssistantProvider.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useState, useCallback, type ReactNode } from "react"; import { Sparkles } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Sheet, @@ -32,6 +33,7 @@ export function useAIAssistant() { } export function AIAssistantTrigger() { + const { t } = useTranslation(); const { setOpen } = useAIAssistant(); return ( ); } export function AIAssistantProvider({ children }: { children: ReactNode }) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); const [isGenerating, setIsGenerating] = useState(false); const [runContext, setRunContext] = useState(null); @@ -71,9 +74,9 @@ export function AIAssistantProvider({ children }: { children: ReactNode }) { - AI Rules Assistant + {t("aiAssistant.title")} - Generate data quality rules using AI + {t("aiAssistant.description")} (null); const [copied, setCopied] = useState(false); const handleGenerate = async () => { if (!userInput.trim()) { - toast.error("Please enter a description of your data quality requirements"); + toast.error(t("aiCheckGenerator.enterDescriptionFirst")); return; } try { const result = await onGenerate(userInput, runContext?.yaml); setGeneratedYaml(result.yaml_output); - toast.success("Checks generated successfully!"); + toast.success(t("aiCheckGenerator.checksGenerated")); } catch (error) { - toast.error("Failed to generate checks. Please try again."); + toast.error(t("aiCheckGenerator.failedGenerate")); } }; @@ -39,7 +41,7 @@ export function AICheckGenerator({ onGenerate, isGenerating, runContext }: AIChe if (generatedYaml) { navigator.clipboard.writeText(generatedYaml); setCopied(true); - toast.success("YAML copied to clipboard"); + toast.success(t("aiCheckGenerator.yamlCopied")); setTimeout(() => setCopied(false), 2000); } }; @@ -59,9 +61,9 @@ export function AICheckGenerator({ onGenerate, isGenerating, runContext }: AIChe
-

AI-Assisted Rules Generation

+

{t("aiCheckGenerator.title")}

- Describe your data quality needs + {t("aiCheckGenerator.subtitle")}

@@ -71,7 +73,7 @@ export function AICheckGenerator({ onGenerate, isGenerating, runContext }: AIChe
- Using context from: {runContext.runName} + {t("aiCheckGenerator.usingContextFrom", { name: runContext.runName })}
)} @@ -90,7 +92,7 @@ export function AICheckGenerator({ onGenerate, isGenerating, runContext }: AIChe >

- Generated Checks + {t("aiCheckGenerator.generatedChecks")}

@@ -129,11 +131,11 @@ export function AICheckGenerator({ onGenerate, isGenerating, runContext }: AIChe
-

No rules generated yet

+

{t("aiCheckGenerator.noRulesYet")}

{runContext - ? `Enter requirements for "${runContext.runName}" to get started` - : "Enter your requirements below to get started"} + ? t("aiCheckGenerator.enterRequirementsContext", { name: runContext.runName }) + : t("aiCheckGenerator.enterRequirements")}

@@ -151,8 +153,8 @@ export function AICheckGenerator({ onGenerate, isGenerating, runContext }: AIChe onKeyDown={handleKeyDown} placeholder={ runContext - ? `Describe rules for "${runContext.runName}"...` - : "Example: Sales amount must be positive" + ? t("aiCheckGenerator.describeRulesContext", { name: runContext.runName }) + : t("aiCheckGenerator.examplePlaceholder") } className="min-h-[100px] resize-none pr-12 bg-card/50 backdrop-blur-sm" disabled={isGenerating} @@ -173,8 +175,8 @@ export function AICheckGenerator({ onGenerate, isGenerating, runContext }: AIChe

- Press Enter to generate or{" "} - Shift+Enter for a new line + Enter {t("aiCheckGenerator.kbdHint")}{" "} + Shift+Enter {t("aiCheckGenerator.kbdHintSuffix")}

diff --git a/app/src/databricks_labs_dqx_app/ui/components/AuthGuard.tsx b/app/src/databricks_labs_dqx_app/ui/components/AuthGuard.tsx index b722bf08a..073031457 100644 --- a/app/src/databricks_labs_dqx_app/ui/components/AuthGuard.tsx +++ b/app/src/databricks_labs_dqx_app/ui/components/AuthGuard.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import axios from "axios"; import { Loader2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { currentUser } from "@/lib/api"; interface AuthGuardProps { @@ -16,6 +17,7 @@ interface AuthGuardProps { * with the backend OpenAPI spec. */ export function AuthGuard({ children }: AuthGuardProps) { + const { t } = useTranslation(); const [isAuthReady, setIsAuthReady] = useState(false); const [retryCount, setRetryCount] = useState(0); const [error, setError] = useState(null); @@ -36,7 +38,7 @@ export function AuthGuard({ children }: AuthGuardProps) { if (retryCount < 15) { const delay = Math.min(1000 * Math.pow(1.3, retryCount), 3000); - + timeoutId = setTimeout(() => { if (!cancelled) { setRetryCount((prev) => prev + 1); @@ -45,14 +47,17 @@ export function AuthGuard({ children }: AuthGuardProps) { } else { const errorMessage = axios.isAxiosError(err) ? err.response?.status === 401 - ? "Authentication timeout. The authentication flow did not complete." - : `Server error (${err.response?.status}): ${err.response?.statusText || err.message}` + ? t("auth.timeoutMessage") + : t("auth.serverErrorMessage", { + status: err.response?.status ?? "", + statusText: err.response?.statusText || err.message, + }) : err instanceof Error ? err.message - : "Unknown connection error"; - + : t("auth.unknownError"); + setError( - `${errorMessage}\n\nPlease refresh the page or contact your administrator if the problem persists.` + `${errorMessage}${t("auth.errorSuffix")}` ); } } @@ -80,7 +85,7 @@ export function AuthGuard({ children }: AuthGuardProps) { clearTimeout(timeoutId); } }; - }, [retryCount, isAuthReady, error]); + }, [retryCount, isAuthReady, error, t]); // Show error state if (error) { @@ -88,14 +93,14 @@ export function AuthGuard({ children }: AuthGuardProps) {
- Authentication Error + {t("auth.errorTitle")}

{error}

@@ -108,9 +113,9 @@ export function AuthGuard({ children }: AuthGuardProps) {
-
Initializing DQX Studio...
+
{t("auth.loadingMessage")}

- Setting up your workspace connection + {t("auth.loadingDescription")}

diff --git a/app/src/databricks_labs_dqx_app/ui/components/CatalogBrowser.tsx b/app/src/databricks_labs_dqx_app/ui/components/CatalogBrowser.tsx index ace148540..6bb0ebed2 100644 --- a/app/src/databricks_labs_dqx_app/ui/components/CatalogBrowser.tsx +++ b/app/src/databricks_labs_dqx_app/ui/components/CatalogBrowser.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { Select, SelectContent, @@ -39,6 +40,7 @@ export function CatalogBrowser({ onAllTablesLoaded, disabledTables, }: CatalogBrowserProps) { + const { t } = useTranslation(); const [catalog, setCatalog] = useState(""); const [schema, setSchema] = useState(""); const [table, setTable] = useState(""); @@ -166,7 +168,7 @@ export function CatalogBrowser({
- + ) : ( - + )} @@ -215,7 +217,7 @@ export function CatalogBrowser({ {!multiSelect && (
- + setFilter(e.target.value)} - placeholder="Filter columns by name or type…" + placeholder={t("columnDiscovery.filterPlaceholder")} className="h-8 pl-7 pr-7 text-xs" /> {filter && ( @@ -341,7 +342,7 @@ export function ColumnDiscoveryPanel({ type="button" onClick={() => setFilter("")} className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" - aria-label="Clear filter" + aria-label={t("columnDiscovery.clearFilter")} > @@ -352,7 +353,7 @@ export function ColumnDiscoveryPanel({ {/* Columns list */} {!table ? (
- Pick a catalog, schema, and table above to see its columns. + {t("columnDiscovery.pickHint")}
) : columnsLoading ? (
@@ -362,20 +363,19 @@ export function ColumnDiscoveryPanel({
) : columnsError ? (
- Failed to load columns for {table}. + {t("columnDiscovery.failedToLoad", { table })}
) : filteredColumns.length === 0 ? (
{filter - ? `No columns match "${filter}".` - : "This table has no columns."} + ? t("columnDiscovery.noMatch", { filter }) + : t("columnDiscovery.noColumns")}
) : ( <>
- {filteredColumns.length} of {columns.length} column - {columns.length === 1 ? "" : "s"} + {t("columnDiscovery.columnsCount", { visible: filteredColumns.length, total: columns.length, count: columns.length })} - Reset + {t("columnDiscovery.reset")}
@@ -404,7 +404,7 @@ export function ColumnDiscoveryPanel({
{col.name}
{col.type_name} - {col.nullable === false ? " · NOT NULL" : ""} + {col.nullable === false ? t("columnDiscovery.notNullSuffix") : ""} {col.comment ? ` · ${col.comment}` : ""}
@@ -420,13 +420,13 @@ export function ColumnDiscoveryPanel({ e.stopPropagation(); handleCopy(col); }} - aria-label={`Copy ${col.name}`} + aria-label={t("columnDiscovery.copyAria", { name: col.name })} > -

Copy column name

+

{t("columnDiscovery.copyTooltip")}

@@ -440,7 +440,7 @@ export function ColumnDiscoveryPanel({ {(catalogsLoading || schemasLoading || tablesLoading) && (
- Loading catalog metadata… + {t("columnDiscovery.loadingMetadata")}
)}
diff --git a/app/src/databricks_labs_dqx_app/ui/components/CommentThread.tsx b/app/src/databricks_labs_dqx_app/ui/components/CommentThread.tsx index 93dc49a07..5d4c185e2 100644 --- a/app/src/databricks_labs_dqx_app/ui/components/CommentThread.tsx +++ b/app/src/databricks_labs_dqx_app/ui/components/CommentThread.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Loader2, MessageSquare, Send, Trash2 } from "lucide-react"; @@ -19,6 +20,7 @@ interface CommentThreadProps { } export function CommentThread({ entityType, entityId }: CommentThreadProps) { + const { t } = useTranslation(); const queryClient = useQueryClient(); const [newComment, setNewComment] = useState(""); const [isOpen, setIsOpen] = useState(false); @@ -40,7 +42,7 @@ export function CommentThread({ entityType, entityId }: CommentThreadProps) { setNewComment(""); queryClient.invalidateQueries({ queryKey: getListCommentsQueryKey(entityType, entityId) }); } catch { - toast.error("Failed to add comment"); + toast.error(t("commentThread.failedAdd")); } }; @@ -49,7 +51,7 @@ export function CommentThread({ entityType, entityId }: CommentThreadProps) { await deleteMutation.mutateAsync({ commentId }); queryClient.invalidateQueries({ queryKey: getListCommentsQueryKey(entityType, entityId) }); } catch { - toast.error("Failed to delete comment"); + toast.error(t("commentThread.failedDelete")); } }; @@ -62,7 +64,7 @@ export function CommentThread({ entityType, entityId }: CommentThreadProps) { > - {isOpen ? "Hide" : "Show"} comments + {isOpen ? t("commentThread.hideComments") : t("commentThread.showComments")} {t("commentThread.commentsSuffix")} {comments.length > 0 && ` (${comments.length})`} @@ -72,12 +74,12 @@ export function CommentThread({ entityType, entityId }: CommentThreadProps) { {isLoading && (
- Loading comments... + {t("commentThread.loading")}
)} {!isLoading && comments.length === 0 && ( -

No comments yet.

+

{t("commentThread.empty")}

)} {comments.map((c) => ( @@ -107,7 +109,7 @@ export function CommentThread({ entityType, entityId }: CommentThreadProps) {