Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions docs/prds/prd-settings-form-save-cancel.md

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions src/frontend/src/components/common/unsaved-changes-guard.tsx
Original file line number Diff line number Diff line change
@@ -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 `<Link>` 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;
}
37 changes: 0 additions & 37 deletions src/frontend/src/components/settings/general-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,8 +30,6 @@ export default function GeneralSettings() {
const hasWriteAccess = hasPermission('settings', FeatureAccessLevel.READ_WRITE);

const [settings, setSettings] = useState<AppSettings>({
enableBackgroundJobs: false,
workspaceDeploymentPath: '',
databricksCatalog: '',
databricksSchema: '',
databricksVolume: '',
Expand All @@ -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 || '',
Expand All @@ -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,
Expand Down Expand Up @@ -127,35 +119,6 @@ export default function GeneralSettings() {
</div>

<div className="space-y-6">
{/* Background Jobs */}
<div className="flex items-center space-x-2">
<Switch
id="background-jobs"
checked={settings.enableBackgroundJobs}
onCheckedChange={(checked) => setSettings(prev => ({ ...prev, enableBackgroundJobs: checked }))}
/>
<Label htmlFor="background-jobs">{t('settings:general.enableBackgroundJobs')}</Label>
</div>

<div className="space-y-2">
<Label htmlFor="workspaceDeploymentPath">
{t('settings:general.workspaceDeploymentPath.label', 'Workspace Deployment Path')}
</Label>
<Input
id="workspaceDeploymentPath"
name="workspaceDeploymentPath"
value={settings.workspaceDeploymentPath}
onChange={handleChange}
placeholder={t('settings:general.workspaceDeploymentPath.placeholder', '/Workspace/Users/user@domain.com/ontos-workflows')}
disabled={!hasWriteAccess || isLoading}
/>
<p className="text-sm text-muted-foreground">
{t('settings:general.workspaceDeploymentPath.help', 'Path in Databricks workspace where workflow files are deployed for background jobs.')}
</p>
</div>

<Separator />

{/* Unity Catalog Settings */}
<div>
<h3 className="text-lg font-medium mb-3">{t('settings:general.unityCatalog.title', 'Unity Catalog')}</h3>
Expand Down
131 changes: 119 additions & 12 deletions src/frontend/src/components/settings/jobs-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}

Expand All @@ -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<string>('');
const [workspaceDeploymentPath, setWorkspaceDeploymentPath] = useState<string>('');
const [workflows, setWorkflows] = useState<WorkflowsMap>({});
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
const [statuses, setStatuses] = useState<Record<string, WorkflowStatus>>({});
const [isSaving, setIsSaving] = useState(false);
// Last-loaded / last-saved snapshot used to detect unsaved edits.
const [snapshot, setSnapshot] = useState<JobsSnapshot>({
jobClusterId: '',
workspaceDeploymentPath: '',
enabledJobIds: [],
});

// Job runs dialog state
const [selectedWorkflow, setSelectedWorkflow] = useState<{ id: string; name: string } | null>(null);
Expand Down Expand Up @@ -74,15 +101,25 @@ export default function JobsSettings() {
const response = await get<SettingsApiResponse>('/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<string>(data.enabled_jobs || data.current_settings?.enabled_jobs || []);
const loadedEnabledIds = data.enabled_jobs || data.current_settings?.enabled_jobs || [];
const enabledSet = new Set<string>(loadedEnabledIds);
const toggles: Record<string, boolean> = {};
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<string>();
for (const wf of wfList) {
Expand Down Expand Up @@ -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<string, boolean> = {};
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' });
Expand Down Expand Up @@ -246,7 +328,7 @@ export default function JobsSettings() {
<Switch
checked={row.original.enabled}
onCheckedChange={() => toggleWorkflow(row.original.id)}
disabled={row.original.status?.is_running}
disabled={row.original.status?.is_running || (!hasPath && !row.original.enabled)}
/>
),
enableSorting: false,
Expand Down Expand Up @@ -298,7 +380,7 @@ export default function JobsSettings() {
},
enableSorting: false,
},
], [configurableWorkflows, t]);
], [configurableWorkflows, hasPath, t]);

return (
<>
Expand All @@ -315,21 +397,46 @@ export default function JobsSettings() {
<Label htmlFor="job-cluster-id">{t('settings:jobs.jobClusterId.label')}</Label>
<Input id="job-cluster-id" value={jobClusterId} onChange={(e) => setJobClusterId(e.target.value)} placeholder={t('settings:jobs.jobClusterId.placeholder')} />
</div>
<div className="space-y-2">
<Label htmlFor="workspace-deployment-path">
{t('settings:jobs.workspaceDeploymentPath.label')}
</Label>
<Input
id="workspace-deployment-path"
value={workspaceDeploymentPath}
onChange={(e) => setWorkspaceDeploymentPath(e.target.value)}
placeholder={t('settings:jobs.workspaceDeploymentPath.placeholder')}
/>
<p className="text-sm text-muted-foreground">
{t('settings:jobs.workspaceDeploymentPath.help')}
</p>
{!hasPath && (
<Alert variant="default">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
{t('settings:jobs.workspaceDeploymentPath.requiredHint')}
</AlertDescription>
</Alert>
)}
</div>
</div>

<DataTable
columns={columns}
data={mergedList}
searchColumn="name"
storageKey="jobs-workflows-sort"
toolbarActions={
<Button onClick={handleSave} disabled={isSaving} className="h-9">
<Save className="mr-2 h-4 w-4" />
{isSaving ? t('common:actions.saving') : t('settings:jobs.saveButton')}
</Button>
}
/>

<SettingsActionBar
isDirty={isDirty}
isSaving={isSaving}
onSave={handleSave}
onCancel={handleCancel}
saveLabel={t('settings:jobs.saveButton')}
/>
<UnsavedChangesGuard isDirty={isDirty} />

{selectedWorkflow && (
<JobRunsDialog
workflowId={selectedWorkflow.id}
Expand Down
Loading
Loading