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
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ function InwardHandleContent({
const labelElement = label ? (
<div
className={cx(
'pointer-events-none flex items-center gap-1.5 whitespace-nowrap rounded-full border border-border-subtle bg-transparent px-2 py-0.5',
'pointer-events-none flex items-center gap-1.5 whitespace-nowrap rounded-full border border-border bg-surface px-2 py-0.5',
'text-xs font-medium leading-4 text-foreground-muted'
)}
style={labelBackgroundColor ? { backgroundColor: labelBackgroundColor } : undefined}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { IterationNavigator } from './IterationNavigator';
import type { LoopIterationState } from './LoopNode.types';

function renderNavigator(iterationState: Partial<LoopIterationState> = {}) {
const onActiveIndexChange = vi.fn();

render(
<IterationNavigator
iterationState={{
activeIndex: 1,
total: 3,
onActiveIndexChange,
...iterationState,
}}
/>
);

return { onActiveIndexChange };
}

describe('IterationNavigator', () => {
it.each([
['zero', 0],
['negative', -1],
['NaN', Number.NaN],
['Infinity', Number.POSITIVE_INFINITY],
])('does not render for %s total', (_caseName, total) => {
renderNavigator({ total });

expect(screen.queryByTestId('loop-iteration-navigator')).not.toBeInTheDocument();
});

it('clamps activeIndex before displaying and computing callbacks', async () => {
const user = userEvent.setup();
const { onActiveIndexChange } = renderNavigator({ activeIndex: 99, total: 3 });

expect(screen.getByTestId('loop-iteration-label')).toHaveTextContent('3 / 3');
expect(screen.getByRole('button', { name: 'Next loop iteration' })).toBeDisabled();

await user.click(screen.getByRole('button', { name: 'Previous loop iteration' }));

expect(onActiveIndexChange).toHaveBeenCalledOnce();
expect(onActiveIndexChange).toHaveBeenCalledWith(1);
});

it('fires previous and next callbacks with the adjacent index', async () => {
const user = userEvent.setup();
const { onActiveIndexChange } = renderNavigator({ activeIndex: 1, total: 3 });

await user.click(screen.getByRole('button', { name: 'Previous loop iteration' }));
await user.click(screen.getByRole('button', { name: 'Next loop iteration' }));

expect(onActiveIndexChange).toHaveBeenNthCalledWith(1, 0);
expect(onActiveIndexChange).toHaveBeenNthCalledWith(2, 2);
});

it('disables previous and next at iteration boundaries', () => {
const { rerender } = render(
<IterationNavigator
iterationState={{
activeIndex: 0,
total: 3,
onActiveIndexChange: vi.fn(),
}}
/>
);

expect(screen.getByRole('button', { name: 'Previous loop iteration' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Next loop iteration' })).not.toBeDisabled();

rerender(
<IterationNavigator
iterationState={{
activeIndex: 2,
total: 3,
onActiveIndexChange: vi.fn(),
}}
/>
);

expect(screen.getByRole('button', { name: 'Previous loop iteration' })).not.toBeDisabled();
expect(screen.getByRole('button', { name: 'Next loop iteration' })).toBeDisabled();
});

it('renders as label-only when onActiveIndexChange is omitted', () => {
renderNavigator({ onActiveIndexChange: undefined });

expect(screen.getByTestId('loop-iteration-label')).toHaveTextContent('2 / 3');
expect(screen.getByRole('button', { name: 'Previous loop iteration' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Next loop iteration' })).toBeDisabled();
});

it('disables navigation when disabled', () => {
renderNavigator({ disabled: true });

expect(screen.getByRole('button', { name: 'Previous loop iteration' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Next loop iteration' })).toBeDisabled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { cn } from '@uipath/apollo-wind';
import type { SyntheticEvent } from 'react';
import { useCallback } from 'react';
import { clamp } from '../../utils';
import { CanvasIcon } from '../../utils/icon-registry';
import type { LoopIterationState } from './LoopNode.types';

interface IterationNavigatorProps {
iterationState: LoopIterationState;
}

function resolveState(iterationState: LoopIterationState): LoopIterationState | undefined {
if (!Number.isFinite(iterationState.total)) {
return undefined;
}

Comment thread
SreedharAvvari marked this conversation as resolved.
const total = Math.trunc(iterationState.total);

if (total <= 0) {
return undefined;
}

const rawActiveIndex = Number.isFinite(iterationState.activeIndex)
? Math.trunc(iterationState.activeIndex)
: 0;

return {
...iterationState,
total,
activeIndex: clamp(rawActiveIndex, 0, total - 1),
};
}

function stopCanvasControlEvent(event: SyntheticEvent) {
event.stopPropagation();
}

export function IterationNavigator({ iterationState }: IterationNavigatorProps) {
const resolvedState = resolveState(iterationState);

if (!resolvedState) {
return null;
}

return <NavigatorContent iterationState={resolvedState} />;
}

function NavigatorContent({ iterationState }: { iterationState: LoopIterationState }) {
const { activeIndex, total, onActiveIndexChange, disabled, ariaLabel } = iterationState;
const canInteract = !disabled && typeof onActiveIndexChange === 'function';
const canGoPrevious = canInteract && activeIndex > 0;
const canGoNext = canInteract && activeIndex < total - 1;
const label = ariaLabel ?? 'Loop iteration';
const visibleIndex = activeIndex + 1;

const handlePrevious = useCallback(
(event: SyntheticEvent) => {
event.stopPropagation();

if (!canGoPrevious) return;
onActiveIndexChange?.(activeIndex - 1);
},
[activeIndex, canGoPrevious, onActiveIndexChange]
);

const handleNext = useCallback(
(event: SyntheticEvent) => {
event.stopPropagation();

if (!canGoNext) return;
onActiveIndexChange?.(activeIndex + 1);
},
[activeIndex, canGoNext, onActiveIndexChange]
);

return (
<fieldset
className={cn(
'nodrag nopan pointer-events-auto m-0 flex h-6 min-w-0 shrink-0 items-center gap-1 rounded-full px-1 py-0',
'border border-border bg-surface text-foreground shadow-sm'
)}
data-testid="loop-iteration-navigator"
onPointerDown={stopCanvasControlEvent}
onMouseDown={stopCanvasControlEvent}
onDoubleClick={stopCanvasControlEvent}
>
<legend className="sr-only">
{label}: {visibleIndex} of {total}
</legend>
<button
type="button"
className={cn(
'nodrag nopan inline-flex h-4 w-4 items-center justify-center rounded-full',
'text-foreground transition-opacity',
canGoPrevious ? 'cursor-pointer opacity-100' : 'cursor-not-allowed opacity-40'
)}
disabled={!canGoPrevious}
aria-label="Previous loop iteration"
onClick={handlePrevious}
onPointerDown={stopCanvasControlEvent}
onMouseDown={stopCanvasControlEvent}
data-testid="loop-iteration-previous"
>
<CanvasIcon icon="chevron-left" size={12} />
</button>
<span
className="min-w-8 select-none px-1 text-center text-[11px] font-semibold leading-4"
data-testid="loop-iteration-label"
>
{visibleIndex} / {total}
</span>
<button
type="button"
className={cn(
'nodrag nopan inline-flex h-4 w-4 items-center justify-center rounded-full',
'text-foreground transition-opacity',
canGoNext ? 'cursor-pointer opacity-100' : 'cursor-not-allowed opacity-40'
)}
disabled={!canGoNext}
aria-label="Next loop iteration"
onClick={handleNext}
onPointerDown={stopCanvasControlEvent}
onMouseDown={stopCanvasControlEvent}
data-testid="loop-iteration-next"
>
<CanvasIcon icon="chevron-right" size={12} />
</button>
</fieldset>
);
}
Loading
Loading