diff --git a/ui/src/app/cluster-workflow-templates/cluster-workflow-template-details.tsx b/ui/src/app/cluster-workflow-templates/cluster-workflow-template-details.tsx index 1ca93761a5c4..3523474acfc6 100644 --- a/ui/src/app/cluster-workflow-templates/cluster-workflow-template-details.tsx +++ b/ui/src/app/cluster-workflow-templates/cluster-workflow-template-details.tsx @@ -15,6 +15,7 @@ import {ZeroState} from '../shared/components/zero-state'; import {Context} from '../shared/context'; import {historyUrl} from '../shared/history'; import {services} from '../shared/services'; +import {useEditableObject} from '../shared/use-editable-object'; import {useQueryParams} from '../shared/use-query-params'; import * as nsUtils from '../shared/namespaces'; import {WorkflowDetailsList} from '../workflows/components/workflow-details-list/workflow-details-list'; @@ -36,8 +37,7 @@ export function ClusterWorkflowTemplateDetails({history, location, match}: Route const [columns, setColumns] = useState([]); const [error, setError] = useState(); - const [template, setTemplate] = useState(); - const [edited, setEdited] = useState(false); + const [template, edited, setTemplate, resetTemplate] = useEditableObject(); useEffect( useQueryParams(history, p => { @@ -47,7 +47,6 @@ export function ClusterWorkflowTemplateDetails({history, location, match}: Route [history] ); - useEffect(() => setEdited(true), [template]); useEffect(() => { history.push(historyUrl('cluster-workflow-templates/{name}', {name, sidePanel, tab})); }, [name, sidePanel, tab]); @@ -56,8 +55,7 @@ export function ClusterWorkflowTemplateDetails({history, location, match}: Route (async () => { try { const newTemplate = await services.clusterWorkflowTemplate.get(name); - setTemplate(newTemplate); - setEdited(false); // set back to false + resetTemplate(newTemplate); setError(null); } catch (err) { setError(err); @@ -106,7 +104,7 @@ export function ClusterWorkflowTemplateDetails({history, location, match}: Route action: () => { services.clusterWorkflowTemplate .update(template, name) - .then(setTemplate) + .then(resetTemplate) .then(() => notifications.show({ content: 'Updated', @@ -114,7 +112,6 @@ export function ClusterWorkflowTemplateDetails({history, location, match}: Route }) ) .then(() => setError(null)) - .then(() => setEdited(false)) .catch(setError); } }, diff --git a/ui/src/app/cron-workflows/cron-workflow-details.tsx b/ui/src/app/cron-workflows/cron-workflow-details.tsx index 36a4986c39e6..3552c902063f 100644 --- a/ui/src/app/cron-workflows/cron-workflow-details.tsx +++ b/ui/src/app/cron-workflows/cron-workflow-details.tsx @@ -17,6 +17,7 @@ import {Context} from '../shared/context'; import {historyUrl} from '../shared/history'; import {services} from '../shared/services'; import {useQueryParams} from '../shared/use-query-params'; +import {useEditableObject} from '../shared/use-editable-object'; import {WidgetGallery} from '../widgets/widget-gallery'; import {WorkflowDetailsList} from '../workflows/components/workflow-details-list/workflow-details-list'; import {CronWorkflowEditor} from './cron-workflow-editor'; @@ -35,8 +36,7 @@ export function CronWorkflowDetails({match, location, history}: RouteComponentPr const [workflows, setWorkflows] = useState([]); const [columns, setColumns] = useState([]); - const [cronWorkflow, setCronWorkflow] = useState(); - const [edited, setEdited] = useState(false); + const [cronWorkflow, edited, setCronWorkflow, resetCronWorkflow] = useEditableObject(); const [error, setError] = useState(); useEffect( @@ -63,14 +63,11 @@ export function CronWorkflowDetails({match, location, history}: RouteComponentPr useEffect(() => { services.cronWorkflows .get(name, namespace) - .then(setCronWorkflow) - .then(() => setEdited(false)) + .then(resetCronWorkflow) .then(() => setError(null)) .catch(setError); }, [namespace, name]); - useEffect(() => setEdited(true), [cronWorkflow]); - useEffect(() => { (async () => { const workflowList = await services.workflows.list(namespace, null, [`${models.labels.cronWorkflow}=${name}`], {limit: 50}); @@ -91,8 +88,7 @@ export function CronWorkflowDetails({match, location, history}: RouteComponentPr action: () => services.cronWorkflows .suspend(name, namespace) - .then(setCronWorkflow) - .then(() => setEdited(false)) + .then(resetCronWorkflow) .then(() => setError(null)) .catch(setError), disabled: !cronWorkflow || edited @@ -103,8 +99,7 @@ export function CronWorkflowDetails({match, location, history}: RouteComponentPr action: () => services.cronWorkflows .resume(name, namespace) - .then(setCronWorkflow) - .then(() => setEdited(false)) + .then(resetCronWorkflow) .then(() => setError(null)) .catch(setError), disabled: !cronWorkflow || !cronWorkflow.spec.suspend || edited @@ -142,10 +137,9 @@ export function CronWorkflowDetails({match, location, history}: RouteComponentPr cronWorkflow.metadata.namespace ) ) - .then(setCronWorkflow) + .then(resetCronWorkflow) .then(() => notifications.show({content: 'Updated', type: NotificationType.Success})) .then(() => setError(null)) - .then(() => setEdited(false)) .catch(setError); } }, diff --git a/ui/src/app/event-sources/event-source-details.tsx b/ui/src/app/event-sources/event-source-details.tsx index 0601dbd75421..42937fccb90d 100644 --- a/ui/src/app/event-sources/event-source-details.tsx +++ b/ui/src/app/event-sources/event-source-details.tsx @@ -16,6 +16,7 @@ import {Context} from '../shared/context'; import {historyUrl} from '../shared/history'; import {services} from '../shared/services'; import {useQueryParams} from '../shared/use-query-params'; +import {useEditableObject} from '../shared/use-editable-object'; import {EventsPanel} from '../workflows/components/events-panel'; import {EventSourceEditor} from './event-source-editor'; import {EventSourceLogsViewer} from './event-source-log-viewer'; @@ -52,9 +53,8 @@ export function EventSourceDetails({history, location, match}: RouteComponentPro [namespace, name, tab, selectedNode] ); - const [edited, setEdited] = useState(false); const [error, setError] = useState(); - const [eventSource, setEventSource] = useState(); + const [eventSource, edited, setEventSource, resetEventSource] = useEditableObject(); const selected = (() => { if (!selectedNode) { @@ -69,8 +69,7 @@ export function EventSourceDetails({history, location, match}: RouteComponentPro (async () => { try { const newEventSource = await services.eventSource.get(name, namespace); - setEventSource(newEventSource); - setEdited(false); // set back to false + resetEventSource(newEventSource); setError(null); } catch (err) { setError(err); @@ -78,8 +77,6 @@ export function EventSourceDetails({history, location, match}: RouteComponentPro })(); }, [name, namespace]); - useEffect(() => setEdited(true), [eventSource]); - useCollectEvent('openedEventSourceDetails'); return ( @@ -100,14 +97,13 @@ export function EventSourceDetails({history, location, match}: RouteComponentPro action: () => services.eventSource .update(eventSource, name, namespace) - .then(setEventSource) + .then(resetEventSource) .then(() => notifications.show({ content: 'Updated', type: NotificationType.Success }) ) - .then(() => setEdited(false)) .then(() => setError(null)) .catch(setError) }, diff --git a/ui/src/app/sensors/sensor-details.tsx b/ui/src/app/sensors/sensor-details.tsx index bbd14aef8f65..c4ddf7b3f00c 100644 --- a/ui/src/app/sensors/sensor-details.tsx +++ b/ui/src/app/sensors/sensor-details.tsx @@ -15,6 +15,7 @@ import {Context} from '../shared/context'; import {historyUrl} from '../shared/history'; import {services} from '../shared/services'; import {useQueryParams} from '../shared/use-query-params'; +import {useEditableObject} from '../shared/use-editable-object'; import {SensorEditor} from './sensor-editor'; import {SensorSidePanel} from './sensor-side-panel'; @@ -29,8 +30,7 @@ export function SensorDetails({match, location, history}: RouteComponentProps(queryParams.get('tab')); - const [sensor, setSensor] = useState(); - const [edited, setEdited] = useState(false); + const [sensor, edited, setSensor, resetSensor] = useEditableObject(); const [selectedLogNode, setSelectedLogNode] = useState(queryParams.get('selectedLogNode')); const [error, setError] = useState(); @@ -58,14 +58,11 @@ export function SensorDetails({match, location, history}: RouteComponentProps { services.sensor .get(name, namespace) - .then(setSensor) - .then(() => setEdited(false)) + .then(resetSensor) .then(() => setError(null)) .catch(setError); }, [namespace, name]); - useEffect(() => setEdited(true), [sensor]); - useCollectEvent('openedSensorDetails'); const selected = (() => { @@ -94,9 +91,8 @@ export function SensorDetails({match, location, history}: RouteComponentProps services.sensor .update(sensor, namespace) - .then(setSensor) + .then(resetSensor) .then(() => notifications.show({content: 'Updated', type: NotificationType.Success})) - .then(() => setEdited(false)) .then(() => setError(null)) .catch(setError) }, diff --git a/ui/src/app/shared/use-editable-object.ts b/ui/src/app/shared/use-editable-object.ts new file mode 100644 index 000000000000..bb18287af8a8 --- /dev/null +++ b/ui/src/app/shared/use-editable-object.ts @@ -0,0 +1,21 @@ +import {useState} from 'react'; + +/** + * useEditableObject is a React hook to manage the state of object that can be edited and updated. + * Uses ref comparisons to determine whether the resource has been edited. + */ +export function useEditableObject(initial?: T): [T, boolean, React.Dispatch, (value: T) => void] { + const [value, setValue] = useState(initial); + const [initialValue, setInitialValue] = useState(initial); + + // Note: This is a pure reference comparison instead of a deep comparison for performance + // reasons, since changes are latency-sensitive. + const edited = value !== initialValue; + + function resetValue(value: T) { + setValue(value); + setInitialValue(value); + } + + return [value, edited, setValue, resetValue]; +} diff --git a/ui/src/app/workflow-templates/workflow-template-details.tsx b/ui/src/app/workflow-templates/workflow-template-details.tsx index 89381addb00c..43114cf2d031 100644 --- a/ui/src/app/workflow-templates/workflow-template-details.tsx +++ b/ui/src/app/workflow-templates/workflow-template-details.tsx @@ -10,6 +10,7 @@ import {WorkflowTemplate, Workflow} from '../../models'; import {uiUrl} from '../shared/base'; import {ErrorNotice} from '../shared/components/error-notice'; import {Loading} from '../shared/components/loading'; +import {useEditableObject} from '../shared/use-editable-object'; import {useCollectEvent} from '../shared/use-collect-event'; import {ZeroState} from '../shared/components/zero-state'; import {Context} from '../shared/context'; @@ -34,11 +35,8 @@ export function WorkflowTemplateDetails({history, location, match}: RouteCompone const [workflows, setWorkflows] = useState([]); const [columns, setColumns] = useState([]); - const [template, setTemplate] = useState(); + const [template, edited, setTemplate, resetTemplate] = useEditableObject(); const [error, setError] = useState(); - const [edited, setEdited] = useState(false); - - useEffect(() => setEdited(true), [template]); useEffect( useQueryParams(history, p => { @@ -64,8 +62,7 @@ export function WorkflowTemplateDetails({history, location, match}: RouteCompone useEffect(() => { services.workflowTemplate .get(name, namespace) - .then(setTemplate) - .then(() => setEdited(false)) // set back to false + .then(resetTemplate) .then(() => setError(null)) .catch(setError); }, [name, namespace]); @@ -106,9 +103,8 @@ export function WorkflowTemplateDetails({history, location, match}: RouteCompone action: () => services.workflowTemplate .update(template, name, namespace) - .then(setTemplate) + .then(resetTemplate) .then(() => notifications.show({content: 'Updated', type: NotificationType.Success})) - .then(() => setEdited(false)) .then(() => setError(null)) .catch(setError) },