Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ and this project adheres to

### Fixed

- 500 error when navigating from collaborative editor to full history page
[#3941](https://github.com/OpenFn/lightning/pull/3941)
- Duplicate `isReadOnly` declaration in TriggerForm that was blocking asset
builds [#3976](https://github.com/OpenFn/lightning/issues/3976)
- Run duration and status alignment drift in history view
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ import {
useRunSteps,
} from '../../hooks/useHistory';
import { useIsNewWorkflow } from '../../hooks/useSessionContext';
import { useVersionMismatch } from '../../hooks/useVersionMismatch';
import { useVersionSelect } from '../../hooks/useVersionSelect';
import { useNodeSelection } from '../../hooks/useWorkflow';
import type { Run } from '../../types/history';

import MiniHistory from './MiniHistory';
import { VersionMismatchBanner } from './VersionMismatchBanner';
import CollaborativeWorkflowDiagramImpl from './WorkflowDiagram';

interface CollaborativeWorkflowDiagramProps {
Expand Down Expand Up @@ -72,14 +75,31 @@ export function CollaborativeWorkflowDiagram({
// Use hook to get run steps with automatic subscription management
const currentRunSteps = useRunSteps(selectedRunId);

// Update URL when run selection changes
const handleRunSelect = useCallback((run: Run) => {
setSelectedRunId(run.id);
// Detect version mismatch for warning banner
const versionMismatch = useVersionMismatch(selectedRunId);

const url = new URL(window.location.href);
url.searchParams.set('run', run.id);
window.history.pushState({}, '', url.toString());
}, []);
// Get version selection handler
const handleVersionSelect = useVersionSelect();

// Update URL when run selection changes
const handleRunSelect = useCallback(
(run: Run) => {
setSelectedRunId(run.id);

// Find the workorder that contains this run
const workorder = history.find(wo => wo.runs.some(r => r.id === run.id));

// Switch to the version this run was executed on
if (workorder) {
handleVersionSelect(workorder.version);
}

const url = new URL(window.location.href);
url.searchParams.set('run', run.id);
window.history.pushState({}, '', url.toString());
},
[history, handleVersionSelect]
);

// Clear URL parameter when deselecting run
const handleDeselectRun = useCallback(() => {
Expand Down Expand Up @@ -133,6 +153,15 @@ export function CollaborativeWorkflowDiagram({
return (
<div ref={containerRef} className={className}>
<ReactFlowProvider>
{/* Version mismatch warning when viewing latest but run used older version */}
{versionMismatch && (
<VersionMismatchBanner
runVersion={versionMismatch.runVersion}
currentVersion={versionMismatch.currentVersion}
className="absolute top-0 left-0 right-0 z-10"
/>
)}

<CollaborativeWorkflowDiagramImpl
selection={currentNode.id}
onSelectionChange={selectNode}
Expand Down
54 changes: 23 additions & 31 deletions assets/js/collaborative-editor/components/diagram/MiniHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,14 @@ import React, { useState } from 'react';
import { relativeLocale } from '../../../hooks';
import { duration } from '../../../utils/duration';
import truncateUid from '../../../utils/truncateUID';
import { useProject } from '../../hooks/useSessionContext';
import { useWorkflowState } from '../../hooks/useWorkflow';
import type { Run, WorkOrder } from '../../types/history';
import {
navigateToRun,
navigateToWorkOrderHistory,
navigateToWorkflowHistory,
} from '../../utils/navigation';

// Extended types with selection state for UI
type RunWithSelection = Run & { selected?: boolean };
Expand Down Expand Up @@ -102,6 +109,10 @@ export default function MiniHistory({
const [expandedWorder, setExpandedWorder] = useState('');
const now = new Date();

// Get project and workflow IDs from state for navigation
const project = useProject();
const workflow = useWorkflowState(state => state.workflow);

// Clear expanded work order when panel collapses
React.useEffect(() => {
if (collapsed) {
Expand Down Expand Up @@ -131,49 +142,30 @@ export default function MiniHistory({
const gotoHistory = (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
const currentUrl = new URL(window.location.href);
const nextUrl = new URL(currentUrl);
const paths = nextUrl.pathname.split('/');
const wIdx = paths.indexOf('w');
const workflowPaths = paths.splice(wIdx, paths.length - wIdx);
nextUrl.pathname = paths.join('/') + `/history`;
nextUrl.search = `?filters[workflow_id]=${
workflowPaths[workflowPaths.length - 1]
}`;
window.location.assign(nextUrl.toString());

if (project?.id && workflow?.id) {
navigateToWorkflowHistory(project.id, workflow.id);
}
};

const navigateToWorkorderHistory = (
const handleNavigateToWorkorderHistory = (
e: React.MouseEvent,
workorderId: string
) => {
e.preventDefault();
e.stopPropagation();
const currentUrl = new URL(window.location.href);
const nextUrl = new URL(currentUrl);
const paths = nextUrl.pathname.split('/');
const projectIndex = paths.indexOf('projects');
const projectId = projectIndex !== -1 ? paths[projectIndex + 1] : null;

if (projectId) {
nextUrl.pathname = `/projects/${projectId}/history`;
nextUrl.search = `?filters[workorder_id]=${workorderId}`;
window.location.assign(nextUrl.toString());
if (project?.id) {
navigateToWorkOrderHistory(project.id, workorderId);
}
};

const navigateToRunView = (e: React.MouseEvent, runId: string) => {
const handleNavigateToRunView = (e: React.MouseEvent, runId: string) => {
e.preventDefault();
e.stopPropagation();
const currentUrl = new URL(window.location.href);
const nextUrl = new URL(currentUrl);
const paths = nextUrl.pathname.split('/');
const projectIndex = paths.indexOf('projects');
const projectId = projectIndex !== -1 ? paths[projectIndex + 1] : null;

if (projectId) {
nextUrl.pathname = `/projects/${projectId}/runs/${runId}`;
window.location.assign(nextUrl.toString());
if (project?.id) {
navigateToRun(project.id, runId);
}
};

Expand Down Expand Up @@ -342,7 +334,7 @@ export default function MiniHistory({
<button
type="button"
onClick={e =>
navigateToWorkorderHistory(e, workorder.id)
handleNavigateToWorkorderHistory(e, workorder.id)
}
className="link-uuid"
title={workorder.id}
Expand Down Expand Up @@ -420,7 +412,7 @@ export default function MiniHistory({
)}
<button
type="button"
onClick={e => navigateToRunView(e, run.id)}
onClick={e => handleNavigateToRunView(e, run.id)}
className="link-uuid"
title={run.id}
aria-label={`View full details for run ${truncateUid(run.id)}`}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* VersionMismatchBanner - Warning when viewing latest workflow but selected run used older version
*
* Displays when:
* - A run is selected
* - Viewing "latest" workflow (not a specific snapshot)
* - The run was executed on a different version than currently displayed
*
* This prevents confusion when the workflow structure has changed since the run executed.
*/

import { cn } from '#/utils/cn';

interface VersionMismatchBannerProps {
runVersion: number;
currentVersion: number;
className?: string;
}

export function VersionMismatchBanner({
runVersion,
currentVersion,
className,
}: VersionMismatchBannerProps) {
return (
<div
className={cn(
'w-full bg-yellow-50 text-yellow-700 justify-center flex items-center gap-x-2 px-6 py-2.5 sm:px-3.5',
className
)}
role="alert"
aria-live="polite"
>
<span
className="hero-exclamation-triangle h-5 w-5 inline-block"
aria-hidden="true"
/>
<p className="text-sm leading-6">
This run was executed on version {runVersion}, but you're viewing
version {currentVersion}. Steps shown may not match the current workflow
structure.
</p>
</div>
);
}
64 changes: 64 additions & 0 deletions assets/js/collaborative-editor/hooks/useVersionMismatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* useVersionMismatch - Detects when viewing latest workflow but selected run used older version
*
* Returns version mismatch info when:
* - A run is selected
* - Viewing "latest" workflow (not a specific snapshot)
* - The run was executed on a different version than currently displayed
*
* This prevents confusion when the workflow structure has changed since the run executed.
*/

import { useMemo } from 'react';

import { useHistory } from './useHistory';
import { useLatestSnapshotLockVersion } from './useSessionContext';
import { useWorkflowState } from './useWorkflow';

interface VersionMismatch {
runVersion: number;
currentVersion: number;
}

export function useVersionMismatch(
selectedRunId: string | null
): VersionMismatch | null {
const history = useHistory();
const workflow = useWorkflowState(state => state.workflow);
const latestSnapshotLockVersion = useLatestSnapshotLockVersion();

return useMemo(() => {
if (
!selectedRunId ||
!workflow ||
!workflow.lock_version ||
!latestSnapshotLockVersion
) {
return null;
}

const workflowLockVersion = workflow.lock_version;

// Find the work order that contains the selected run
const selectedWorkOrder = history.find(wo =>
wo.runs.some(run => run.id === selectedRunId)
);

if (!selectedWorkOrder) {
return null;
}

// Show warning when viewing a different version than the run used
const runUsedDifferentVersion =
selectedWorkOrder.version !== workflowLockVersion;

if (runUsedDifferentVersion) {
return {
runVersion: selectedWorkOrder.version,
currentVersion: workflowLockVersion,
};
}

return null;
}, [selectedRunId, workflow, latestSnapshotLockVersion, history]);
}
79 changes: 79 additions & 0 deletions assets/js/collaborative-editor/utils/navigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Navigation utilities for the collaborative editor
*
* Provides clean, testable navigation functions that build URLs from IDs
* rather than parsing existing URLs. Uses URLSearchParams for query string
* construction to avoid brittle string concatenation.
*
* **Important**: These utilities are for navigating to pages OUTSIDE the
* collaborative editor (e.g., history page, run detail pages). For URL changes
* within the collaborative editor itself, use the `useURLState` hook instead
* to update query parameters without full page reloads.
*
* @see {@link ../hooks/use-url-state.ts} for in-editor URL state management
*/

/**
* Navigate to the history page filtered by workflow ID
*
* @param projectId - The project UUID
* @param workflowId - The workflow UUID
*
* @example
* navigateToWorkflowHistory('proj-123', 'wf-456')
* // Navigates to: /projects/proj-123/history?filters[workflow_id]=wf-456
*/
export function navigateToWorkflowHistory(
projectId: string,
workflowId: string
): void {
const url = new URL(window.location.origin);
url.pathname = `/projects/${projectId}/history`;

const params = new URLSearchParams();
params.set('filters[workflow_id]', workflowId);
url.search = params.toString();

window.location.assign(url.toString());
}

/**
* Navigate to the history page filtered by work order ID
*
* @param projectId - The project UUID
* @param workOrderId - The work order UUID
*
* @example
* navigateToWorkOrderHistory('proj-123', 'wo-789')
* // Navigates to: /projects/proj-123/history?filters[workorder_id]=wo-789
*/
export function navigateToWorkOrderHistory(
projectId: string,
workOrderId: string
): void {
const url = new URL(window.location.origin);
url.pathname = `/projects/${projectId}/history`;

const params = new URLSearchParams();
params.set('filters[workorder_id]', workOrderId);
url.search = params.toString();

window.location.assign(url.toString());
}

/**
* Navigate to a specific run detail page
*
* @param projectId - The project UUID
* @param runId - The run UUID
*
* @example
* navigateToRun('proj-123', 'run-456')
* // Navigates to: /projects/proj-123/runs/run-456
*/
export function navigateToRun(projectId: string, runId: string): void {
const url = new URL(window.location.origin);
url.pathname = `/projects/${projectId}/runs/${runId}`;

window.location.assign(url.toString());
}
12 changes: 10 additions & 2 deletions assets/js/workflow-diagram/util/ensure-node-position.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Flow, Positions } from "./types";
import type { Flow, Positions } from './types';

export const ensureNodePosition = (
model: Flow.Model,
Expand Down Expand Up @@ -40,7 +40,15 @@ export const ensureNodePosition = (
};
return true;
} else {
console.warn("WARNING: could not auto-calculate position for ", node.id);
// Only warn if positions map has data but we still couldn't find a parent
// Empty positions on initial render is expected before layout runs
const hasPositions = Object.keys(positions).length > 0;
if (hasPositions) {
console.warn(
'WARNING: could not auto-calculate position for ',
node.id
);
}
node.position = { x: 0, y: 0 };
}
}
Expand Down
Loading