From a712c1a818033a5fa70eb66d5441ec3716a3ad69 Mon Sep 17 00:00:00 2001 From: Lars George Date: Fri, 22 May 2026 22:54:03 +0200 Subject: [PATCH] feat(settings): clean up Jobs page, drop dead toggle, POC dirty-tracking action bar Two related Settings UX changes plus the follow-up PRD: 1. General settings: drop the "Enable Background Jobs" toggle, which was a dead control (never read or persisted on the backend), and move the "Workspace Deployment Path" input to the Jobs Configuration page so it sits next to the job-cluster and workflow toggles it actually governs. Per-workflow Enable switches are disabled (and Save is blocked with a toast) when the deployment path is empty, since workflow installation requires it. i18n keys for the path field move from settings.general.* to settings.jobs.* across all seven shipped locales and a new requiredHint string is added. 2. Jobs page: introduce a per-form dirty-tracking POC. A snapshot of the last-loaded/saved cluster id, deployment path, and enabled-job set drives an isDirty derivation; Cancel reverts every edit to the snapshot; Save resets the snapshot on success. Two small shared primitives back the POC: a SettingsActionBar component (sticky bar that renders only when dirty) and an UnsavedChangesGuard component that installs a beforeunload listener while dirty. 3. PRD #428 captures the broader plan to roll the dirty-tracking convention out across all six form-style settings sub-views (general, ui-customization, git, delivery, jobs, search-config) with a static bottom-left Save/Cancel block instead of the current sticky right-aligned POC variant. List/CRUD settings pages and the BrowserRouter -> Data Router migration needed for in-app navigation blocking are explicitly out of scope of that PRD. No backend changes. No new i18n keys outside the moved path block; the shared action bar reuses existing common.actions.* and common.confirmations.unsavedChanges keys. Refs #428 --- docs/prds/prd-settings-form-save-cancel.md | 133 ++++++++++++++++++ .../common/unsaved-changes-guard.tsx | 34 +++++ .../components/settings/general-settings.tsx | 37 ----- .../src/components/settings/jobs-settings.tsx | 131 +++++++++++++++-- .../settings/settings-action-bar.tsx | 89 ++++++++++++ .../src/i18n/locales/de/settings.json | 12 +- .../src/i18n/locales/en/settings.json | 12 +- .../src/i18n/locales/es/settings.json | 12 +- .../src/i18n/locales/fr/settings.json | 12 +- .../src/i18n/locales/it/settings.json | 12 +- .../src/i18n/locales/ja/settings.json | 12 +- .../src/i18n/locales/nl/settings.json | 12 +- 12 files changed, 417 insertions(+), 91 deletions(-) create mode 100644 docs/prds/prd-settings-form-save-cancel.md create mode 100644 src/frontend/src/components/common/unsaved-changes-guard.tsx create mode 100644 src/frontend/src/components/settings/settings-action-bar.tsx diff --git a/docs/prds/prd-settings-form-save-cancel.md b/docs/prds/prd-settings-form-save-cancel.md new file mode 100644 index 00000000..aaf3f191 --- /dev/null +++ b/docs/prds/prd-settings-form-save-cancel.md @@ -0,0 +1,133 @@ +# PRD: Consistent Save / Cancel and Dirty-Tracking Across Settings Form Sub-Views + +## Problem Statement + +After the recent split of the Settings page into focused sub-views (one route per concern: `settings-general`, `settings-ui`, `settings-jobs`, `settings-git`, `settings-delivery`, `settings-search`, etc.), the editing UX inside each form-style sub-view drifted apart. Concretely, today: + +- Save button placement is inconsistent. Most pages render an ad-hoc `pt-4` block at the **bottom-right** of the form (`general-settings.tsx`, `ui-customization-settings.tsx`, `git-settings.tsx`, `delivery-settings.tsx`). `jobs-settings.tsx` was recently changed to render a **sticky right-aligned** bar (a POC from a prior iteration). `search-config-editor.tsx` follows yet another convention. There is no single rule. +- **There is no Cancel button anywhere on the form-style settings sub-views.** A user who starts editing has no first-class way to discard local edits short of reloading the page or backing out of the route (which silently loses changes). +- **There is no dirty tracking.** Pages compare nothing against their last-loaded snapshot. The Save button is enabled unconditionally, so users routinely hit Save with no real changes; conversely, the page never indicates that there are pending edits. +- **There is no navigation guard.** Closing the tab, hitting reload, typing a new URL, or clicking a sidebar link while edits are pending all silently drop the changes. There is no `beforeunload` listener and no in-app prompt anywhere in the Settings area. + +The result is a Settings experience that feels rough relative to the rest of the app, with measurable foot-guns (lost edits, accidental no-op saves) and visible inconsistency in alignment and labelling between very similar pages. It also blocks future polish work — every new settings sub-view today reinvents this same minor scaffolding, and reinvents it slightly differently. + +## Solution + +Introduce a single shared editing convention for **form-style** settings sub-views and apply it uniformly: + +- Every form-style sub-view ends with a **static** Save / Cancel action block at the **bottom of the form, left-aligned**. The block is part of the normal page flow, not a sticky/floating bar. +- Each page tracks its own dirty state by comparing the current edit state against a snapshot taken at load time (and refreshed on successful save). Save and Cancel are disabled when the form is not dirty (Save is additionally disabled when the form is invalid or while a save request is in flight). +- Pages install a browser-level `beforeunload` warning while dirty, so tab close, reload, and typed-URL navigation prompt the user via the native browser dialog. +- The convention is delivered as **one component** (`SettingsFormActions`) and **one hook** (`useDirtyForm`), plus the already-existing `UnsavedChangesGuard` (built during the prior jobs-settings POC, currently only renders the `beforeunload` listener). All three live in shared component / hook directories and are reused across every migrated page. + +The change is **UI only**: no backend changes, no API changes, no state model changes beyond local React state and snapshots. List/CRUD-style settings sub-views (where edits happen inside Dialogs that already have their own Save/Cancel) are explicitly out of scope. + +In-app router blocking (intercepting clicks on ``s in the sidebar) is also out of scope: the app currently uses `BrowserRouter` (declarative router) and react-router v6's `useBlocker` requires `createBrowserRouter` + `RouterProvider`. Migrating the root router is a separate, larger refactor; the visible static action block plus the `beforeunload` listener are judged sufficient for this pass. + +## User Stories + +### Editing-flow stories + +1. As a settings admin editing the General Settings form, I want to see Save and Cancel buttons in the same place on every settings sub-view, so that muscle memory carries between pages and I never have to hunt for the action. +2. As a settings admin who has made changes to a form, I want a Save button that is clearly enabled (and disabled when I have no pending changes), so that I can tell at a glance whether the form has unsaved edits. +3. As a settings admin who has made changes to a form, I want a Cancel button that reverts every field on the form back to the values that were loaded from the server, so that I can abandon a half-made edit without reloading the page. +4. As a settings admin who has just saved successfully, I want Save and Cancel to immediately return to their disabled state, so that I can see the change has been persisted and I do not accidentally re-save the same payload. +5. As a settings admin whose save request fails, I want my edits to stay in the form and Save / Cancel to remain enabled, so that I can correct and retry without re-typing. +6. As a settings admin filling out a form with required fields, I want Save to remain disabled while the form is invalid (even if it is dirty), so that I cannot submit a bad payload and trigger a backend validation error. + +### Navigation-guard stories + +7. As a settings admin with unsaved changes on a form, I want the browser to prompt me before I close the tab, hit reload, or type a different URL, so that I do not silently lose work. +8. As a settings admin with no pending changes, I want navigation to proceed silently with no prompt, so that the guard does not get in my way during normal browsing. +9. As a settings admin, I want the prompt to disappear immediately after I click Save successfully, so that the very next page navigation is unobstructed. +10. As a settings admin, I accept that clicking a sidebar link will still silently navigate me away in this phase (because the app is not yet on a Data Router and in-app blocking is out of scope), so that the change can ship as a UI-only update. + +### Visual / layout stories + +11. As a settings admin on a narrow viewport, I want Save / Cancel to be left-aligned at the bottom of the form, so that the primary action is closer to the natural reading start of the page and not clipped by the right edge. +12. As a settings admin, I want the action block to always be visible at the bottom of the form (not a floating sticky bar), so that the layout is predictable and matches the rest of the app shell. +13. As a settings admin, I want Save to be the primary (filled) button and Cancel to be the secondary (outline) button, so that the destructive action is visually de-emphasized. +14. As a settings admin saving a form, I want a spinner inside the Save button while the request is in flight, so that I can see the action is being processed. + +### Per-page parity stories + +15. As a settings admin on the **General Settings** sub-view, I want the same Save / Cancel / dirty-tracking behavior, so that this page no longer differs from the others. +16. As a settings admin on the **UI Customization** sub-view, I want the same Save / Cancel / dirty-tracking behavior, including correct snapshot tracking for the four current fields (i18n toggle, logo URL, About content, custom CSS). +17. As a settings admin on the **Git Settings** sub-view, I want the same Save / Cancel / dirty-tracking behavior across both the Git-connection form and any inline sub-forms on the page. +18. As a settings admin on the **Delivery Settings** sub-view, I want the same Save / Cancel / dirty-tracking behavior, including the multiple toggle/text fields that compose the delivery configuration. +19. As a settings admin on the **Jobs** sub-view, I want the existing right-aligned sticky bar replaced with the new static left-aligned action block, so that this page conforms to the same convention as the other forms even though it was migrated first. +20. As a settings admin on the **Search Configuration** sub-view, I want the same Save / Cancel / dirty-tracking behavior wrapping the nested editor controls, so that the page-level save matches every other settings form. + +## Implementation Decisions + +### Shared modules to add + +- A new **`useDirtyForm` hook** that encapsulates dirty detection. Inputs: a function that builds a stable snapshot key from the current edit state, and the value of the last-loaded snapshot key. Outputs: `isDirty` (boolean) and helpers to update the stored snapshot key (after a successful save). The hook is the only place in the codebase that defines what "dirty" means; pages do not roll their own equality. The hook is a deep module in Ousterhout's sense — a single tiny interface (`isDirty` + reset) backed by stable, consistent equality semantics that every settings page can depend on. +- A new **`SettingsFormActions` component** that renders the bottom-left action block. Props include `isDirty`, `isValid` (defaults to `true`), `isSaving`, `onSave`, `onCancel`, and optional label overrides for the rare page that wants page-specific button text. Save is rendered as primary; Cancel as outline. Both are disabled when not dirty; Save is additionally disabled when invalid or saving; the saving state shows a spinner inside Save. +- An **`UnsavedChangesGuard` component** already exists from the prior jobs-settings POC and installs a `beforeunload` listener while `isDirty` is true. It is kept as-is and reused by every migrated page. The existing POC-era `SettingsActionBar` component (the sticky right-aligned bar) is removed once `jobs-settings` is re-migrated, since it no longer matches the convention. + +### Convention every form-style page implements + +- On data load, capture a snapshot of every editable field's loaded value. Store it in component state. +- Compare a `snapshotKey(current)` against a `snapshotKey(loaded)` via `useDirtyForm` to derive `isDirty`. The snapshot key is the page's responsibility to define; it should produce a deterministic string (e.g. sorted-key JSON) so that incidental ordering differences (Set iteration, object key order) do not produce false-positive dirty states. +- A successful save reseats the snapshot to the just-saved values so the form returns to the not-dirty state. +- A Cancel click restores every edit field from the stored snapshot. +- Render `` at the end of the form. Render `` once per page. +- Remove the page's bespoke save button block. + +### Layout and alignment + +- The action block is a normal flow element at the bottom of the form, not sticky, not floating. It uses a consistent top margin and a consistent button spacing. +- Buttons are left-aligned (`justify-start`) so that the primary action sits near the natural reading start of the page; this contrasts with the current right-aligned convention and is the explicit ask. +- Save uses the default (primary) button variant; Cancel uses the outline variant. Save shows a `Save` icon when idle and a spinner when saving; Cancel shows a `RotateCcw` icon. + +### Navigation-guard behaviour + +- A single `beforeunload` listener is installed by `UnsavedChangesGuard` whenever the page is dirty. It triggers the native browser confirmation dialog on tab close, reload, and typed-URL navigation. Custom prompt text is not supported by modern browsers. +- No in-app router blocker is installed in this PRD. Clicking a sidebar `` while dirty will silently navigate. This is an accepted limitation tied to the app's current `BrowserRouter`. Lifting it is tracked separately as a future router migration. + +### Pages migrated + +- `general-settings.tsx` +- `ui-customization-settings.tsx` +- `git-settings.tsx` +- `delivery-settings.tsx` +- `jobs-settings.tsx` (replace the existing sticky `SettingsActionBar` with the new static `SettingsFormActions`; keep the snapshot logic that was added in the POC) +- `search-config-editor.tsx` + +### Pages NOT migrated + +- `certification-levels-settings.tsx`, `roles-settings.tsx`, `tags-settings.tsx`, `connectors-settings.tsx`, `semantic-models-settings.tsx`, `mcp-tokens-settings.tsx`. These are list/CRUD pages where editing happens inside a Dialog with its own Save/Cancel; a page-level action block does not apply. + +### i18n + +- No new translation keys are required. The component uses the existing `common:actions.save`, `common:actions.saving`, `common:actions.cancel`, and `common:confirmations.unsavedChanges` keys, which already exist in all seven shipped locales. +- Pages that today pass a page-specific Save label (e.g. "Save Configuration", "Save UI Settings") can continue to do so via the `saveLabel` prop on `SettingsFormActions`. + +### Validation hook-up + +- `SettingsFormActions` exposes an `isValid` prop. Pages that today rely on inline `disabled={!something.trim()}` predicates pass that boolean through. This keeps page-specific validation rules in the page; the shared component only knows how to disable. +- Jobs settings retains its existing "cannot enable workflows without a deployment path" guard, which today lives inside `handleSave` and surfaces a toast. That logic moves into the `isValid`-style derivation so the Save button is greyed out (with a tooltip later if desired) rather than firing a toast. + +## Testing Decisions + +- **No automated tests are added in this PRD.** The change is treated as a UI consistency cleanup and validated via manual smoke testing per page, consistent with how the prior `jobs-settings` POC was verified. +- For each migrated page, the manual smoke checklist is: load the page (Save / Cancel disabled), edit a field (Save / Cancel enable; "unsaved changes" pill visible), click Cancel (every field reverts, Save / Cancel disable again), edit again, hit Save (request fires; on success Save / Cancel disable; on failure they remain enabled), trigger a reload while dirty (browser native prompt fires), trigger a reload while clean (no prompt). +- Existing per-feature smoke flows (Jobs install, Git connection, Delivery toggles, etc.) continue to be exercised manually after the migration. +- If a follow-up PRD wants automated coverage, the natural seams are the `useDirtyForm` hook (pure function over snapshot strings; trivial vitest coverage) and the `SettingsFormActions` component (RTL test for the disabled-state matrix). Neither is required to ship this change. + +## Out of Scope + +- **Migrating the app from `BrowserRouter` to `createBrowserRouter` + `RouterProvider`.** Required to enable in-app router blocking via `useBlocker`. Tracked separately as a router-architecture concern. +- **In-app navigation prompts.** Sidebar `` clicks while dirty will silently navigate. This will be revisited after the router migration. +- **List/CRUD settings pages** (`certification-levels`, `roles`, `tags`, `connectors`, `semantic-models`, `mcp-tokens`). Their per-row editing happens in Dialogs that already have Save/Cancel; harmonizing those Dialogs is a separate cleanup. +- **Backend changes.** No API endpoints, schemas, or persistence semantics change. The set of fields that get sent in each page's PUT remains exactly as it is today. +- **Toast / notification rework.** The existing success/failure toasts on each page stay as-is. +- **Sticky / floating action bar exploration.** The user explicitly requested a static bottom-left block; the sticky pattern from the prior `jobs-settings` POC is being unwound, not generalized. +- **Tooltips, inline help on disabled buttons, or richer empty-state messaging.** Polish for a follow-up. + +## Further Notes + +- The prior `jobs-settings` POC introduced the components `src/frontend/src/components/settings/settings-action-bar.tsx` (sticky right-aligned) and `src/frontend/src/components/common/unsaved-changes-guard.tsx`. The guard is reused unchanged; the action bar is replaced by the new `SettingsFormActions` and then deleted. The snapshot pattern proven in `jobs-settings.tsx` (sorted JSON stringify with stable key ordering) is generalized into the new `useDirtyForm` hook. +- The "left-aligned" alignment ask is intentional. Most form pages in shadcn-ui examples use a right-aligned save bar; the user prefers the action group near the natural reading start so it remains visible on narrow viewports and is closer to the form labels. +- This PRD assumes the existing `i18n` keys (`common:actions.save`, `common:actions.cancel`, `common:actions.saving`, `common:confirmations.unsavedChanges`) are stable and shared across all seven shipped locales (verified during the jobs POC). If those keys change in the future, the shared component is the single update point. diff --git a/src/frontend/src/components/common/unsaved-changes-guard.tsx b/src/frontend/src/components/common/unsaved-changes-guard.tsx new file mode 100644 index 00000000..98508b67 --- /dev/null +++ b/src/frontend/src/components/common/unsaved-changes-guard.tsx @@ -0,0 +1,34 @@ +import { useEffect } from 'react'; + +interface UnsavedChangesGuardProps { + /** When true, tab close / reload / address-bar nav will prompt via native beforeunload. */ + isDirty: boolean; +} + +/** + * Drop-in guard that warns the user when leaving the tab with unsaved changes. + * + * Currently installs only a `beforeunload` listener (covers reload, tab close, + * and address-bar navigation). In-app navigation via `` is NOT blocked + * because the app uses the legacy declarative `BrowserRouter`; react-router's + * `useBlocker` only works under a Data Router (`createBrowserRouter` + + * `RouterProvider`). Upgrade the root router to enable in-app guard. + * + * Until then, the visible sticky action bar (Save + Cancel/Revert) gives users + * a clear way to undo pending edits without leaving the page. + */ +export default function UnsavedChangesGuard({ isDirty }: UnsavedChangesGuardProps) { + useEffect(() => { + if (!isDirty) return; + const handler = (event: BeforeUnloadEvent) => { + event.preventDefault(); + // Modern browsers ignore custom text; setting returnValue triggers the + // native confirm prompt. + event.returnValue = ''; + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isDirty]); + + return null; +} diff --git a/src/frontend/src/components/settings/general-settings.tsx b/src/frontend/src/components/settings/general-settings.tsx index 83b84fcf..b2430022 100644 --- a/src/frontend/src/components/settings/general-settings.tsx +++ b/src/frontend/src/components/settings/general-settings.tsx @@ -12,8 +12,6 @@ import { FeatureAccessLevel } from '@/types/settings'; import { useToast } from '@/hooks/use-toast'; interface AppSettings { - enableBackgroundJobs: boolean; - workspaceDeploymentPath: string; databricksCatalog: string; databricksSchema: string; databricksVolume: string; @@ -32,8 +30,6 @@ export default function GeneralSettings() { const hasWriteAccess = hasPermission('settings', FeatureAccessLevel.READ_WRITE); const [settings, setSettings] = useState({ - enableBackgroundJobs: false, - workspaceDeploymentPath: '', databricksCatalog: '', databricksSchema: '', databricksVolume: '', @@ -54,8 +50,6 @@ export default function GeneralSettings() { if (response.ok) { const data = await response.json(); setSettings({ - enableBackgroundJobs: data.enable_background_jobs || false, - workspaceDeploymentPath: data.workspace_deployment_path || '', databricksCatalog: data.databricks_catalog || '', databricksSchema: data.databricks_schema || '', databricksVolume: data.databricks_volume || '', @@ -82,8 +76,6 @@ export default function GeneralSettings() { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - enable_background_jobs: settings.enableBackgroundJobs, - workspace_deployment_path: settings.workspaceDeploymentPath, databricks_catalog: settings.databricksCatalog, databricks_schema: settings.databricksSchema, databricks_volume: settings.databricksVolume, @@ -127,35 +119,6 @@ export default function GeneralSettings() {
- {/* Background Jobs */} -
- setSettings(prev => ({ ...prev, enableBackgroundJobs: checked }))} - /> - -
- -
- - -

- {t('settings:general.workspaceDeploymentPath.help', 'Path in Databricks workspace where workflow files are deployed for background jobs.')} -

-
- - - {/* Unity Catalog Settings */}

{t('settings:general.unityCatalog.title', 'Unity Catalog')}

diff --git a/src/frontend/src/components/settings/jobs-settings.tsx b/src/frontend/src/components/settings/jobs-settings.tsx index dc89abbc..28b44357 100644 --- a/src/frontend/src/components/settings/jobs-settings.tsx +++ b/src/frontend/src/components/settings/jobs-settings.tsx @@ -9,20 +9,25 @@ import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Badge } from '@/components/ui/badge'; import { DataTable } from '@/components/ui/data-table'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { JobRunsDialog } from '@/components/settings/job-runs-dialog'; +import SettingsActionBar from '@/components/settings/settings-action-bar'; +import UnsavedChangesGuard from '@/components/common/unsaved-changes-guard'; import WorkflowActions from '@/components/settings/workflow-actions'; import WorkflowConfigurationDialog from '@/components/settings/workflow-configuration-dialog'; import { WorkflowStatus } from '@/types/workflows'; import { WorkflowParameterDefinition } from '@/types/workflow-configurations'; -import { Briefcase, ChevronDown, History, Save, Settings as SettingsIcon } from 'lucide-react'; +import { AlertTriangle, Briefcase, ChevronDown, History, Settings as SettingsIcon } from 'lucide-react'; interface SettingsApiResponse { job_cluster_id?: string | null; enabled_jobs?: string[]; + workspace_deployment_path?: string | null; available_workflows?: { id: string; name: string; description?: string }[]; current_settings?: { job_cluster_id?: string | null; enabled_jobs?: string[]; + workspace_deployment_path?: string | null; }; } @@ -36,16 +41,38 @@ interface MergedWorkflow { enabled: boolean; } +interface JobsSnapshot { + jobClusterId: string; + workspaceDeploymentPath: string; + enabledJobIds: string[]; +} + +// Build a stable comparison key for a JobsSnapshot. Sorting `enabledJobIds` +// keeps dirty detection insensitive to set iteration order. +function snapshotKey(snap: JobsSnapshot): string { + return JSON.stringify({ + ...snap, + enabledJobIds: [...snap.enabledJobIds].sort(), + }); +} + export default function JobsSettings() { const { t } = useTranslation(['settings', 'common']); const { toast } = useToast(); const { get, put, post } = useApi(); const [jobClusterId, setJobClusterId] = useState(''); + const [workspaceDeploymentPath, setWorkspaceDeploymentPath] = useState(''); const [workflows, setWorkflows] = useState({}); const [enabled, setEnabled] = useState>({}); const [statuses, setStatuses] = useState>({}); const [isSaving, setIsSaving] = useState(false); + // Last-loaded / last-saved snapshot used to detect unsaved edits. + const [snapshot, setSnapshot] = useState({ + jobClusterId: '', + workspaceDeploymentPath: '', + enabledJobIds: [], + }); // Job runs dialog state const [selectedWorkflow, setSelectedWorkflow] = useState<{ id: string; name: string } | null>(null); @@ -74,15 +101,25 @@ export default function JobsSettings() { const response = await get('/api/settings'); const data = response.data || {}; const clusterId = data.job_cluster_id ?? data.current_settings?.job_cluster_id ?? ''; - setJobClusterId(clusterId || ''); + const loadedClusterId = clusterId || ''; + setJobClusterId(loadedClusterId); + const deploymentPath = data.workspace_deployment_path ?? data.current_settings?.workspace_deployment_path ?? ''; + const loadedPath = deploymentPath || ''; + setWorkspaceDeploymentPath(loadedPath); const wfList = data.available_workflows || []; const wfMap: WorkflowsMap = {}; wfList.forEach(w => { wfMap[w.id] = w; }); setWorkflows(wfMap); - const enabledSet = new Set(data.enabled_jobs || data.current_settings?.enabled_jobs || []); + const loadedEnabledIds = data.enabled_jobs || data.current_settings?.enabled_jobs || []; + const enabledSet = new Set(loadedEnabledIds); const toggles: Record = {}; wfList.forEach(w => { toggles[w.id] = enabledSet.has(w.id); }); setEnabled(toggles); + setSnapshot({ + jobClusterId: loadedClusterId, + workspaceDeploymentPath: loadedPath, + enabledJobIds: wfList.map(w => w.id).filter(id => enabledSet.has(id)), + }); const configurable = new Set(); for (const wf of wfList) { @@ -128,18 +165,63 @@ export default function JobsSettings() { return () => { cancelled = true; clearInterval(id); }; }, [get, toast, t]); + const hasPath = workspaceDeploymentPath.trim().length > 0; + + const currentEnabledIds = useMemo( + () => Object.entries(enabled).filter(([, v]) => v).map(([k]) => k), + [enabled] + ); + + const isDirty = useMemo(() => { + return ( + snapshotKey(snapshot) !== + snapshotKey({ + jobClusterId, + workspaceDeploymentPath, + enabledJobIds: currentEnabledIds, + }) + ); + }, [snapshot, jobClusterId, workspaceDeploymentPath, currentEnabledIds]); + + const handleCancel = () => { + setJobClusterId(snapshot.jobClusterId); + setWorkspaceDeploymentPath(snapshot.workspaceDeploymentPath); + const restoreSet = new Set(snapshot.enabledJobIds); + setEnabled(prev => { + const next: Record = {}; + Object.keys(prev).forEach(id => { next[id] = restoreSet.has(id); }); + return next; + }); + }; + const handleSave = async () => { + if (currentEnabledIds.length > 0 && !hasPath) { + toast({ + title: t('common:status.error'), + description: t('settings:jobs.workspaceDeploymentPath.requiredHint'), + variant: 'destructive', + }); + return; + } setIsSaving(true); try { + const trimmedPath = workspaceDeploymentPath.trim(); const payload = { job_cluster_id: jobClusterId || null, - enabled_jobs: Object.entries(enabled).filter(([, v]) => v).map(([k]) => k), + workspace_deployment_path: trimmedPath || null, + enabled_jobs: currentEnabledIds, }; const response = await put('/api/settings', payload); if (response.error) { toast({ title: t('common:status.error'), description: response.error, variant: 'destructive' }); return; } + // Refresh snapshot to the persisted values so isDirty resets to false. + setSnapshot({ + jobClusterId, + workspaceDeploymentPath: trimmedPath, + enabledJobIds: currentEnabledIds, + }); toast({ title: t('common:status.success'), description: t('settings:jobs.messages.saveSuccess') }); } catch (e: any) { toast({ title: t('common:status.error'), description: e?.message || 'Failed to save', variant: 'destructive' }); @@ -246,7 +328,7 @@ export default function JobsSettings() { toggleWorkflow(row.original.id)} - disabled={row.original.status?.is_running} + disabled={row.original.status?.is_running || (!hasPath && !row.original.enabled)} /> ), enableSorting: false, @@ -298,7 +380,7 @@ export default function JobsSettings() { }, enableSorting: false, }, - ], [configurableWorkflows, t]); + ], [configurableWorkflows, hasPath, t]); return ( <> @@ -315,6 +397,28 @@ export default function JobsSettings() { setJobClusterId(e.target.value)} placeholder={t('settings:jobs.jobClusterId.placeholder')} />
+
+ + setWorkspaceDeploymentPath(e.target.value)} + placeholder={t('settings:jobs.workspaceDeploymentPath.placeholder')} + /> +

+ {t('settings:jobs.workspaceDeploymentPath.help')} +

+ {!hasPath && ( + + + + {t('settings:jobs.workspaceDeploymentPath.requiredHint')} + + + )} +
- - {isSaving ? t('common:actions.saving') : t('settings:jobs.saveButton')} - - } /> + + + {selectedWorkflow && ( void; + /** Revert handler. Should restore local state to the last loaded snapshot. */ + onCancel: () => void; + /** Optional override for the Save button label. */ + saveLabel?: string; + /** Optional override for the Cancel/Revert button label. */ + cancelLabel?: string; + /** Optional override for the saving spinner label. */ + savingLabel?: string; + /** Optional override for the "unsaved changes" status text. */ + dirtyLabel?: string; + /** Additional class names applied to the bar wrapper. */ + className?: string; +} + +/** + * Shared sticky action bar for settings sub-pages. + * + * Renders nothing when `isDirty` is false. When dirty, slides up from the + * bottom of the viewport with a Save (primary) and Cancel/Revert (outline) + * button, plus a small status pill. Pair with `` + * to also catch tab-close / reload. + * + * Layout choice: the bar is `sticky bottom-0` rather than `fixed` so it + * respects the page's scroll container and the existing app shell layout. + */ +export default function SettingsActionBar({ + isDirty, + isSaving = false, + onSave, + onCancel, + saveLabel, + cancelLabel, + savingLabel, + dirtyLabel, + className, +}: SettingsActionBarProps) { + const { t } = useTranslation(['common']); + + if (!isDirty) return null; + + return ( +
+ + {dirtyLabel ?? t('common:confirmations.unsavedChanges', 'You have unsaved changes')} + + + +
+ ); +} diff --git a/src/frontend/src/i18n/locales/de/settings.json b/src/frontend/src/i18n/locales/de/settings.json index e1214b34..8603813c 100644 --- a/src/frontend/src/i18n/locales/de/settings.json +++ b/src/frontend/src/i18n/locales/de/settings.json @@ -29,12 +29,6 @@ "general": { "title": "Allgemeine Einstellungen", "description": "Grundlegende Anwendungseinstellungen konfigurieren", - "enableBackgroundJobs": "Hintergrund-Jobs aktivieren", - "workspaceDeploymentPath": { - "label": "Workspace-Bereitstellungspfad", - "placeholder": "/Workspace/Users/user@domain.com/ontos-workflows", - "help": "Pfad im Databricks-Workspace, in dem Workflow-Dateien für Hintergrund-Jobs bereitgestellt werden." - }, "unityCatalog": { "title": "Unity Catalog", "help": "Unity Catalog-Speicherort für Anwendungsdaten.", @@ -131,6 +125,12 @@ "label": "Job-Cluster-ID", "placeholder": "Job-Cluster-ID eingeben" }, + "workspaceDeploymentPath": { + "label": "Workspace-Bereitstellungspfad", + "placeholder": "/Workspace/Users/user@domain.com/ontos-workflows", + "help": "Pfad im Databricks-Workspace, in dem Workflow-Dateien für Hintergrund-Jobs bereitgestellt werden.", + "requiredHint": "Legen Sie oben den Bereitstellungspfad fest, um Workflows zu aktivieren." + }, "availableWorkflows": { "label": "Verfügbare Workflows", "description": "Hintergrund-Workflows aktivieren oder deaktivieren" diff --git a/src/frontend/src/i18n/locales/en/settings.json b/src/frontend/src/i18n/locales/en/settings.json index 429e9042..45643d99 100644 --- a/src/frontend/src/i18n/locales/en/settings.json +++ b/src/frontend/src/i18n/locales/en/settings.json @@ -122,12 +122,6 @@ "general": { "title": "General Settings", "description": "Configure basic application settings", - "enableBackgroundJobs": "Enable Background Jobs", - "workspaceDeploymentPath": { - "label": "Workspace Deployment Path", - "placeholder": "/Workspace/Users/user@domain.com/ontos-workflows", - "help": "Path in Databricks workspace where workflow files are deployed for background jobs." - }, "unityCatalog": { "title": "Unity Catalog", "help": "Unity Catalog location for storing application data.", @@ -224,6 +218,12 @@ "label": "Job Cluster ID (optional)", "placeholder": "Leave empty for serverless compute" }, + "workspaceDeploymentPath": { + "label": "Workspace Deployment Path", + "placeholder": "/Workspace/Users/user@domain.com/ontos-workflows", + "help": "Path in Databricks workspace where workflow files are deployed for background jobs.", + "requiredHint": "Set the deployment path above to enable workflows." + }, "availableWorkflows": { "label": "Available Workflows", "description": "Enable or disable background workflows" diff --git a/src/frontend/src/i18n/locales/es/settings.json b/src/frontend/src/i18n/locales/es/settings.json index fd501306..247692f6 100644 --- a/src/frontend/src/i18n/locales/es/settings.json +++ b/src/frontend/src/i18n/locales/es/settings.json @@ -29,12 +29,6 @@ "general": { "title": "Configuración general", "description": "Configurar ajustes básicos de la aplicación", - "enableBackgroundJobs": "Habilitar jobs en segundo plano", - "workspaceDeploymentPath": { - "label": "Ruta de despliegue del workspace", - "placeholder": "/Workspace/Users/user@domain.com/ontos-workflows", - "help": "Ruta en el workspace de Databricks donde se despliegan los archivos de workflow para jobs en segundo plano." - }, "unityCatalog": { "title": "Unity Catalog", "help": "Ubicación de Unity Catalog para almacenar datos de la aplicación.", @@ -131,6 +125,12 @@ "label": "ID del clúster de jobs (opcional)", "placeholder": "Dejar vacío para computación serverless" }, + "workspaceDeploymentPath": { + "label": "Ruta de despliegue del workspace", + "placeholder": "/Workspace/Users/user@domain.com/ontos-workflows", + "help": "Ruta en el workspace de Databricks donde se despliegan los archivos de workflow para jobs en segundo plano.", + "requiredHint": "Establece la ruta de despliegue arriba para habilitar los workflows." + }, "availableWorkflows": { "label": "Workflows disponibles", "description": "Habilitar o deshabilitar workflows en segundo plano" diff --git a/src/frontend/src/i18n/locales/fr/settings.json b/src/frontend/src/i18n/locales/fr/settings.json index 59dbf880..8a989599 100644 --- a/src/frontend/src/i18n/locales/fr/settings.json +++ b/src/frontend/src/i18n/locales/fr/settings.json @@ -29,12 +29,6 @@ "general": { "title": "Paramètres généraux", "description": "Configurer les paramètres de base de l'application", - "enableBackgroundJobs": "Activer les jobs en arrière-plan", - "workspaceDeploymentPath": { - "label": "Chemin de déploiement du workspace", - "placeholder": "/Workspace/Users/user@domain.com/ontos-workflows", - "help": "Chemin dans le workspace Databricks où les fichiers de workflow sont déployés pour les jobs en arrière-plan." - }, "unityCatalog": { "title": "Unity Catalog", "help": "Emplacement Unity Catalog pour stocker les données de l'application.", @@ -131,6 +125,12 @@ "label": "ID du cluster de jobs (optionnel)", "placeholder": "Laisser vide pour le calcul serverless" }, + "workspaceDeploymentPath": { + "label": "Chemin de déploiement du workspace", + "placeholder": "/Workspace/Users/user@domain.com/ontos-workflows", + "help": "Chemin dans le workspace Databricks où les fichiers de workflow sont déployés pour les jobs en arrière-plan.", + "requiredHint": "Définissez le chemin de déploiement ci-dessus pour activer les workflows." + }, "availableWorkflows": { "label": "Workflows disponibles", "description": "Activer ou désactiver les workflows en arrière-plan" diff --git a/src/frontend/src/i18n/locales/it/settings.json b/src/frontend/src/i18n/locales/it/settings.json index 44bc0731..36ae5dd7 100644 --- a/src/frontend/src/i18n/locales/it/settings.json +++ b/src/frontend/src/i18n/locales/it/settings.json @@ -29,12 +29,6 @@ "general": { "title": "Impostazioni generali", "description": "Configura le impostazioni di base dell'applicazione", - "enableBackgroundJobs": "Abilita job in background", - "workspaceDeploymentPath": { - "label": "Percorso di distribuzione del workspace", - "placeholder": "/Workspace/Users/user@domain.com/ontos-workflows", - "help": "Percorso nel workspace Databricks dove vengono distribuiti i file di workflow per i job in background." - }, "unityCatalog": { "title": "Unity Catalog", "help": "Posizione Unity Catalog per l'archiviazione dei dati dell'applicazione.", @@ -131,6 +125,12 @@ "label": "ID cluster job (opzionale)", "placeholder": "Lascia vuoto per il calcolo serverless" }, + "workspaceDeploymentPath": { + "label": "Percorso di distribuzione del workspace", + "placeholder": "/Workspace/Users/user@domain.com/ontos-workflows", + "help": "Percorso nel workspace Databricks dove vengono distribuiti i file di workflow per i job in background.", + "requiredHint": "Imposta il percorso di distribuzione sopra per abilitare i workflow." + }, "availableWorkflows": { "label": "Workflow disponibili", "description": "Abilita o disabilita i workflow in background" diff --git a/src/frontend/src/i18n/locales/ja/settings.json b/src/frontend/src/i18n/locales/ja/settings.json index a9639e4b..708a5cf9 100644 --- a/src/frontend/src/i18n/locales/ja/settings.json +++ b/src/frontend/src/i18n/locales/ja/settings.json @@ -29,12 +29,6 @@ "general": { "title": "一般設定", "description": "基本的なアプリケーション設定を構成", - "enableBackgroundJobs": "バックグラウンドジョブを有効化", - "workspaceDeploymentPath": { - "label": "ワークスペースデプロイパス", - "placeholder": "/Workspace/Users/user@domain.com/ontos-workflows", - "help": "バックグラウンドジョブ用のワークフローファイルがデプロイされるDatabricksワークスペース内のパス。" - }, "unityCatalog": { "title": "Unity Catalog", "help": "アプリケーションデータを保存するUnity Catalogの場所。", @@ -131,6 +125,12 @@ "label": "ジョブクラスタID", "placeholder": "ジョブクラスタIDを入力" }, + "workspaceDeploymentPath": { + "label": "ワークスペースデプロイパス", + "placeholder": "/Workspace/Users/user@domain.com/ontos-workflows", + "help": "バックグラウンドジョブ用のワークフローファイルがデプロイされるDatabricksワークスペース内のパス。", + "requiredHint": "ワークフローを有効化するには、上記のデプロイパスを設定してください。" + }, "availableWorkflows": { "label": "利用可能なワークフロー", "description": "バックグラウンドワークフローの有効化・無効化" diff --git a/src/frontend/src/i18n/locales/nl/settings.json b/src/frontend/src/i18n/locales/nl/settings.json index 3f4d609b..68962b58 100644 --- a/src/frontend/src/i18n/locales/nl/settings.json +++ b/src/frontend/src/i18n/locales/nl/settings.json @@ -29,12 +29,6 @@ "general": { "title": "Algemene Instellingen", "description": "Configureer basis applicatie-instellingen", - "enableBackgroundJobs": "Achtergrond Jobs Inschakelen", - "workspaceDeploymentPath": { - "label": "Workspace Deployment Pad", - "placeholder": "/Workspace/Users/user@domain.com/ontos-workflows", - "help": "Pad in Databricks workspace waar workflow bestanden worden gedeployed voor achtergrond jobs." - }, "unityCatalog": { "title": "Unity Catalog", "help": "Unity Catalog locatie voor het opslaan van applicatiedata.", @@ -131,6 +125,12 @@ "label": "Job Cluster ID (optioneel)", "placeholder": "Laat leeg voor serverless compute" }, + "workspaceDeploymentPath": { + "label": "Workspace Deployment Pad", + "placeholder": "/Workspace/Users/user@domain.com/ontos-workflows", + "help": "Pad in Databricks workspace waar workflow bestanden worden gedeployed voor achtergrond jobs.", + "requiredHint": "Stel het deployment pad hierboven in om workflows in te schakelen." + }, "availableWorkflows": { "label": "Beschikbare Workflows", "description": "Schakel achtergrond workflows in of uit"