From 1789988be793d87e8222a19a7593f66ef864fafc Mon Sep 17 00:00:00 2001 From: QuantumGhost Date: Wed, 25 Mar 2026 15:47:31 +0800 Subject: [PATCH 1/5] fix(api): fix concurrency issues in StreamsBroadcastChannel (#34061) --- .../redis/streams_channel.py | 108 ++++++++++++------ .../redis/test_streams_channel_unit_tests.py | 7 +- 2 files changed, 75 insertions(+), 40 deletions(-) diff --git a/api/libs/broadcast_channel/redis/streams_channel.py b/api/libs/broadcast_channel/redis/streams_channel.py index aaeaf76f7b..983f785027 100644 --- a/api/libs/broadcast_channel/redis/streams_channel.py +++ b/api/libs/broadcast_channel/redis/streams_channel.py @@ -63,24 +63,45 @@ class _StreamsSubscription(Subscription): def __init__(self, client: Redis | RedisCluster, key: str): self._client = client self._key = key - self._closed = threading.Event() - # Setting initial last id to `$` to signal redis that we only want new messages. - # - # ref: https://redis.io/docs/latest/commands/xread/#the-special--id - self._last_id = "$" + self._queue: queue.Queue[object] = queue.Queue() - self._start_lock = threading.Lock() + + # The `_lock` lock is used to + # + # 1. protect the _listener attribute + # 2. prevent repeated releases of underlying resoueces. (The _closed flag.) + # + # INVARIANT: the implementation must hold the lock while + # reading and writing the _listener / `_closed` attribute. + self._lock = threading.Lock() + self._closed: bool = False + # self._closed = threading.Event() self._listener: threading.Thread | None = None def _listen(self) -> None: - try: - while not self._closed.is_set(): - streams = self._client.xread({self._key: self._last_id}, block=1000, count=100) + """The `_listen` method handles the message retrieval loop. It requires a dedicated thread + and is not intended for direct invocation. + The thread is started by `_start_if_needed`. + """ + + # since this method runs in a dedicated thread, acquiring `_lock` inside this method won't cause + # deadlock. + + # Setting initial last id to `$` to signal redis that we only want new messages. + # + # ref: https://redis.io/docs/latest/commands/xread/#the-special--id + last_id = "$" + try: + while True: + with self._lock: + if self._closed: + break + streams = self._client.xread({self._key: last_id}, block=1000, count=100) if not streams: continue - for _key, entries in streams: + for _, entries in streams: for entry_id, fields in entries: data = None if isinstance(fields, dict): @@ -92,37 +113,48 @@ class _StreamsSubscription(Subscription): data_bytes = bytes(data) if data_bytes is not None: self._queue.put_nowait(data_bytes) - self._last_id = entry_id + last_id = entry_id finally: self._queue.put_nowait(self._SENTINEL) - self._listener = None + with self._lock: + self._listener = None + self._closed = True def _start_if_needed(self) -> None: + """This method must be called with `_lock` held.""" if self._listener is not None: return # Ensure only one listener thread is created under concurrent calls - with self._start_lock: - if self._listener is not None or self._closed.is_set(): - return - self._listener = threading.Thread( - target=self._listen, - name=f"redis-streams-sub-{self._key}", - daemon=True, - ) - self._listener.start() + if self._listener is not None or self._closed: + return + self._listener = threading.Thread( + target=self._listen, + name=f"redis-streams-sub-{self._key}", + daemon=True, + ) + self._listener.start() def __iter__(self) -> Iterator[bytes]: # Iterator delegates to receive with timeout; stops on closure. - self._start_if_needed() - while not self._closed.is_set(): - item = self.receive(timeout=1) + with self._lock: + self._start_if_needed() + + while True: + with self._lock: + if self._closed: + return + try: + item = self.receive(timeout=1) + except SubscriptionClosedError: + return if item is not None: yield item def receive(self, timeout: float | None = 0.1) -> bytes | None: - if self._closed.is_set(): - raise SubscriptionClosedError("The Redis streams subscription is closed") - self._start_if_needed() + with self._lock: + if self._closed: + raise SubscriptionClosedError("The Redis streams subscription is closed") + self._start_if_needed() try: if timeout is None: @@ -132,29 +164,33 @@ class _StreamsSubscription(Subscription): except queue.Empty: return None - if item is self._SENTINEL or self._closed.is_set(): + if item is self._SENTINEL: raise SubscriptionClosedError("The Redis streams subscription is closed") assert isinstance(item, (bytes, bytearray)), "Unexpected item type in stream queue" return bytes(item) def close(self) -> None: - if self._closed.is_set(): - return - self._closed.set() - listener = self._listener - if listener is not None: + with self._lock: + if self._closed: + return + self._closed = True + listener = self._listener + if listener is not None: + self._listener = None + # We close the listener outside of the with block to avoid holding the + # lock for a long time. + if listener is not None and listener.is_alive(): listener.join(timeout=2.0) if listener.is_alive(): logger.warning( "Streams subscription listener for key %s did not stop within timeout; keeping reference.", self._key, ) - else: - self._listener = None # Context manager helpers def __enter__(self) -> Self: - self._start_if_needed() + with self._lock: + self._start_if_needed() return self def __exit__(self, exc_type, exc_value, traceback) -> bool | None: diff --git a/api/tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py b/api/tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py index bf548f69cf..0886b70ee5 100644 --- a/api/tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py +++ b/api/tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py @@ -230,7 +230,7 @@ class TestStreamsSubscription: if self._calls == 1: key = next(iter(streams)) return [(key, [("1-0", self._fields)])] - subscription._closed.set() + subscription._closed = True return [] subscription = _StreamsSubscription(OneShotRedis(case.fields), "stream:payload-shape") @@ -244,7 +244,6 @@ class TestStreamsSubscription: received.append(bytes(item)) assert received == case.expected_messages - assert subscription._last_id == "1-0" def test_iterator_yields_messages_until_subscription_is_closed(self, streams_channel: StreamsBroadcastChannel): topic = streams_channel.topic("iter") @@ -301,7 +300,7 @@ class TestStreamsSubscription: def test_start_if_needed_returns_immediately_for_closed_subscription(self): subscription = _StreamsSubscription(FakeStreamsRedis(), "stream:already-closed") - subscription._closed.set() + subscription._closed = True subscription._start_if_needed() @@ -316,7 +315,7 @@ class TestStreamsSubscription: def fake_receive(timeout: float | None = 0.1) -> bytes | None: value = next(items) if value is not None: - subscription._closed.set() + subscription._closed = True return value subscription.receive = fake_receive # type: ignore[method-assign] From a8e1ff85db19a97a252007799ac8ac6a59dbeefa Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:03:49 +0800 Subject: [PATCH 2/5] feat(web): base-ui slider (#34064) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- web/.storybook/preview.tsx | 2 +- .../config/agent/agent-setting/index.spec.tsx | 8 +- .../config/agent/agent-setting/index.tsx | 5 +- .../params-config/config-content.spec.tsx | 6 +- .../params-config/weighted-score.css | 7 - .../params-config/weighted-score.spec.tsx | 11 +- .../params-config/weighted-score.tsx | 36 +- .../annotation-reply/config-param-modal.tsx | 1 - .../score-slider/__tests__/index.spec.tsx | 23 +- .../base-slider/__tests__/index.spec.tsx | 50 -- .../score-slider/base-slider/index.tsx | 40 -- .../score-slider/base-slider/style.module.css | 20 - .../annotation-reply/score-slider/index.tsx | 29 +- .../__tests__/index-slider.spec.tsx | 10 +- .../base/param-item/__tests__/index.spec.tsx | 22 +- .../__tests__/score-threshold-item.spec.tsx | 6 +- .../param-item/__tests__/top-k-item.spec.tsx | 14 +- web/app/components/base/param-item/index.tsx | 5 +- .../base/slider/__tests__/index.spec.tsx | 77 --- .../components/base/slider/index.stories.tsx | 635 ------------------ web/app/components/base/slider/index.tsx | 43 -- web/app/components/base/slider/style.css | 11 - .../base/ui/slider/__tests__/index.spec.tsx | 73 ++ .../base/ui/slider/index.stories.tsx | 92 +++ web/app/components/base/ui/slider/index.tsx | 100 +++ .../index-method/__tests__/index.spec.tsx | 5 +- .../__tests__/keyword-number.spec.tsx | 20 +- .../settings/index-method/keyword-number.tsx | 5 +- .../__tests__/parameter-item.spec.tsx | 6 +- .../model-parameter-modal/parameter-item.tsx | 13 +- .../__tests__/agent-strategy.spec.tsx | 2 +- .../nodes/_base/components/agent-strategy.tsx | 5 +- .../components/input-number-with-slider.tsx | 7 +- .../nodes/_base/components/memory-config.tsx | 7 +- .../_base/components/retry/retry-on-panel.tsx | 8 +- .../workflow/nodes/iteration/panel.tsx | 11 +- .../__tests__/index-method.spec.tsx | 2 +- .../components/index-method.tsx | 7 +- .../components/__tests__/integration.spec.tsx | 4 +- .../components/on-minute-selector.tsx | 5 +- web/eslint-suppressions.json | 35 - web/package.json | 2 - web/pnpm-lock.yaml | 23 - 43 files changed, 425 insertions(+), 1068 deletions(-) delete mode 100644 web/app/components/app/configuration/dataset-config/params-config/weighted-score.css delete mode 100644 web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/__tests__/index.spec.tsx delete mode 100644 web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.tsx delete mode 100644 web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/style.module.css delete mode 100644 web/app/components/base/slider/__tests__/index.spec.tsx delete mode 100644 web/app/components/base/slider/index.stories.tsx delete mode 100644 web/app/components/base/slider/index.tsx delete mode 100644 web/app/components/base/slider/style.css create mode 100644 web/app/components/base/ui/slider/__tests__/index.spec.tsx create mode 100644 web/app/components/base/ui/slider/index.stories.tsx create mode 100644 web/app/components/base/ui/slider/index.tsx diff --git a/web/.storybook/preview.tsx b/web/.storybook/preview.tsx index 5b38424776..072244c33f 100644 --- a/web/.storybook/preview.tsx +++ b/web/.storybook/preview.tsx @@ -7,7 +7,7 @@ import { I18nClientProvider as I18N } from '../app/components/provider/i18n' import commonEnUS from '../i18n/en-US/common.json' import '../app/styles/globals.css' -import '../app/styles/markdown.scss' +import '../app/styles/markdown.css' import './storybook.css' const queryClient = new QueryClient({ diff --git a/web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx b/web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx index b3a9bd7abc..1b8d64b911 100644 --- a/web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx +++ b/web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx @@ -12,15 +12,15 @@ vi.mock('ahooks', async (importOriginal) => { } }) -vi.mock('react-slider', () => ({ - default: (props: { className?: string, min?: number, max?: number, value: number, onChange: (value: number) => void }) => ( +vi.mock('@/app/components/base/ui/slider', () => ({ + Slider: (props: { className?: string, min?: number, max?: number, value: number, onValueChange: (value: number) => void }) => ( props.onChange(Number(e.target.value))} + onChange={e => props.onValueChange(Number(e.target.value))} /> ), })) diff --git a/web/app/components/app/configuration/config/agent/agent-setting/index.tsx b/web/app/components/app/configuration/config/agent/agent-setting/index.tsx index ec42e946dd..bce4e74aab 100644 --- a/web/app/components/app/configuration/config/agent/agent-setting/index.tsx +++ b/web/app/components/app/configuration/config/agent/agent-setting/index.tsx @@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import { CuteRobot } from '@/app/components/base/icons/src/vender/solid/communication' import { Unblur } from '@/app/components/base/icons/src/vender/solid/education' -import Slider from '@/app/components/base/slider' +import { Slider } from '@/app/components/base/ui/slider' import { DEFAULT_AGENT_PROMPT, MAX_ITERATIONS_NUM } from '@/config' import ItemPanel from './item-panel' @@ -105,12 +105,13 @@ const AgentSetting: FC = ({ min={maxIterationsMin} max={MAX_ITERATIONS_NUM} value={tempPayload.max_iteration} - onChange={(value) => { + onValueChange={(value) => { setTempPayload({ ...tempPayload, max_iteration: value, }) }} + aria-label={t('agent.setting.maximumIterations.name', { ns: 'appDebug' })} /> { />, ) - const weightedScoreSlider = screen.getAllByRole('slider') - .find(slider => slider.getAttribute('aria-valuemax') === '1') - expect(weightedScoreSlider).toBeDefined() - await user.click(weightedScoreSlider!) + const weightedScoreSlider = screen.getByLabelText('dataset.weightedScore.semantic') + weightedScoreSlider.focus() const callsBefore = onChange.mock.calls.length await user.keyboard('{ArrowRight}') diff --git a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.css b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.css deleted file mode 100644 index ef9350645a..0000000000 --- a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.css +++ /dev/null @@ -1,7 +0,0 @@ -.weightedScoreSliderTrack { - background: var(--color-util-colors-blue-light-blue-light-500) !important; -} - -.weightedScoreSliderTrack-1 { - background: transparent !important; -} diff --git a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.spec.tsx index 7729830348..8e9348c77a 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.spec.tsx @@ -3,6 +3,8 @@ import userEvent from '@testing-library/user-event' import WeightedScore from './weighted-score' describe('WeightedScore', () => { + const getSliderInput = () => screen.getByLabelText('dataset.weightedScore.semantic') + beforeEach(() => { vi.clearAllMocks() }) @@ -48,8 +50,8 @@ describe('WeightedScore', () => { render() // Act - await user.tab() - const slider = screen.getByRole('slider') + const slider = getSliderInput() + slider.focus() expect(slider).toHaveFocus() const callsBefore = onChange.mock.calls.length await user.keyboard('{ArrowRight}') @@ -69,9 +71,8 @@ describe('WeightedScore', () => { render() // Act - await user.tab() - const slider = screen.getByRole('slider') - expect(slider).toHaveFocus() + const slider = getSliderInput() + expect(slider).toBeDisabled() await user.keyboard('{ArrowRight}') // Assert diff --git a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx index 40beef52e8..d4ce935a4d 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx @@ -1,9 +1,13 @@ +import type { CSSProperties } from 'react' import { noop } from 'es-toolkit/function' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import Slider from '@/app/components/base/slider' -import { cn } from '@/utils/classnames' -import './weighted-score.css' +import { Slider } from '@/app/components/base/ui/slider' + +const weightedScoreSliderStyle: CSSProperties & Record<'--slider-track' | '--slider-range', string> = { + '--slider-track': 'var(--color-util-colors-teal-teal-500)', + '--slider-range': 'var(--color-util-colors-blue-light-blue-light-500)', +} const formatNumber = (value: number) => { if (value > 0 && value < 1) @@ -33,24 +37,26 @@ const WeightedScore = ({ return (
- !readonly && onChange({ value: [v, (10 - v * 10) / 10] })} - trackClassName="weightedScoreSliderTrack" - disabled={readonly} - /> +
+ !readonly && onChange({ value: [v, (10 - v * 10) / 10] })} + disabled={readonly} + aria-label={t('weightedScore.semantic', { ns: 'dataset' })} + /> +
-
+
{t('weightedScore.semantic', { ns: 'dataset' })}
{formatNumber(value.value[0])}
-
+
{formatNumber(value.value[1])}
{t('weightedScore.keyword', { ns: 'dataset' })} diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx index 332b87cb30..ac0b6d0f57 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx @@ -93,7 +93,6 @@ const ConfigParamModal: FC = ({ className="mt-1" value={(annotationConfig.score_threshold || ANNOTATION_DEFAULT.score_threshold) * 100} onChange={(val) => { - /* v8 ignore next -- callback dispatch depends on react-slider drag mechanics that are flaky in jsdom. @preserve */ setAnnotationConfig({ ...annotationConfig, score_threshold: val / 100, diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/__tests__/index.spec.tsx index 2bc30e4ead..ffa9c33043 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/__tests__/index.spec.tsx @@ -1,20 +1,9 @@ import { render, screen } from '@testing-library/react' import ScoreSlider from '../index' -vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider', () => ({ - default: ({ value, onChange, min, max }: { value: number, onChange: (v: number) => void, min: number, max: number }) => ( - onChange(Number(e.target.value))} - /> - ), -})) - describe('ScoreSlider', () => { + const getSliderInput = () => screen.getByLabelText('appDebug.feature.annotation.scoreThreshold.title') + beforeEach(() => { vi.clearAllMocks() }) @@ -22,7 +11,7 @@ describe('ScoreSlider', () => { it('should render the slider', () => { render() - expect(screen.getByTestId('slider')).toBeInTheDocument() + expect(getSliderInput()).toBeInTheDocument() }) it('should display easy match and accurate match labels', () => { @@ -37,14 +26,14 @@ describe('ScoreSlider', () => { it('should render with custom className', () => { const { container } = render() - // Verifying the component renders successfully with a custom className - expect(screen.getByTestId('slider')).toBeInTheDocument() + expect(getSliderInput()).toBeInTheDocument() expect(container.firstChild).toHaveClass('custom-class') }) it('should pass value to the slider', () => { render() - expect(screen.getByTestId('slider')).toHaveValue('95') + expect(getSliderInput()).toHaveValue('95') + expect(screen.getByText('0.95')).toBeInTheDocument() }) }) diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/__tests__/index.spec.tsx deleted file mode 100644 index 815e8ffe49..0000000000 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/__tests__/index.spec.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { render, screen } from '@testing-library/react' -import Slider from '../index' - -describe('BaseSlider', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should render the slider component', () => { - render() - - expect(screen.getByRole('slider')).toBeInTheDocument() - }) - - it('should display the formatted value in the thumb', () => { - render() - - expect(screen.getByText('0.85')).toBeInTheDocument() - }) - - it('should use default min/max/step when not provided', () => { - render() - - const slider = screen.getByRole('slider') - expect(slider).toHaveAttribute('aria-valuemin', '0') - expect(slider).toHaveAttribute('aria-valuemax', '100') - expect(slider).toHaveAttribute('aria-valuenow', '50') - }) - - it('should use custom min/max/step when provided', () => { - render() - - const slider = screen.getByRole('slider') - expect(slider).toHaveAttribute('aria-valuemin', '80') - expect(slider).toHaveAttribute('aria-valuemax', '100') - expect(slider).toHaveAttribute('aria-valuenow', '90') - }) - - it('should handle NaN value as 0', () => { - render() - - expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0') - }) - - it('should pass disabled prop', () => { - render() - - expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true') - }) -}) diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.tsx deleted file mode 100644 index 509426c08e..0000000000 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import ReactSlider from 'react-slider' -import { cn } from '@/utils/classnames' -import s from './style.module.css' - -type ISliderProps = { - className?: string - value: number - max?: number - min?: number - step?: number - disabled?: boolean - onChange: (value: number) => void -} - -const Slider: React.FC = ({ className, max, min, step, value, disabled, onChange }) => { - return ( - ( -
-
-
- {(state.valueNow / 100).toFixed(2)} -
-
-
- )} - /> - ) -} - -export default Slider diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/style.module.css b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/style.module.css deleted file mode 100644 index 8ef23b54b5..0000000000 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/style.module.css +++ /dev/null @@ -1,20 +0,0 @@ -.slider { - position: relative; -} - -.slider.disabled { - opacity: 0.6; -} - -.slider-thumb:focus { - outline: none; -} - -.slider-track { - background-color: #528BFF; - height: 2px; -} - -.slider-track-1 { - background-color: #E5E7EB; -} diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/index.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/index.tsx index c6fb1a0b4e..0363eb2820 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/index.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/index.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Slider from '@/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider' +import { Slider } from '@/app/components/base/ui/slider' type Props = { className?: string @@ -10,23 +10,42 @@ type Props = { onChange: (value: number) => void } +const clamp = (value: number, min: number, max: number) => { + if (!Number.isFinite(value)) + return min + + return Math.min(Math.max(value, min), max) +} + const ScoreSlider: FC = ({ className, value, onChange, }) => { const { t } = useTranslation() + const safeValue = clamp(value, 80, 100) return (
-
+
+
+ {(safeValue / 100).toFixed(2)} +
diff --git a/web/app/components/base/param-item/__tests__/index-slider.spec.tsx b/web/app/components/base/param-item/__tests__/index-slider.spec.tsx index 0048b89644..6448835844 100644 --- a/web/app/components/base/param-item/__tests__/index-slider.spec.tsx +++ b/web/app/components/base/param-item/__tests__/index-slider.spec.tsx @@ -14,12 +14,14 @@ describe('ParamItem Slider onChange', () => { vi.clearAllMocks() }) + const getSlider = () => screen.getByLabelText('Test Param') + it('should divide slider value by 100 when max < 5', async () => { const user = userEvent.setup() render() - const slider = screen.getByRole('slider') + const slider = getSlider() - await user.click(slider) + slider.focus() await user.keyboard('{ArrowRight}') // max=1 < 5, so slider value change (50->51) becomes 0.51 @@ -29,9 +31,9 @@ describe('ParamItem Slider onChange', () => { it('should not divide slider value when max >= 5', async () => { const user = userEvent.setup() render() - const slider = screen.getByRole('slider') + const slider = getSlider() - await user.click(slider) + slider.focus() await user.keyboard('{ArrowRight}') // max=10 >= 5, so value remains raw (5->6) diff --git a/web/app/components/base/param-item/__tests__/index.spec.tsx b/web/app/components/base/param-item/__tests__/index.spec.tsx index 96591446c8..889662c87d 100644 --- a/web/app/components/base/param-item/__tests__/index.spec.tsx +++ b/web/app/components/base/param-item/__tests__/index.spec.tsx @@ -17,6 +17,8 @@ describe('ParamItem', () => { vi.clearAllMocks() }) + const getSlider = () => screen.getByLabelText('Test Param') + describe('Rendering', () => { it('should render the parameter name', () => { render() @@ -54,7 +56,7 @@ describe('ParamItem', () => { render() expect(screen.getByRole('textbox')).toBeInTheDocument() - expect(screen.getByRole('slider')).toBeInTheDocument() + expect(getSlider()).toBeInTheDocument() }) }) @@ -74,7 +76,7 @@ describe('ParamItem', () => { it('should disable Slider when enable is false', () => { render() - expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true') + expect(getSlider()).toBeDisabled() }) it('should set switch value based on enable prop', () => { @@ -135,7 +137,7 @@ describe('ParamItem', () => { await user.clear(input) expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 0) - expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0') + expect(getSlider()).toHaveAttribute('aria-valuenow', '0') await user.tab() @@ -166,12 +168,12 @@ describe('ParamItem', () => { await user.type(input, '1.5') expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 1) - expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '100') + expect(getSlider()).toHaveAttribute('aria-valuenow', '100') }) it('should pass scaled value to slider when max < 5', () => { render() - const slider = screen.getByRole('slider') + const slider = getSlider() // When max < 5, slider value = value * 100 = 50 expect(slider).toHaveAttribute('aria-valuenow', '50') @@ -179,7 +181,7 @@ describe('ParamItem', () => { it('should pass raw value to slider when max >= 5', () => { render() - const slider = screen.getByRole('slider') + const slider = getSlider() // When max >= 5, slider value = value = 5 expect(slider).toHaveAttribute('aria-valuenow', '5') @@ -212,15 +214,15 @@ describe('ParamItem', () => { render() // Slider should get value * 100 = 50, min * 100 = 0, max * 100 = 100 - const slider = screen.getByRole('slider') - expect(slider).toHaveAttribute('aria-valuemax', '100') + const slider = getSlider() + expect(slider).toHaveAttribute('max', '100') }) it('should not scale slider value when max >= 5', () => { render() - const slider = screen.getByRole('slider') - expect(slider).toHaveAttribute('aria-valuemax', '10') + const slider = getSlider() + expect(slider).toHaveAttribute('max', '10') }) it('should expose default minimum of 0 when min is not provided', () => { diff --git a/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx b/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx index 54a13e1b74..ddc286942b 100644 --- a/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx +++ b/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx @@ -14,6 +14,8 @@ describe('ScoreThresholdItem', () => { vi.clearAllMocks() }) + const getSlider = () => screen.getByLabelText('appDebug.datasetConfig.score_threshold') + describe('Rendering', () => { it('should render the translated parameter name', () => { render() @@ -32,7 +34,7 @@ describe('ScoreThresholdItem', () => { render() expect(screen.getByRole('textbox')).toBeInTheDocument() - expect(screen.getByRole('slider')).toBeInTheDocument() + expect(getSlider()).toBeInTheDocument() }) }) @@ -63,7 +65,7 @@ describe('ScoreThresholdItem', () => { render() expect(screen.getByRole('textbox')).toBeDisabled() - expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true') + expect(getSlider()).toBeDisabled() }) }) diff --git a/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx b/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx index 1b8555213b..c84fd50518 100644 --- a/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx +++ b/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx @@ -19,6 +19,8 @@ describe('TopKItem', () => { vi.clearAllMocks() }) + const getSlider = () => screen.getByLabelText('appDebug.datasetConfig.top_k') + describe('Rendering', () => { it('should render the translated parameter name', () => { render() @@ -37,7 +39,7 @@ describe('TopKItem', () => { render() expect(screen.getByRole('textbox')).toBeInTheDocument() - expect(screen.getByRole('slider')).toBeInTheDocument() + expect(getSlider()).toBeInTheDocument() }) }) @@ -52,7 +54,7 @@ describe('TopKItem', () => { render() expect(screen.getByRole('textbox')).toBeDisabled() - expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true') + expect(getSlider()).toBeDisabled() }) }) @@ -77,10 +79,10 @@ describe('TopKItem', () => { it('should render slider with max >= 5 so no scaling is applied', () => { render() - const slider = screen.getByRole('slider') + const slider = getSlider() // max=10 >= 5 so slider shows raw values - expect(slider).toHaveAttribute('aria-valuemax', '10') + expect(slider).toHaveAttribute('max', '10') }) it('should not render a switch (no hasSwitch prop)', () => { @@ -116,9 +118,9 @@ describe('TopKItem', () => { it('should call onChange with integer value when slider changes', async () => { const user = userEvent.setup() render() - const slider = screen.getByRole('slider') + const slider = getSlider() - await user.click(slider) + slider.focus() await user.keyboard('{ArrowRight}') expect(defaultProps.onChange).toHaveBeenLastCalledWith('top_k', 3) diff --git a/web/app/components/base/param-item/index.tsx b/web/app/components/base/param-item/index.tsx index 63af4bca84..56999fc6ea 100644 --- a/web/app/components/base/param-item/index.tsx +++ b/web/app/components/base/param-item/index.tsx @@ -1,8 +1,8 @@ 'use client' import type { FC } from 'react' -import Slider from '@/app/components/base/slider' import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' +import { Slider } from '@/app/components/base/ui/slider' import { NumberField, NumberFieldControls, @@ -78,7 +78,8 @@ const ParamItem: FC = ({ className, id, name, noTooltip, tip, step = 0.1, value={max < 5 ? value * 100 : value} min={min < 1 ? min * 100 : min} max={max < 5 ? max * 100 : max} - onChange={value => onChange(id, value / (max < 5 ? 100 : 1))} + onValueChange={value => onChange(id, value / (max < 5 ? 100 : 1))} + aria-label={name} />
diff --git a/web/app/components/base/slider/__tests__/index.spec.tsx b/web/app/components/base/slider/__tests__/index.spec.tsx deleted file mode 100644 index bb1f030689..0000000000 --- a/web/app/components/base/slider/__tests__/index.spec.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { act, render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { describe, expect, it, vi } from 'vitest' -import Slider from '../index' - -describe('Slider Component', () => { - it('should render with correct default ARIA limits and current value', () => { - render() - - const slider = screen.getByRole('slider') - expect(slider).toHaveAttribute('aria-valuemin', '0') - expect(slider).toHaveAttribute('aria-valuemax', '100') - expect(slider).toHaveAttribute('aria-valuenow', '50') - }) - - it('should apply custom min, max, and step values', () => { - render() - - const slider = screen.getByRole('slider') - expect(slider).toHaveAttribute('aria-valuemin', '5') - expect(slider).toHaveAttribute('aria-valuemax', '20') - expect(slider).toHaveAttribute('aria-valuenow', '10') - }) - - it('should default to 0 if the value prop is NaN', () => { - render() - - const slider = screen.getByRole('slider') - expect(slider).toHaveAttribute('aria-valuenow', '0') - }) - - it('should call onChange when arrow keys are pressed', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - - render() - - const slider = screen.getByRole('slider') - - await act(async () => { - slider.focus() - await user.keyboard('{ArrowRight}') - }) - - expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenCalledWith(21, 0) - }) - - it('should not trigger onChange when disabled', async () => { - const user = userEvent.setup() - const onChange = vi.fn() - render() - - const slider = screen.getByRole('slider') - - expect(slider).toHaveAttribute('aria-disabled', 'true') - - await act(async () => { - slider.focus() - await user.keyboard('{ArrowRight}') - }) - - expect(onChange).not.toHaveBeenCalled() - }) - - it('should apply custom class names', () => { - render( - , - ) - - const sliderWrapper = screen.getByRole('slider').closest('.outer-test') - expect(sliderWrapper).toBeInTheDocument() - - const thumb = screen.getByRole('slider') - expect(thumb).toHaveClass('thumb-test') - }) -}) diff --git a/web/app/components/base/slider/index.stories.tsx b/web/app/components/base/slider/index.stories.tsx deleted file mode 100644 index bde937ffad..0000000000 --- a/web/app/components/base/slider/index.stories.tsx +++ /dev/null @@ -1,635 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { useState } from 'react' -import Slider from '.' - -const meta = { - title: 'Base/Data Entry/Slider', - component: Slider, - parameters: { - layout: 'centered', - docs: { - description: { - component: 'Slider component for selecting a numeric value within a range. Built on react-slider with customizable min/max/step values.', - }, - }, - }, - tags: ['autodocs'], - argTypes: { - value: { - control: 'number', - description: 'Current slider value', - }, - min: { - control: 'number', - description: 'Minimum value (default: 0)', - }, - max: { - control: 'number', - description: 'Maximum value (default: 100)', - }, - step: { - control: 'number', - description: 'Step increment (default: 1)', - }, - disabled: { - control: 'boolean', - description: 'Disabled state', - }, - }, - args: { - onChange: (value) => { - console.log('Slider value:', value) - }, - }, -} satisfies Meta - -export default meta -type Story = StoryObj - -// Interactive demo wrapper -const SliderDemo = (args: any) => { - const [value, setValue] = useState(args.value || 50) - - return ( -
- { - setValue(v) - console.log('Slider value:', v) - }} - /> -
- Value: - {' '} - {value} -
-
- ) -} - -// Default state -export const Default: Story = { - render: args => , - args: { - value: 50, - min: 0, - max: 100, - step: 1, - disabled: false, - }, -} - -// With custom range -export const CustomRange: Story = { - render: args => , - args: { - value: 25, - min: 0, - max: 50, - step: 1, - disabled: false, - }, -} - -// With step increment -export const WithStepIncrement: Story = { - render: args => , - args: { - value: 50, - min: 0, - max: 100, - step: 10, - disabled: false, - }, -} - -// Decimal values -export const DecimalValues: Story = { - render: args => , - args: { - value: 2.5, - min: 0, - max: 5, - step: 0.5, - disabled: false, - }, -} - -// Disabled state -export const Disabled: Story = { - render: args => , - args: { - value: 75, - min: 0, - max: 100, - step: 1, - disabled: true, - }, -} - -// Real-world example - Volume control -const VolumeControlDemo = () => { - const [volume, setVolume] = useState(70) - - const getVolumeIcon = (vol: number) => { - if (vol === 0) - return '🔇' - if (vol < 33) - return '🔈' - if (vol < 66) - return '🔉' - return '🔊' - } - - return ( -
-
-

Volume Control

- {getVolumeIcon(volume)} -
- -
- Mute - - {volume} - % - - Max -
-
- ) -} - -export const VolumeControl: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Real-world example - Brightness control -const BrightnessControlDemo = () => { - const [brightness, setBrightness] = useState(80) - - return ( -
-
-

Screen Brightness

- ☀️ -
- -
-
- Preview at - {' '} - {brightness} - % brightness -
-
-
- ) -} - -export const BrightnessControl: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Real-world example - Price range filter -const PriceRangeFilterDemo = () => { - const [maxPrice, setMaxPrice] = useState(500) - const minPrice = 0 - - const products = [ - { name: 'Product A', price: 150 }, - { name: 'Product B', price: 350 }, - { name: 'Product C', price: 600 }, - { name: 'Product D', price: 250 }, - { name: 'Product E', price: 450 }, - ] - - const filteredProducts = products.filter(p => p.price >= minPrice && p.price <= maxPrice) - - return ( -
-

Filter by Price

-
-
- Maximum Price - - $ - {maxPrice} - -
- -
-
-
- Showing - {' '} - {filteredProducts.length} - {' '} - of - {' '} - {products.length} - {' '} - products -
-
- {filteredProducts.map(product => ( -
- {product.name} - - $ - {product.price} - -
- ))} -
-
-
- ) -} - -export const PriceRangeFilter: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Real-world example - Temperature selector -const TemperatureSelectorDemo = () => { - const [temperature, setTemperature] = useState(22) - const fahrenheit = ((temperature * 9) / 5 + 32).toFixed(1) - - return ( -
-

Thermostat Control

-
- -
-
-
-
Celsius
-
- {temperature} - °C -
-
-
-
Fahrenheit
-
- {fahrenheit} - °F -
-
-
-
- {temperature < 18 && '🥶 Too cold'} - {temperature >= 18 && temperature <= 24 && '😊 Comfortable'} - {temperature > 24 && '🥵 Too warm'} -
-
- ) -} - -export const TemperatureSelector: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Real-world example - Progress/completion slider -const ProgressSliderDemo = () => { - const [progress, setProgress] = useState(65) - - return ( -
-

Project Completion

- -
-
- Progress - - {progress} - % - -
-
-
- = 25 ? '✅' : '⏳'}>Planning - 25% -
-
- = 50 ? '✅' : '⏳'}>Development - 50% -
-
- = 75 ? '✅' : '⏳'}>Testing - 75% -
-
- = 100 ? '✅' : '⏳'}>Deployment - 100% -
-
-
-
- ) -} - -export const ProgressSlider: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Real-world example - Zoom control -const ZoomControlDemo = () => { - const [zoom, setZoom] = useState(100) - - return ( -
-

Zoom Level

-
- -
- -
- -
-
- 50% - - {zoom} - % - - 200% -
-
-
Preview content
-
-
- ) -} - -export const ZoomControl: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Real-world example - AI model parameters -const AIModelParametersDemo = () => { - const [temperature, setTemperature] = useState(0.7) - const [maxTokens, setMaxTokens] = useState(2000) - const [topP, setTopP] = useState(0.9) - - return ( -
-

Model Configuration

-
-
-
- - {temperature} -
- -

- Controls randomness. Lower is more focused, higher is more creative. -

-
- -
-
- - {maxTokens} -
- -

- Maximum length of generated response. -

-
- -
-
- - {topP} -
- -

- Nucleus sampling threshold. -

-
-
-
-
- Temperature: - {' '} - {temperature} -
-
- Max Tokens: - {' '} - {maxTokens} -
-
- Top P: - {' '} - {topP} -
-
-
- ) -} - -export const AIModelParameters: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Real-world example - Image quality selector -const ImageQualitySelectorDemo = () => { - const [quality, setQuality] = useState(80) - - const getQualityLabel = (q: number) => { - if (q < 50) - return 'Low' - if (q < 70) - return 'Medium' - if (q < 90) - return 'High' - return 'Maximum' - } - - const estimatedSize = Math.round((quality / 100) * 5) - - return ( -
-

Image Export Quality

- -
-
-
Quality
-
{getQualityLabel(quality)}
-
- {quality} - % -
-
-
-
File Size
-
- ~ - {estimatedSize} - {' '} - MB -
-
Estimated
-
-
-
- ) -} - -export const ImageQualitySelector: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Multiple sliders -const MultipleSlidersDemo = () => { - const [red, setRed] = useState(128) - const [green, setGreen] = useState(128) - const [blue, setBlue] = useState(128) - - const rgbColor = `rgb(${red}, ${green}, ${blue})` - - return ( -
-

RGB Color Picker

-
-
-
- - {red} -
- -
-
-
- - {green} -
- -
-
-
- - {blue} -
- -
-
-
-
-
-
Color Value
-
{rgbColor}
-
- # - {red.toString(16).padStart(2, '0')} - {green.toString(16).padStart(2, '0')} - {blue.toString(16).padStart(2, '0')} -
-
-
-
- ) -} - -export const MultipleSliders: Story = { - render: () => , - parameters: { controls: { disable: true } }, -} as unknown as Story - -// Interactive playground -export const Playground: Story = { - render: args => , - args: { - value: 50, - min: 0, - max: 100, - step: 1, - disabled: false, - }, -} diff --git a/web/app/components/base/slider/index.tsx b/web/app/components/base/slider/index.tsx deleted file mode 100644 index 4e4656f590..0000000000 --- a/web/app/components/base/slider/index.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import ReactSlider from 'react-slider' -import { cn } from '@/utils/classnames' -import './style.css' - -type ISliderProps = { - className?: string - thumbClassName?: string - trackClassName?: string - value: number - max?: number - min?: number - step?: number - disabled?: boolean - onChange: (value: number) => void -} - -const Slider: React.FC = ({ - className, - thumbClassName, - trackClassName, - max, - min, - step, - value, - disabled, - onChange, -}) => { - return ( - - ) -} - -export default Slider diff --git a/web/app/components/base/slider/style.css b/web/app/components/base/slider/style.css deleted file mode 100644 index 5d87fb0897..0000000000 --- a/web/app/components/base/slider/style.css +++ /dev/null @@ -1,11 +0,0 @@ -.slider.disabled { - opacity: 0.6; -} - -.slider-track { - background-color: var(--color-components-slider-range); -} - -.slider-track-1 { - background-color: var(--color-components-slider-track); -} diff --git a/web/app/components/base/ui/slider/__tests__/index.spec.tsx b/web/app/components/base/ui/slider/__tests__/index.spec.tsx new file mode 100644 index 0000000000..f34de5010d --- /dev/null +++ b/web/app/components/base/ui/slider/__tests__/index.spec.tsx @@ -0,0 +1,73 @@ +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { Slider } from '../index' + +describe('Slider', () => { + const getSliderInput = () => screen.getByLabelText('Value') + + it('should render with correct default ARIA limits and current value', () => { + render() + + const slider = getSliderInput() + expect(slider).toHaveAttribute('min', '0') + expect(slider).toHaveAttribute('max', '100') + expect(slider).toHaveAttribute('aria-valuenow', '50') + }) + + it('should apply custom min, max, and step values', () => { + render() + + const slider = getSliderInput() + expect(slider).toHaveAttribute('min', '5') + expect(slider).toHaveAttribute('max', '20') + expect(slider).toHaveAttribute('aria-valuenow', '10') + }) + + it('should clamp non-finite values to min', () => { + render() + + expect(getSliderInput()).toHaveAttribute('aria-valuenow', '5') + }) + + it('should call onValueChange when arrow keys are pressed', async () => { + const user = userEvent.setup() + const onValueChange = vi.fn() + + render() + + const slider = getSliderInput() + + await act(async () => { + slider.focus() + await user.keyboard('{ArrowRight}') + }) + + expect(onValueChange).toHaveBeenCalledTimes(1) + expect(onValueChange).toHaveBeenLastCalledWith(21, expect.anything()) + }) + + it('should not trigger onValueChange when disabled', async () => { + const user = userEvent.setup() + const onValueChange = vi.fn() + render() + + const slider = getSliderInput() + + expect(slider).toBeDisabled() + + await act(async () => { + slider.focus() + await user.keyboard('{ArrowRight}') + }) + + expect(onValueChange).not.toHaveBeenCalled() + }) + + it('should apply custom class names on root', () => { + const { container } = render() + + const sliderWrapper = container.querySelector('.outer-test') + expect(sliderWrapper).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/ui/slider/index.stories.tsx b/web/app/components/base/ui/slider/index.stories.tsx new file mode 100644 index 0000000000..b61a6cb288 --- /dev/null +++ b/web/app/components/base/ui/slider/index.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type * as React from 'react' +import { useState } from 'react' +import { Slider } from '.' + +const meta = { + title: 'Base UI/Data Entry/Slider', + component: Slider, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Single-value horizontal slider built on Base UI.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + value: { + control: 'number', + }, + min: { + control: 'number', + }, + max: { + control: 'number', + }, + step: { + control: 'number', + }, + disabled: { + control: 'boolean', + }, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +function SliderDemo({ + value: initialValue = 50, + defaultValue: _defaultValue, + ...args +}: React.ComponentProps) { + const [value, setValue] = useState(initialValue) + + return ( +
+ +
+ {value} +
+
+ ) +} + +export const Default: Story = { + render: args => , + args: { + value: 50, + min: 0, + max: 100, + step: 1, + }, +} + +export const Decimal: Story = { + render: args => , + args: { + value: 0.5, + min: 0, + max: 1, + step: 0.1, + }, +} + +export const Disabled: Story = { + render: args => , + args: { + value: 75, + min: 0, + max: 100, + step: 1, + disabled: true, + }, +} diff --git a/web/app/components/base/ui/slider/index.tsx b/web/app/components/base/ui/slider/index.tsx new file mode 100644 index 0000000000..8e1dc969bc --- /dev/null +++ b/web/app/components/base/ui/slider/index.tsx @@ -0,0 +1,100 @@ +'use client' + +import { Slider as BaseSlider } from '@base-ui/react/slider' +import * as React from 'react' +import { cn } from '@/utils/classnames' + +type SliderRootProps = BaseSlider.Root.Props +type SliderThumbProps = BaseSlider.Thumb.Props + +type SliderBaseProps = Pick< + SliderRootProps, + 'onValueChange' | 'min' | 'max' | 'step' | 'disabled' | 'name' +> & Pick & { + className?: string +} + +type ControlledSliderProps = SliderBaseProps & { + value: number + defaultValue?: never +} + +type UncontrolledSliderProps = SliderBaseProps & { + value?: never + defaultValue?: number +} + +export type SliderProps = ControlledSliderProps | UncontrolledSliderProps + +const sliderRootClassName = 'group/slider relative inline-flex w-full data-[disabled]:opacity-30' +const sliderControlClassName = cn( + 'relative flex h-5 w-full touch-none select-none items-center', + 'data-[disabled]:cursor-not-allowed', +) +const sliderTrackClassName = cn( + 'relative h-1 w-full overflow-hidden rounded-full', + 'bg-[var(--slider-track,var(--color-components-slider-track))]', +) +const sliderIndicatorClassName = cn( + 'h-full rounded-full', + 'bg-[var(--slider-range,var(--color-components-slider-range))]', +) +const sliderThumbClassName = cn( + 'block h-5 w-2 shrink-0 rounded-[3px] border-[0.5px]', + 'border-[var(--slider-knob-border,var(--color-components-slider-knob-border))]', + 'bg-[var(--slider-knob,var(--color-components-slider-knob))] shadow-sm', + 'transition-[background-color,border-color,box-shadow,opacity] motion-reduce:transition-none', + 'hover:bg-[var(--slider-knob-hover,var(--color-components-slider-knob-hover))]', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-slider-knob-border-hover focus-visible:ring-offset-0', + 'active:shadow-md', + 'group-data-[disabled]/slider:bg-[var(--slider-knob-disabled,var(--color-components-slider-knob-disabled))]', + 'group-data-[disabled]/slider:border-[var(--slider-knob-border,var(--color-components-slider-knob-border))]', + 'group-data-[disabled]/slider:shadow-none', +) + +const getSafeValue = (value: number | undefined, min: number) => { + if (value === undefined) + return undefined + + return Number.isFinite(value) ? value : min +} + +export function Slider({ + value, + defaultValue, + onValueChange, + min = 0, + max = 100, + step = 1, + disabled = false, + name, + className, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, +}: SliderProps) { + return ( + + + + + + + + + ) +} diff --git a/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx b/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx index ae2c17d880..7441274155 100644 --- a/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx +++ b/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx @@ -14,6 +14,8 @@ describe('IndexMethod', () => { vi.clearAllMocks() }) + const getKeywordSlider = () => screen.getByLabelText('datasetSettings.form.numberOfKeywords') + describe('Rendering', () => { it('should render without crashing', () => { render() @@ -123,8 +125,7 @@ describe('IndexMethod', () => { describe('KeywordNumber', () => { it('should render KeywordNumber component inside Economy option', () => { render() - // KeywordNumber has a slider - expect(screen.getByRole('slider')).toBeInTheDocument() + expect(getKeywordSlider()).toBeInTheDocument() }) it('should pass keywordNumber to KeywordNumber component', () => { diff --git a/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx b/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx index e7ba8af6f1..cd0d56bbeb 100644 --- a/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx +++ b/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx @@ -11,6 +11,8 @@ describe('KeyWordNumber', () => { vi.clearAllMocks() }) + const getSlider = () => screen.getByLabelText('datasetSettings.form.numberOfKeywords') + describe('Rendering', () => { it('should render without crashing', () => { render() @@ -31,8 +33,7 @@ describe('KeyWordNumber', () => { it('should render slider', () => { render() - // Slider has a slider role - expect(screen.getByRole('slider')).toBeInTheDocument() + expect(getSlider()).toBeInTheDocument() }) it('should render input number field', () => { @@ -61,7 +62,7 @@ describe('KeyWordNumber', () => { it('should pass correct value to slider', () => { render() - const slider = screen.getByRole('slider') + const slider = getSlider() expect(slider).toHaveAttribute('aria-valuenow', '30') }) }) @@ -71,8 +72,7 @@ describe('KeyWordNumber', () => { const handleChange = vi.fn() render() - const slider = screen.getByRole('slider') - // Verify slider is rendered and interactive + const slider = getSlider() expect(slider).toBeInTheDocument() expect(slider).not.toBeDisabled() }) @@ -109,14 +109,14 @@ describe('KeyWordNumber', () => { describe('Slider Configuration', () => { it('should have max value of 50', () => { render() - const slider = screen.getByRole('slider') - expect(slider).toHaveAttribute('aria-valuemax', '50') + const slider = getSlider() + expect(slider).toHaveAttribute('max', '50') }) it('should have min value of 0', () => { render() - const slider = screen.getByRole('slider') - expect(slider).toHaveAttribute('aria-valuemin', '0') + const slider = getSlider() + expect(slider).toHaveAttribute('min', '0') }) }) @@ -162,7 +162,7 @@ describe('KeyWordNumber', () => { describe('Accessibility', () => { it('should have accessible slider', () => { render() - const slider = screen.getByRole('slider') + const slider = getSlider() expect(slider).toBeInTheDocument() }) diff --git a/web/app/components/datasets/settings/index-method/keyword-number.tsx b/web/app/components/datasets/settings/index-method/keyword-number.tsx index 95810d7d49..feb63c1d65 100644 --- a/web/app/components/datasets/settings/index-method/keyword-number.tsx +++ b/web/app/components/datasets/settings/index-method/keyword-number.tsx @@ -1,7 +1,6 @@ import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import Slider from '@/app/components/base/slider' import Tooltip from '@/app/components/base/tooltip' import { NumberField, @@ -11,6 +10,7 @@ import { NumberFieldIncrement, NumberFieldInput, } from '@/app/components/base/ui/number-field' +import { Slider } from '@/app/components/base/ui/slider' const MIN_KEYWORD_NUMBER = 0 const MAX_KEYWORD_NUMBER = 50 @@ -47,7 +47,8 @@ const KeyWordNumber = ({ value={keywordNumber} min={MIN_KEYWORD_NUMBER} max={MAX_KEYWORD_NUMBER} - onChange={onKeywordNumberChange} + onValueChange={onKeywordNumberChange} + aria-label={t('form.numberOfKeywords', { ns: 'datasetSettings' })} /> ({ useLanguage: () => 'en_US', })) -vi.mock('@/app/components/base/slider', () => ({ - default: ({ onChange }: { onChange: (v: number) => void }) => ( - +vi.mock('@/app/components/base/ui/slider', () => ({ + Slider: ({ onValueChange }: { onValueChange: (v: number) => void }) => ( + ), })) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx index 01e3f45371..162e39d162 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx @@ -7,10 +7,10 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import PromptEditor from '@/app/components/base/prompt-editor' import Radio from '@/app/components/base/radio' -import Slider from '@/app/components/base/slider' import Switch from '@/app/components/base/switch' import TagInput from '@/app/components/base/tag-input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select' +import { Slider } from '@/app/components/base/ui/slider' import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip' import { BlockEnum } from '@/app/components/workflow/types' import { cn } from '@/utils/classnames' @@ -78,6 +78,7 @@ function ParameterItem({ } const renderValue = value ?? localValue ?? getDefaultValue() + const sliderLabel = parameterRule.label[language] || parameterRule.label.en_US const handleInputChange = (newValue: ParameterValue) => { setLocalValue(newValue) @@ -170,7 +171,8 @@ function ParameterItem({ min={parameterRule.min} max={parameterRule.max} step={step} - onChange={handleSlideChange} + onValueChange={handleSlideChange} + aria-label={sliderLabel} /> )} )} - {parameterRule.label[language] || parameterRule.label.en_US} + {sliderLabel}
{ parameterRule.help && ( diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy.spec.tsx index c3738ca260..fb63a30030 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy.spec.tsx @@ -145,7 +145,7 @@ describe('AgentStrategy', () => { />, ) - expect(screen.getByRole('slider')).toBeInTheDocument() + expect(screen.getByLabelText('Count')).toBeInTheDocument() expect(screen.getByRole('textbox')).toBeInTheDocument() }) diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx index 70c480892b..b70bcd0173 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx @@ -9,7 +9,6 @@ import { memo } from 'react' import { useTranslation } from 'react-i18next' import { Agent } from '@/app/components/base/icons/src/vender/workflow' import ListEmpty from '@/app/components/base/list-empty' -import Slider from '@/app/components/base/slider' import { NumberField, NumberFieldControls, @@ -18,6 +17,7 @@ import { NumberFieldIncrement, NumberFieldInput, } from '@/app/components/base/ui/number-field' +import { Slider } from '@/app/components/base/ui/slider' import { FormTypeEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form' @@ -147,10 +147,11 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
= ({ onChange, }) => { const handleBlur = useCallback(() => { - if (value === undefined || value === null) { + if (value === undefined || value === null || Number.isNaN(value)) { onChange(defaultValue) return } @@ -57,8 +57,9 @@ const InputNumberWithSlider: FC = ({ min={min} max={max} step={1} - onChange={onChange} + onValueChange={onChange} disabled={readonly} + aria-label="Number input slider" />
) diff --git a/web/app/components/workflow/nodes/_base/components/memory-config.tsx b/web/app/components/workflow/nodes/_base/components/memory-config.tsx index ac82162915..9ee522e54f 100644 --- a/web/app/components/workflow/nodes/_base/components/memory-config.tsx +++ b/web/app/components/workflow/nodes/_base/components/memory-config.tsx @@ -6,8 +6,8 @@ import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import Slider from '@/app/components/base/slider' import Switch from '@/app/components/base/switch' +import { Slider } from '@/app/components/base/ui/slider' import Field from '@/app/components/workflow/nodes/_base/components/field' import { cn } from '@/utils/classnames' import { MemoryRole } from '../../../types' @@ -154,7 +154,7 @@ const MemoryConfig: FC = ({ size="md" disabled={readonly} /> -
{t(`${i18nPrefix}.windowSize`, { ns: 'workflow' })}
+
{t(`${i18nPrefix}.windowSize`, { ns: 'workflow' })}
= ({ min={WINDOW_SIZE_MIN} max={WINDOW_SIZE_MAX} step={1} - onChange={handleWindowSizeChange} + onValueChange={handleWindowSizeChange} disabled={readonly || !payload.window?.enabled} + aria-label={t(`${i18nPrefix}.windowSize`, { ns: 'workflow' })} /> > = ({ title={t(`${i18nPrefix}.input`, { ns: 'workflow' })} required operations={( -
Array
+
Array
)} > > = ({ title={t(`${i18nPrefix}.output`, { ns: 'workflow' })} required operations={( -
Array
+
Array
)} > > = ({ { changeParallelNums(Number(e.target.value)) }} />
diff --git a/web/app/components/workflow/nodes/knowledge-base/components/__tests__/index-method.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/components/__tests__/index-method.spec.tsx index a11f93e0b0..583c1c8966 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/__tests__/index-method.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/__tests__/index-method.spec.tsx @@ -39,7 +39,7 @@ describe('IndexMethod', () => { fireEvent.change(container.querySelector('input') as HTMLInputElement, { target: { value: '7' } }) - expect(onKeywordNumberChange).toHaveBeenCalledWith(7) + expect(onKeywordNumberChange).toHaveBeenCalledWith(7, expect.anything()) }) it('should disable keyword controls when readonly is enabled', () => { diff --git a/web/app/components/workflow/nodes/knowledge-base/components/index-method.tsx b/web/app/components/workflow/nodes/knowledge-base/components/index-method.tsx index b692e35ed2..a223c5ab24 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/index-method.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/index-method.tsx @@ -9,8 +9,8 @@ import { HighQuality, } from '@/app/components/base/icons/src/vender/knowledge' import Input from '@/app/components/base/input' -import Slider from '@/app/components/base/slider' import Tooltip from '@/app/components/base/tooltip' +import { Slider } from '@/app/components/base/ui/slider' import { Field } from '@/app/components/workflow/nodes/_base/components/layout' import { cn } from '@/utils/classnames' import { @@ -94,7 +94,7 @@ const IndexMethod = ({ >
-
+
{t('form.numberOfKeywords', { ns: 'datasetSettings' })}
{ const onChange = vi.fn() render() - const slider = screen.getByRole('slider') + const slider = screen.getByLabelText('workflow.nodes.triggerSchedule.onMinute') slider.focus() await user.keyboard('{ArrowRight}') - expect(onChange).toHaveBeenCalledWith(16, 0) + expect(onChange).toHaveBeenCalledWith(16, expect.objectContaining({ activeThumbIndex: 0 })) }) it('should keep at least one weekday selected', async () => { diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/on-minute-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/on-minute-selector.tsx index 188a630151..e0a46d1c68 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/components/on-minute-selector.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/components/on-minute-selector.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import Slider from '@/app/components/base/slider' +import { Slider } from '@/app/components/base/ui/slider' type OnMinuteSelectorProps = { value?: number @@ -27,7 +27,8 @@ const OnMinuteSelector = ({ value = 0, onChange }: OnMinuteSelectorProps) => { min={0} max={59} step={1} - onChange={onChange} + onValueChange={onChange} + aria-label={t('nodes.triggerSchedule.onMinute', { ns: 'workflow' })} />
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 38835cf3a5..d1c4312e35 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -965,11 +965,6 @@ "count": 1 } }, - "app/components/app/configuration/dataset-config/params-config/weighted-score.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 2 - } - }, "app/components/app/configuration/dataset-config/select-dataset/index.spec.tsx": { "ts/no-explicit-any": { "count": 2 @@ -2043,11 +2038,6 @@ "count": 3 } }, - "app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.tsx": { - "unicorn/prefer-number-properties": { - "count": 1 - } - }, "app/components/base/features/new-feature-panel/annotation-reply/type.ts": { "erasable-syntax-only/enums": { "count": 1 @@ -2878,19 +2868,6 @@ "count": 3 } }, - "app/components/base/slider/index.stories.tsx": { - "no-console": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, - "app/components/base/slider/index.tsx": { - "tailwindcss/no-unnecessary-whitespace": { - "count": 1 - } - }, "app/components/base/sort/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 3 @@ -7041,9 +7018,6 @@ } }, "app/components/workflow/nodes/_base/components/memory-config.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 1 - }, "unicorn/prefer-number-properties": { "count": 1 } @@ -7872,12 +7846,6 @@ "app/components/workflow/nodes/iteration/panel.tsx": { "no-restricted-imports": { "count": 1 - }, - "tailwindcss/enforce-consistent-class-order": { - "count": 2 - }, - "tailwindcss/no-unnecessary-whitespace": { - "count": 1 } }, "app/components/workflow/nodes/iteration/use-config.ts": { @@ -7906,9 +7874,6 @@ "app/components/workflow/nodes/knowledge-base/components/index-method.tsx": { "no-restricted-imports": { "count": 1 - }, - "tailwindcss/enforce-consistent-class-order": { - "count": 1 } }, "app/components/workflow/nodes/knowledge-base/components/option-card.tsx": { diff --git a/web/package.json b/web/package.json index 7ce1d2a530..1b573d84eb 100644 --- a/web/package.json +++ b/web/package.json @@ -140,7 +140,6 @@ "react-multi-email": "1.0.25", "react-papaparse": "4.4.0", "react-pdf-highlighter": "8.0.0-rc.0", - "react-slider": "2.0.6", "react-sortablejs": "6.1.4", "react-syntax-highlighter": "15.6.6", "react-textarea-autosize": "8.5.9", @@ -202,7 +201,6 @@ "@types/qs": "6.15.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", - "@types/react-slider": "1.3.6", "@types/react-syntax-highlighter": "15.5.13", "@types/react-window": "1.8.8", "@types/sortablejs": "1.15.9", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 933870a79b..9945c6f893 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -307,9 +307,6 @@ importers: react-pdf-highlighter: specifier: 8.0.0-rc.0 version: 8.0.0-rc.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react-slider: - specifier: 2.0.6 - version: 2.0.6(react@19.2.4) react-sortablejs: specifier: 6.1.4 version: 6.1.4(@types/sortablejs@1.15.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sortablejs@1.15.7) @@ -488,9 +485,6 @@ importers: '@types/react-dom': specifier: 19.2.3 version: 19.2.3(@types/react@19.2.14) - '@types/react-slider': - specifier: 1.3.6 - version: 1.3.6 '@types/react-syntax-highlighter': specifier: 15.5.13 version: 15.5.13 @@ -3537,9 +3531,6 @@ packages: peerDependencies: '@types/react': ^19.2.0 - '@types/react-slider@1.3.6': - resolution: {integrity: sha512-RS8XN5O159YQ6tu3tGZIQz1/9StMLTg/FCIPxwqh2gwVixJnlfIodtVx+fpXVMZHe7A58lAX1Q4XTgAGOQaCQg==} - '@types/react-syntax-highlighter@15.5.13': resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==} @@ -6885,11 +6876,6 @@ packages: react-dom: ^19.2.4 webpack: ^5.59.0 - react-slider@2.0.6: - resolution: {integrity: sha512-gJxG1HwmuMTJ+oWIRCmVWvgwotNCbByTwRkFZC6U4MBsHqJBmxwbYRJUmxy4Tke1ef8r9jfXjgkmY/uHOCEvbA==} - peerDependencies: - react: ^16 || ^17 || ^18 - react-sortablejs@6.1.4: resolution: {integrity: sha512-fc7cBosfhnbh53Mbm6a45W+F735jwZ1UFIYSrIqcO/gRIFoDyZeMtgKlpV4DdyQfbCzdh5LoALLTDRxhMpTyXQ==} peerDependencies: @@ -10939,10 +10925,6 @@ snapshots: dependencies: '@types/react': 19.2.14 - '@types/react-slider@1.3.6': - dependencies: - '@types/react': 19.2.14 - '@types/react-syntax-highlighter@15.5.13': dependencies: '@types/react': 19.2.14 @@ -14937,11 +14919,6 @@ snapshots: webpack: 5.105.4(esbuild@0.27.2)(uglify-js@3.19.3) webpack-sources: 3.3.4 - react-slider@2.0.6(react@19.2.4): - dependencies: - prop-types: 15.8.1 - react: 19.2.4 - react-sortablejs@6.1.4(@types/sortablejs@1.15.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sortablejs@1.15.7): dependencies: '@types/sortablejs': 1.15.9 From f87dafa229edfa18ddebc5a76be966a7ee9a72e3 Mon Sep 17 00:00:00 2001 From: Joel Date: Wed, 25 Mar 2026 16:16:52 +0800 Subject: [PATCH 3/5] fix: partner stack not recorded when not login (#34062) --- .../__tests__/cookie-recorder.spec.tsx | 45 +++++++++++++++++++ .../billing/partner-stack/cookie-recorder.tsx | 19 ++++++++ .../billing/partner-stack/use-ps-info.ts | 6 +-- web/app/layout.tsx | 2 + web/app/signin/page.tsx | 7 --- 5 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 web/app/components/billing/partner-stack/__tests__/cookie-recorder.spec.tsx create mode 100644 web/app/components/billing/partner-stack/cookie-recorder.tsx diff --git a/web/app/components/billing/partner-stack/__tests__/cookie-recorder.spec.tsx b/web/app/components/billing/partner-stack/__tests__/cookie-recorder.spec.tsx new file mode 100644 index 0000000000..1441653c9c --- /dev/null +++ b/web/app/components/billing/partner-stack/__tests__/cookie-recorder.spec.tsx @@ -0,0 +1,45 @@ +import { render } from '@testing-library/react' +import PartnerStackCookieRecorder from '../cookie-recorder' + +let isCloudEdition = true + +const saveOrUpdate = vi.fn() + +vi.mock('@/config', () => ({ + get IS_CLOUD_EDITION() { + return isCloudEdition + }, +})) + +vi.mock('../use-ps-info', () => ({ + default: () => ({ + saveOrUpdate, + }), +})) + +describe('PartnerStackCookieRecorder', () => { + beforeEach(() => { + vi.clearAllMocks() + isCloudEdition = true + }) + + it('should call saveOrUpdate once on mount when running in cloud edition', () => { + render() + + expect(saveOrUpdate).toHaveBeenCalledTimes(1) + }) + + it('should not call saveOrUpdate when not running in cloud edition', () => { + isCloudEdition = false + + render() + + expect(saveOrUpdate).not.toHaveBeenCalled() + }) + + it('should render null', () => { + const { container } = render() + + expect(container.innerHTML).toBe('') + }) +}) diff --git a/web/app/components/billing/partner-stack/cookie-recorder.tsx b/web/app/components/billing/partner-stack/cookie-recorder.tsx new file mode 100644 index 0000000000..3c75b2973c --- /dev/null +++ b/web/app/components/billing/partner-stack/cookie-recorder.tsx @@ -0,0 +1,19 @@ +'use client' + +import { useEffect } from 'react' +import { IS_CLOUD_EDITION } from '@/config' +import usePSInfo from './use-ps-info' + +const PartnerStackCookieRecorder = () => { + const { saveOrUpdate } = usePSInfo() + + useEffect(() => { + if (!IS_CLOUD_EDITION) + return + saveOrUpdate() + }, []) + + return null +} + +export default PartnerStackCookieRecorder diff --git a/web/app/components/billing/partner-stack/use-ps-info.ts b/web/app/components/billing/partner-stack/use-ps-info.ts index 7c45d7ef87..5a83dec0e5 100644 --- a/web/app/components/billing/partner-stack/use-ps-info.ts +++ b/web/app/components/billing/partner-stack/use-ps-info.ts @@ -24,7 +24,7 @@ const usePSInfo = () => { }] = useBoolean(false) const { mutateAsync } = useBindPartnerStackInfo() // Save to top domain. cloud.dify.ai => .dify.ai - const domain = globalThis.location.hostname.replace('cloud', '') + const domain = globalThis.location?.hostname.replace('cloud', '') const saveOrUpdate = useCallback(() => { if (!psPartnerKey || !psClickId) @@ -39,7 +39,7 @@ const usePSInfo = () => { path: '/', domain, }) - }, [psPartnerKey, psClickId, isPSChanged]) + }, [psPartnerKey, psClickId, isPSChanged, domain]) const bind = useCallback(async () => { if (psPartnerKey && psClickId && !hasBind) { @@ -59,7 +59,7 @@ const usePSInfo = () => { Cookies.remove(PARTNER_STACK_CONFIG.cookieName, { path: '/', domain }) setBind() } - }, [psPartnerKey, psClickId, mutateAsync, hasBind, setBind]) + }, [psPartnerKey, psClickId, hasBind, domain, setBind, mutateAsync]) return { psPartnerKey, psClickId, diff --git a/web/app/layout.tsx b/web/app/layout.tsx index f08ce9bb49..98cce27491 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -9,6 +9,7 @@ import { getLocaleOnServer } from '@/i18n-config/server' import { ToastProvider } from './components/base/toast' import { ToastHost } from './components/base/ui/toast' import { TooltipProvider } from './components/base/ui/tooltip' +import PartnerStackCookieRecorder from './components/billing/partner-stack/cookie-recorder' import { AgentationLoader } from './components/devtools/agentation-loader' import { ReactScanLoader } from './components/devtools/react-scan/loader' import { I18nServerProvider } from './components/provider/i18n-server' @@ -67,6 +68,7 @@ const LocaleLayout = async ({ + diff --git a/web/app/signin/page.tsx b/web/app/signin/page.tsx index 7fad92fe5d..3f893b12fa 100644 --- a/web/app/signin/page.tsx +++ b/web/app/signin/page.tsx @@ -1,18 +1,11 @@ 'use client' -import { useEffect } from 'react' import { useSearchParams } from '@/next/navigation' -import usePSInfo from '../components/billing/partner-stack/use-ps-info' import NormalForm from './normal-form' import OneMoreStep from './one-more-step' const SignIn = () => { const searchParams = useSearchParams() const step = searchParams.get('step') - const { saveOrUpdate } = usePSInfo() - - useEffect(() => { - saveOrUpdate() - }, []) if (step === 'next') return From 7fbb1c96db4d95e457d0259ed85246c926e0a6bc Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Wed, 25 Mar 2026 17:21:48 +0800 Subject: [PATCH 4/5] feat(workflow): add selection context menu helpers and integrate with context menu component (#34013) Co-authored-by: CodingOnStar Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: lif <1835304752@qq.com> Co-authored-by: hjlarry Co-authored-by: Stephen Zhou Co-authored-by: tmimmanuel <14046872+tmimmanuel@users.noreply.github.com> Co-authored-by: Desel72 Co-authored-by: Renzo <170978465+RenzoMXD@users.noreply.github.com> Co-authored-by: Krishna Chaitanya Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- web/__tests__/check-i18n.test.ts | 4 +- .../__tests__/candidate-node-main.spec.tsx | 260 +++++++ .../workflow/__tests__/custom-edge.spec.tsx | 235 ++++++ .../__tests__/node-contextmenu.spec.tsx | 114 +++ .../__tests__/panel-contextmenu.spec.tsx | 151 ++++ .../__tests__/selection-contextmenu.spec.tsx | 275 +++++++ .../update-dsl-modal.helpers.spec.ts | 79 ++ .../__tests__/update-dsl-modal.spec.tsx | 365 ++++++++++ .../help-line/__tests__/index.spec.tsx | 61 ++ .../hooks/__tests__/use-config-vision.spec.ts | 171 +++++ .../use-dynamic-test-run-options.spec.tsx | 146 ++++ .../_base/__tests__/node-sections.spec.tsx | 135 ++++ .../_base/__tests__/node.helpers.spec.ts | 34 + .../nodes/_base/__tests__/node.spec.tsx | 218 ++++++ .../use-node-resize-observer.spec.tsx | 55 ++ .../form-input-item.branches.spec.tsx | 410 +++++++++++ .../__tests__/form-input-item.helpers.spec.ts | 166 +++++ .../form-input-item.sections.spec.tsx | 60 ++ .../__tests__/form-input-item.spec.tsx | 148 ++++ .../before-run-form/__tests__/helpers.spec.ts | 115 +++ .../before-run-form/__tests__/index.spec.tsx | 226 ++++++ .../components/before-run-form/helpers.ts | 105 +++ .../components/before-run-form/index.tsx | 95 +-- .../components/form-input-item.helpers.ts | 259 +++++++ .../components/form-input-item.sections.tsx | 129 ++++ .../_base/components/form-input-item.tsx | 383 +++------- .../var-reference-picker.branches.spec.tsx | 226 ++++++ .../var-reference-picker.helpers.spec.ts | 236 ++++++ .../__tests__/var-reference-picker.spec.tsx | 140 ++++ .../var-reference-picker.trigger.spec.tsx | 176 +++++ .../var-reference-vars.helpers.spec.ts | 84 +++ .../__tests__/var-reference-vars.spec.tsx | 226 ++++++ .../variable/var-reference-picker.helpers.ts | 221 ++++++ .../variable/var-reference-picker.trigger.tsx | 315 ++++++++ .../variable/var-reference-picker.tsx | 477 ++++-------- .../variable/var-reference-vars.helpers.ts | 100 +++ .../variable/var-reference-vars.tsx | 90 +-- .../workflow-panel/__tests__/helpers.spec.tsx | 90 +++ .../workflow-panel/__tests__/index.spec.tsx | 687 +++++++++++++++--- .../components/workflow-panel/helpers.tsx | 80 ++ .../_base/components/workflow-panel/index.tsx | 75 +- .../last-run/__tests__/index.spec.tsx | 235 ++++++ .../workflow/nodes/_base/node-sections.tsx | 94 +++ .../workflow/nodes/_base/node.helpers.tsx | 32 + .../components/workflow/nodes/_base/node.tsx | 168 ++--- .../nodes/_base/use-node-resize-observer.ts | 30 + .../hooks/__tests__/use-config.spec.ts | 139 ++++ .../__tests__/button-style-dropdown.spec.tsx | 149 ++++ .../__tests__/form-content-preview.spec.tsx | 135 ++++ .../__tests__/form-content.spec.tsx | 258 +++++++ .../components/__tests__/timeout.spec.tsx | 77 ++ .../components/__tests__/user-action.spec.tsx | 146 ++++ .../delivery-method/__tests__/index.spec.tsx | 150 ++++ .../recipient/__tests__/index.spec.tsx | 156 ++++ .../hooks/__tests__/use-config.spec.ts | 156 ++++ .../hooks/__tests__/use-form-content.spec.ts | 112 +++ .../use-single-run-form-params.spec.ts | 234 ++++++ .../iteration/__tests__/use-config.spec.ts | 173 +++++ .../use-single-run-form-params.spec.ts | 168 +++++ .../nodes/start/__tests__/use-config.spec.ts | 245 +++++++ .../__tests__/generic-table.spec.tsx | 2 +- .../variable-assigner/__tests__/hooks.spec.ts | 244 +++++++ .../__tests__/integration.spec.tsx | 8 +- .../use-variable-modal-state.spec.ts | 195 +++++ .../__tests__/variable-modal.helpers.spec.ts | 123 ++++ .../__tests__/variable-modal.spec.tsx | 198 +++++ .../components/use-variable-modal-state.ts | 228 ++++++ .../components/variable-modal.helpers.ts | 170 +++++ .../components/variable-modal.sections.tsx | 217 ++++++ .../components/variable-modal.tsx | 456 +++--------- .../workflow/run/__tests__/hooks.spec.ts | 127 ++++ .../run/__tests__/result-panel.spec.tsx | 356 +++++++++ .../run/__tests__/tracing-panel.spec.tsx | 199 +++++ .../workflow/run/get-hovered-parallel-id.ts | 10 + .../components/workflow/run/tracing-panel.tsx | 26 +- .../utils/format-log/__tests__/index.spec.ts | 199 +++++ .../workflow/selection-contextmenu.tsx | 634 ++++++++-------- .../workflow/update-dsl-modal.helpers.ts | 110 +++ .../components/workflow/update-dsl-modal.tsx | 145 ++-- .../__tests__/value-content-sections.spec.tsx | 143 ++++ .../value-content.helpers.branches.spec.ts | 48 ++ .../__tests__/value-content.helpers.spec.ts | 80 ++ .../__tests__/value-content.spec.tsx | 410 +++++++++++ .../value-content-sections.tsx | 190 +++++ .../variable-inspect/value-content.helpers.ts | 77 ++ .../variable-inspect/value-content.tsx | 239 ++---- web/eslint-suppressions.json | 48 +- 87 files changed, 13256 insertions(+), 2105 deletions(-) create mode 100644 web/app/components/workflow/__tests__/candidate-node-main.spec.tsx create mode 100644 web/app/components/workflow/__tests__/custom-edge.spec.tsx create mode 100644 web/app/components/workflow/__tests__/node-contextmenu.spec.tsx create mode 100644 web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx create mode 100644 web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx create mode 100644 web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts create mode 100644 web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx create mode 100644 web/app/components/workflow/help-line/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.sections.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/_base/components/before-run-form/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/before-run-form/helpers.ts create mode 100644 web/app/components/workflow/nodes/_base/components/form-input-item.helpers.ts create mode 100644 web/app/components/workflow/nodes/_base/components/form-input-item.sections.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.branches.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.helpers.spec.ts create mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.helpers.ts create mode 100644 web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.helpers.ts create mode 100644 web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/helpers.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/workflow-panel/helpers.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/node-sections.tsx create mode 100644 web/app/components/workflow/nodes/_base/node.helpers.tsx create mode 100644 web/app/components/workflow/nodes/_base/use-node-resize-observer.ts create mode 100644 web/app/components/workflow/nodes/data-source/hooks/__tests__/use-config.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/button-style-dropdown.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/timeout.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/user-action.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/__tests__/index.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/hooks/__tests__/use-config.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.spec.ts create mode 100644 web/app/components/workflow/nodes/iteration/__tests__/use-config.spec.ts create mode 100644 web/app/components/workflow/nodes/iteration/__tests__/use-single-run-form-params.spec.ts create mode 100644 web/app/components/workflow/nodes/start/__tests__/use-config.spec.ts create mode 100644 web/app/components/workflow/nodes/variable-assigner/__tests__/hooks.spec.ts create mode 100644 web/app/components/workflow/panel/chat-variable-panel/components/__tests__/use-variable-modal-state.spec.ts create mode 100644 web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.helpers.spec.ts create mode 100644 web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.spec.tsx create mode 100644 web/app/components/workflow/panel/chat-variable-panel/components/use-variable-modal-state.ts create mode 100644 web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.helpers.ts create mode 100644 web/app/components/workflow/panel/chat-variable-panel/components/variable-modal.sections.tsx create mode 100644 web/app/components/workflow/run/__tests__/hooks.spec.ts create mode 100644 web/app/components/workflow/run/__tests__/result-panel.spec.tsx create mode 100644 web/app/components/workflow/run/__tests__/tracing-panel.spec.tsx create mode 100644 web/app/components/workflow/run/get-hovered-parallel-id.ts create mode 100644 web/app/components/workflow/run/utils/format-log/__tests__/index.spec.ts create mode 100644 web/app/components/workflow/update-dsl-modal.helpers.ts create mode 100644 web/app/components/workflow/variable-inspect/__tests__/value-content-sections.spec.tsx create mode 100644 web/app/components/workflow/variable-inspect/__tests__/value-content.helpers.branches.spec.ts create mode 100644 web/app/components/workflow/variable-inspect/__tests__/value-content.helpers.spec.ts create mode 100644 web/app/components/workflow/variable-inspect/__tests__/value-content.spec.tsx create mode 100644 web/app/components/workflow/variable-inspect/value-content-sections.tsx create mode 100644 web/app/components/workflow/variable-inspect/value-content.helpers.ts diff --git a/web/__tests__/check-i18n.test.ts b/web/__tests__/check-i18n.test.ts index de78ae997e..9e9b3d7168 100644 --- a/web/__tests__/check-i18n.test.ts +++ b/web/__tests__/check-i18n.test.ts @@ -774,7 +774,7 @@ export default translation` const endTime = Date.now() expect(keys.length).toBe(1000) - expect(endTime - startTime).toBeLessThan(1000) // Should complete in under 1 second + expect(endTime - startTime).toBeLessThan(10000) }) it('should handle multiple translation files concurrently', async () => { @@ -796,7 +796,7 @@ export default translation` const endTime = Date.now() expect(keys.length).toBe(20) // 10 files * 2 keys each - expect(endTime - startTime).toBeLessThan(500) + expect(endTime - startTime).toBeLessThan(10000) }) }) diff --git a/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx b/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx new file mode 100644 index 0000000000..61e5410aac --- /dev/null +++ b/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx @@ -0,0 +1,260 @@ +import { render, screen } from '@testing-library/react' +import CandidateNodeMain from '../candidate-node-main' +import { CUSTOM_NODE } from '../constants' +import { CUSTOM_NOTE_NODE } from '../note-node/constants' +import { BlockEnum } from '../types' +import { createNode } from './fixtures' + +const mockUseEventListener = vi.hoisted(() => vi.fn()) +const mockUseStoreApi = vi.hoisted(() => vi.fn()) +const mockUseReactFlow = vi.hoisted(() => vi.fn()) +const mockUseViewport = vi.hoisted(() => vi.fn()) +const mockUseStore = vi.hoisted(() => vi.fn()) +const mockUseWorkflowStore = vi.hoisted(() => vi.fn()) +const mockUseHooks = vi.hoisted(() => vi.fn()) +const mockCustomNode = vi.hoisted(() => vi.fn()) +const mockCustomNoteNode = vi.hoisted(() => vi.fn()) +const mockGetIterationStartNode = vi.hoisted(() => vi.fn()) +const mockGetLoopStartNode = vi.hoisted(() => vi.fn()) + +vi.mock('ahooks', () => ({ + useEventListener: (...args: unknown[]) => mockUseEventListener(...args), +})) + +vi.mock('reactflow', () => ({ + useStoreApi: () => mockUseStoreApi(), + useReactFlow: () => mockUseReactFlow(), + useViewport: () => mockUseViewport(), + Position: { + Left: 'left', + Right: 'right', + }, +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { mousePosition: { + pageX: number + pageY: number + elementX: number + elementY: number + } }) => unknown) => mockUseStore(selector), + useWorkflowStore: () => mockUseWorkflowStore(), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesInteractions: () => mockUseHooks().useNodesInteractions(), + useNodesSyncDraft: () => mockUseHooks().useNodesSyncDraft(), + useWorkflowHistory: () => mockUseHooks().useWorkflowHistory(), + useAutoGenerateWebhookUrl: () => mockUseHooks().useAutoGenerateWebhookUrl(), + WorkflowHistoryEvent: { + NodeAdd: 'NodeAdd', + NoteAdd: 'NoteAdd', + }, +})) + +vi.mock('@/app/components/workflow/nodes', () => ({ + __esModule: true, + default: (props: { id: string }) => { + mockCustomNode(props) + return
{props.id}
+ }, +})) + +vi.mock('@/app/components/workflow/note-node', () => ({ + __esModule: true, + default: (props: { id: string }) => { + mockCustomNoteNode(props) + return
{props.id}
+ }, +})) + +vi.mock('@/app/components/workflow/utils', () => ({ + getIterationStartNode: (...args: unknown[]) => mockGetIterationStartNode(...args), + getLoopStartNode: (...args: unknown[]) => mockGetLoopStartNode(...args), +})) + +describe('CandidateNodeMain', () => { + const mockSetNodes = vi.fn() + const mockHandleNodeSelect = vi.fn() + const mockSaveStateToHistory = vi.fn() + const mockHandleSyncWorkflowDraft = vi.fn() + const mockAutoGenerateWebhookUrl = vi.fn() + const mockWorkflowStoreSetState = vi.fn() + const createNodesInteractions = () => ({ + handleNodeSelect: mockHandleNodeSelect, + }) + const createWorkflowHistory = () => ({ + saveStateToHistory: mockSaveStateToHistory, + }) + const createNodesSyncDraft = () => ({ + handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, + }) + const createAutoGenerateWebhookUrl = () => mockAutoGenerateWebhookUrl + const eventHandlers: Partial void }) => void>> = {} + let nodes = [createNode({ id: 'existing-node' })] + + beforeEach(() => { + vi.clearAllMocks() + nodes = [createNode({ id: 'existing-node' })] + eventHandlers.click = undefined + eventHandlers.contextmenu = undefined + + mockUseEventListener.mockImplementation((event: 'click' | 'contextmenu', handler: (event: { preventDefault: () => void }) => void) => { + eventHandlers[event] = handler + }) + mockUseStoreApi.mockReturnValue({ + getState: () => ({ + getNodes: () => nodes, + setNodes: mockSetNodes, + }), + }) + mockUseReactFlow.mockReturnValue({ + screenToFlowPosition: ({ x, y }: { x: number, y: number }) => ({ x: x + 10, y: y + 20 }), + }) + mockUseViewport.mockReturnValue({ zoom: 1.5 }) + mockUseStore.mockImplementation((selector: (state: { mousePosition: { + pageX: number + pageY: number + elementX: number + elementY: number + } }) => unknown) => selector({ + mousePosition: { + pageX: 100, + pageY: 200, + elementX: 30, + elementY: 40, + }, + })) + mockUseWorkflowStore.mockReturnValue({ + setState: mockWorkflowStoreSetState, + }) + mockUseHooks.mockReturnValue({ + useNodesInteractions: createNodesInteractions, + useWorkflowHistory: createWorkflowHistory, + useNodesSyncDraft: createNodesSyncDraft, + useAutoGenerateWebhookUrl: createAutoGenerateWebhookUrl, + }) + mockHandleSyncWorkflowDraft.mockImplementation((_isSync: boolean, _force: boolean, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }) + mockGetIterationStartNode.mockReturnValue(createNode({ id: 'iteration-start' })) + mockGetLoopStartNode.mockReturnValue(createNode({ id: 'loop-start' })) + }) + + it('should render the candidate node and commit a webhook node on click', () => { + const candidateNode = createNode({ + id: 'candidate-webhook', + type: CUSTOM_NODE, + data: { + type: BlockEnum.TriggerWebhook, + title: 'Webhook Candidate', + _isCandidate: true, + }, + }) + + const { container } = render() + + expect(screen.getByTestId('candidate-custom-node')).toHaveTextContent('candidate-webhook') + expect(container.firstChild).toHaveStyle({ + left: '30px', + top: '40px', + transform: 'scale(1.5)', + }) + + eventHandlers.click?.({ preventDefault: vi.fn() }) + + expect(mockSetNodes).toHaveBeenCalledWith(expect.arrayContaining([ + expect.objectContaining({ id: 'existing-node' }), + expect.objectContaining({ + id: 'candidate-webhook', + position: { x: 110, y: 220 }, + data: expect.objectContaining({ _isCandidate: false }), + }), + ])) + expect(mockSaveStateToHistory).toHaveBeenCalledWith('NodeAdd', { nodeId: 'candidate-webhook' }) + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ candidateNode: undefined }) + expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true, expect.objectContaining({ + onSuccess: expect.any(Function), + })) + expect(mockAutoGenerateWebhookUrl).toHaveBeenCalledWith('candidate-webhook') + expect(mockHandleNodeSelect).not.toHaveBeenCalled() + }) + + it('should save note candidates as notes and select the inserted note', () => { + const candidateNode = createNode({ + id: 'candidate-note', + type: CUSTOM_NOTE_NODE, + data: { + type: BlockEnum.Code, + title: 'Note Candidate', + _isCandidate: true, + }, + }) + + render() + + expect(screen.getByTestId('candidate-note-node')).toHaveTextContent('candidate-note') + + eventHandlers.click?.({ preventDefault: vi.fn() }) + + expect(mockSaveStateToHistory).toHaveBeenCalledWith('NoteAdd', { nodeId: 'candidate-note' }) + expect(mockHandleNodeSelect).toHaveBeenCalledWith('candidate-note') + }) + + it('should append iteration and loop start helper nodes for control-flow candidates', () => { + const iterationNode = createNode({ + id: 'candidate-iteration', + type: CUSTOM_NODE, + data: { + type: BlockEnum.Iteration, + title: 'Iteration Candidate', + _isCandidate: true, + }, + }) + const loopNode = createNode({ + id: 'candidate-loop', + type: CUSTOM_NODE, + data: { + type: BlockEnum.Loop, + title: 'Loop Candidate', + _isCandidate: true, + }, + }) + + const { rerender } = render() + + eventHandlers.click?.({ preventDefault: vi.fn() }) + expect(mockGetIterationStartNode).toHaveBeenCalledWith('candidate-iteration') + expect(mockSetNodes.mock.calls[0][0]).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'candidate-iteration' }), + expect.objectContaining({ id: 'iteration-start' }), + ])) + + rerender() + eventHandlers.click?.({ preventDefault: vi.fn() }) + + expect(mockGetLoopStartNode).toHaveBeenCalledWith('candidate-loop') + expect(mockSetNodes.mock.calls[1][0]).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'candidate-loop' }), + expect.objectContaining({ id: 'loop-start' }), + ])) + }) + + it('should clear the candidate node on contextmenu', () => { + const candidateNode = createNode({ + id: 'candidate-context', + type: CUSTOM_NODE, + data: { + type: BlockEnum.Code, + title: 'Context Candidate', + _isCandidate: true, + }, + }) + + render() + + eventHandlers.contextmenu?.({ preventDefault: vi.fn() }) + + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ candidateNode: undefined }) + }) +}) diff --git a/web/app/components/workflow/__tests__/custom-edge.spec.tsx b/web/app/components/workflow/__tests__/custom-edge.spec.tsx new file mode 100644 index 0000000000..f8ff9a1a0e --- /dev/null +++ b/web/app/components/workflow/__tests__/custom-edge.spec.tsx @@ -0,0 +1,235 @@ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { Position } from 'reactflow' +import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' +import CustomEdge from '../custom-edge' +import { BlockEnum, NodeRunningStatus } from '../types' + +const mockUseAvailableBlocks = vi.hoisted(() => vi.fn()) +const mockUseNodesInteractions = vi.hoisted(() => vi.fn()) +const mockBlockSelector = vi.hoisted(() => vi.fn()) +const mockGradientRender = vi.hoisted(() => vi.fn()) + +vi.mock('reactflow', () => ({ + BaseEdge: (props: { + id: string + path: string + style: { + stroke: string + strokeWidth: number + opacity: number + strokeDasharray?: string + } + }) => ( +
+ ), + EdgeLabelRenderer: ({ children }: { children?: ReactNode }) =>
{children}
, + getBezierPath: () => ['M 0 0', 24, 48], + Position: { + Right: 'right', + Left: 'left', + }, +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useAvailableBlocks: (...args: unknown[]) => mockUseAvailableBlocks(...args), + useNodesInteractions: () => mockUseNodesInteractions(), +})) + +vi.mock('@/app/components/workflow/block-selector', () => ({ + __esModule: true, + default: (props: { + open: boolean + onOpenChange: (open: boolean) => void + onSelect: (nodeType: string, pluginDefaultValue?: Record) => void + availableBlocksTypes: string[] + triggerClassName?: () => string + }) => { + mockBlockSelector(props) + return ( + + ) + }, +})) + +vi.mock('@/app/components/workflow/custom-edge-linear-gradient-render', () => ({ + __esModule: true, + default: (props: { + id: string + startColor: string + stopColor: string + }) => { + mockGradientRender(props) + return
{props.id}
+ }, +})) + +describe('CustomEdge', () => { + const mockHandleNodeAdd = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockUseNodesInteractions.mockReturnValue({ + handleNodeAdd: mockHandleNodeAdd, + }) + mockUseAvailableBlocks.mockImplementation((nodeType: BlockEnum) => { + if (nodeType === BlockEnum.Code) + return { availablePrevBlocks: ['code', 'llm'] } + + return { availableNextBlocks: ['llm', 'tool'] } + }) + }) + + it('should render a gradient edge and insert a node between the source and target', () => { + render( + , + ) + + expect(screen.getByTestId('edge-gradient')).toHaveTextContent('edge-1') + expect(mockGradientRender).toHaveBeenCalledWith(expect.objectContaining({ + id: 'edge-1', + startColor: 'var(--color-workflow-link-line-success-handle)', + stopColor: 'var(--color-workflow-link-line-error-handle)', + })) + expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'url(#edge-1)') + expect(screen.getByTestId('base-edge')).toHaveAttribute('data-opacity', '0.3') + expect(screen.getByTestId('base-edge')).toHaveAttribute('data-dasharray', '8 8') + expect(screen.getByTestId('block-selector')).toHaveTextContent('llm') + expect(screen.getByTestId('block-selector').parentElement).toHaveStyle({ + transform: 'translate(-50%, -50%) translate(24px, 48px)', + opacity: '0.7', + }) + + fireEvent.click(screen.getByTestId('block-selector')) + + expect(mockHandleNodeAdd).toHaveBeenCalledWith( + { + nodeType: 'llm', + pluginDefaultValue: { provider: 'openai' }, + }, + { + prevNodeId: 'source-node', + prevNodeSourceHandle: 'source', + nextNodeId: 'target-node', + nextNodeTargetHandle: 'target', + }, + ) + }) + + it('should prefer the running stroke color when the edge is selected', () => { + render( + , + ) + + expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-handle)') + }) + + it('should use the fail-branch running color while the connected node is hovering', () => { + render( + , + ) + + expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-failure-handle)') + }) + + it('should fall back to the default edge color when no highlight state is active', () => { + render( + , + ) + + expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'var(--color-workflow-link-line-normal)') + expect(screen.getByTestId('block-selector')).toHaveAttribute('data-trigger-class', 'hover:scale-150 transition-all') + }) +}) diff --git a/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx new file mode 100644 index 0000000000..7418b7f313 --- /dev/null +++ b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx @@ -0,0 +1,114 @@ +import type { Node } from '../types' +import { fireEvent, render, screen } from '@testing-library/react' +import NodeContextmenu from '../node-contextmenu' + +const mockUseClickAway = vi.hoisted(() => vi.fn()) +const mockUseNodes = vi.hoisted(() => vi.fn()) +const mockUsePanelInteractions = vi.hoisted(() => vi.fn()) +const mockUseStore = vi.hoisted(() => vi.fn()) +const mockPanelOperatorPopup = vi.hoisted(() => vi.fn()) + +vi.mock('ahooks', () => ({ + useClickAway: (...args: unknown[]) => mockUseClickAway(...args), +})) + +vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ + __esModule: true, + default: () => mockUseNodes(), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + usePanelInteractions: () => mockUsePanelInteractions(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => mockUseStore(selector), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup', () => ({ + __esModule: true, + default: (props: { + id: string + data: Node['data'] + showHelpLink: boolean + onClosePopup: () => void + }) => { + mockPanelOperatorPopup(props) + return ( + + ) + }, +})) + +describe('NodeContextmenu', () => { + const mockHandleNodeContextmenuCancel = vi.fn() + let nodeMenu: { nodeId: string, left: number, top: number } | undefined + let nodes: Node[] + let clickAwayHandler: (() => void) | undefined + + beforeEach(() => { + vi.clearAllMocks() + nodeMenu = undefined + nodes = [{ + id: 'node-1', + type: 'custom', + position: { x: 0, y: 0 }, + data: { + title: 'Node 1', + desc: '', + type: 'code' as never, + }, + } as Node] + clickAwayHandler = undefined + + mockUseClickAway.mockImplementation((handler: () => void) => { + clickAwayHandler = handler + }) + mockUseNodes.mockImplementation(() => nodes) + mockUsePanelInteractions.mockReturnValue({ + handleNodeContextmenuCancel: mockHandleNodeContextmenuCancel, + }) + mockUseStore.mockImplementation((selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => selector({ nodeMenu })) + }) + + it('should stay hidden when the node menu is absent', () => { + render() + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(mockPanelOperatorPopup).not.toHaveBeenCalled() + }) + + it('should stay hidden when the referenced node cannot be found', () => { + nodeMenu = { nodeId: 'missing-node', left: 80, top: 120 } + + render() + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(mockPanelOperatorPopup).not.toHaveBeenCalled() + }) + + it('should render the popup at the stored position and close on popup/click-away actions', () => { + nodeMenu = { nodeId: 'node-1', left: 80, top: 120 } + const { container } = render() + + expect(screen.getByRole('button')).toHaveTextContent('node-1:Node 1') + expect(mockPanelOperatorPopup).toHaveBeenCalledWith(expect.objectContaining({ + id: 'node-1', + data: expect.objectContaining({ title: 'Node 1' }), + showHelpLink: true, + })) + expect(container.firstChild).toHaveStyle({ + left: '80px', + top: '120px', + }) + + fireEvent.click(screen.getByRole('button')) + clickAwayHandler?.() + + expect(mockHandleNodeContextmenuCancel).toHaveBeenCalledTimes(2) + }) +}) diff --git a/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx new file mode 100644 index 0000000000..914c1be617 --- /dev/null +++ b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx @@ -0,0 +1,151 @@ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import PanelContextmenu from '../panel-contextmenu' + +const mockUseClickAway = vi.hoisted(() => vi.fn()) +const mockUseTranslation = vi.hoisted(() => vi.fn()) +const mockUseStore = vi.hoisted(() => vi.fn()) +const mockUseNodesInteractions = vi.hoisted(() => vi.fn()) +const mockUsePanelInteractions = vi.hoisted(() => vi.fn()) +const mockUseWorkflowStartRun = vi.hoisted(() => vi.fn()) +const mockUseOperator = vi.hoisted(() => vi.fn()) +const mockUseDSL = vi.hoisted(() => vi.fn()) + +vi.mock('ahooks', () => ({ + useClickAway: (...args: unknown[]) => mockUseClickAway(...args), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => mockUseTranslation(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { + panelMenu?: { left: number, top: number } + clipboardElements: unknown[] + setShowImportDSLModal: (visible: boolean) => void + }) => unknown) => mockUseStore(selector), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesInteractions: () => mockUseNodesInteractions(), + usePanelInteractions: () => mockUsePanelInteractions(), + useWorkflowStartRun: () => mockUseWorkflowStartRun(), + useDSL: () => mockUseDSL(), +})) + +vi.mock('@/app/components/workflow/operator/hooks', () => ({ + useOperator: () => mockUseOperator(), +})) + +vi.mock('@/app/components/workflow/operator/add-block', () => ({ + __esModule: true, + default: ({ renderTrigger }: { renderTrigger: () => ReactNode }) => ( +
{renderTrigger()}
+ ), +})) + +vi.mock('@/app/components/base/divider', () => ({ + __esModule: true, + default: ({ className }: { className?: string }) =>
, +})) + +vi.mock('@/app/components/workflow/shortcuts-name', () => ({ + __esModule: true, + default: ({ keys }: { keys: string[] }) => {keys.join('+')}, +})) + +describe('PanelContextmenu', () => { + const mockHandleNodesPaste = vi.fn() + const mockHandlePaneContextmenuCancel = vi.fn() + const mockHandleStartWorkflowRun = vi.fn() + const mockHandleAddNote = vi.fn() + const mockExportCheck = vi.fn() + const mockSetShowImportDSLModal = vi.fn() + let panelMenu: { left: number, top: number } | undefined + let clipboardElements: unknown[] + let clickAwayHandler: (() => void) | undefined + + beforeEach(() => { + vi.clearAllMocks() + panelMenu = undefined + clipboardElements = [] + clickAwayHandler = undefined + + mockUseClickAway.mockImplementation((handler: () => void) => { + clickAwayHandler = handler + }) + mockUseTranslation.mockReturnValue({ + t: (key: string) => key, + }) + mockUseStore.mockImplementation((selector: (state: { + panelMenu?: { left: number, top: number } + clipboardElements: unknown[] + setShowImportDSLModal: (visible: boolean) => void + }) => unknown) => selector({ + panelMenu, + clipboardElements, + setShowImportDSLModal: mockSetShowImportDSLModal, + })) + mockUseNodesInteractions.mockReturnValue({ + handleNodesPaste: mockHandleNodesPaste, + }) + mockUsePanelInteractions.mockReturnValue({ + handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel, + }) + mockUseWorkflowStartRun.mockReturnValue({ + handleStartWorkflowRun: mockHandleStartWorkflowRun, + }) + mockUseOperator.mockReturnValue({ + handleAddNote: mockHandleAddNote, + }) + mockUseDSL.mockReturnValue({ + exportCheck: mockExportCheck, + }) + }) + + it('should stay hidden when the panel menu is absent', () => { + render() + + expect(screen.queryByTestId('add-block')).not.toBeInTheDocument() + }) + + it('should keep paste disabled when the clipboard is empty', () => { + panelMenu = { left: 24, top: 48 } + + render() + + fireEvent.click(screen.getByText('common.pasteHere')) + + expect(mockHandleNodesPaste).not.toHaveBeenCalled() + expect(mockHandlePaneContextmenuCancel).not.toHaveBeenCalled() + }) + + it('should render actions, position the menu, and execute each action', () => { + panelMenu = { left: 24, top: 48 } + clipboardElements = [{ id: 'copied-node' }] + const { container } = render() + + expect(screen.getByTestId('add-block')).toHaveTextContent('common.addBlock') + expect(screen.getByTestId('shortcut-alt-r')).toHaveTextContent('alt+r') + expect(screen.getByTestId('shortcut-ctrl-v')).toHaveTextContent('ctrl+v') + expect(container.firstChild).toHaveStyle({ + left: '24px', + top: '48px', + }) + + fireEvent.click(screen.getByText('nodes.note.addNote')) + fireEvent.click(screen.getByText('common.run')) + fireEvent.click(screen.getByText('common.pasteHere')) + fireEvent.click(screen.getByText('export')) + fireEvent.click(screen.getByText('common.importDSL')) + clickAwayHandler?.() + + expect(mockHandleAddNote).toHaveBeenCalledTimes(1) + expect(mockHandleStartWorkflowRun).toHaveBeenCalledTimes(1) + expect(mockHandleNodesPaste).toHaveBeenCalledTimes(1) + expect(mockExportCheck).toHaveBeenCalledTimes(1) + expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(true) + expect(mockHandlePaneContextmenuCancel).toHaveBeenCalledTimes(4) + }) +}) diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx new file mode 100644 index 0000000000..247184349d --- /dev/null +++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx @@ -0,0 +1,275 @@ +import type { Edge, Node } from '../types' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { useEffect } from 'react' +import { useNodes } from 'reactflow' +import SelectionContextmenu from '../selection-contextmenu' +import { useWorkflowHistoryStore } from '../workflow-history-store' +import { createEdge, createNode } from './fixtures' +import { renderWorkflowFlowComponent } from './workflow-test-env' + +let latestNodes: Node[] = [] +let latestHistoryEvent: string | undefined +const mockGetNodesReadOnly = vi.fn() + +vi.mock('../hooks', async () => { + const actual = await vi.importActual('../hooks') + return { + ...actual, + useNodesReadOnly: () => ({ + getNodesReadOnly: mockGetNodesReadOnly, + }), + } +}) + +const RuntimeProbe = () => { + latestNodes = useNodes() as Node[] + const { store } = useWorkflowHistoryStore() + + useEffect(() => { + latestHistoryEvent = store.getState().workflowHistoryEvent + return store.subscribe((state) => { + latestHistoryEvent = state.workflowHistoryEvent + }) + }, [store]) + + return null +} + +const hooksStoreProps = { + doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined), +} + +const renderSelectionMenu = (options?: { + nodes?: Node[] + edges?: Edge[] + initialStoreState?: Record +}) => { + latestNodes = [] + latestHistoryEvent = undefined + + const nodes = options?.nodes ?? [] + const edges = options?.edges ?? [] + + return renderWorkflowFlowComponent( +
+ + +
, + { + nodes, + edges, + hooksStoreProps, + historyStore: { nodes, edges }, + initialStoreState: options?.initialStoreState, + reactFlowProps: { fitView: false }, + }, + ) +} + +describe('SelectionContextmenu', () => { + beforeEach(() => { + vi.clearAllMocks() + latestNodes = [] + latestHistoryEvent = undefined + mockGetNodesReadOnly.mockReset() + mockGetNodesReadOnly.mockReturnValue(false) + }) + + it('should not render when selectionMenu is absent', () => { + renderSelectionMenu() + + expect(screen.queryByText('operator.vertical')).not.toBeInTheDocument() + }) + + it('should keep the menu inside the workflow container bounds', () => { + const nodes = [ + createNode({ id: 'n1', selected: true, width: 80, height: 40 }), + createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }), + ] + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { left: 780, top: 590 } }) + }) + + const menu = screen.getByTestId('selection-contextmenu') + expect(menu).toHaveStyle({ left: '540px', top: '210px' }) + }) + + it('should close itself when only one node is selected', async () => { + const nodes = [ + createNode({ id: 'n1', selected: true, width: 80, height: 40 }), + ] + + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { left: 120, top: 120 } }) + }) + + await waitFor(() => { + expect(store.getState().selectionMenu).toBeUndefined() + }) + }) + + it('should align selected nodes to the left and save history', async () => { + vi.useFakeTimers() + const nodes = [ + createNode({ id: 'n1', selected: true, position: { x: 20, y: 40 }, width: 40, height: 20 }), + createNode({ id: 'n2', selected: true, position: { x: 140, y: 90 }, width: 60, height: 30 }), + ] + + const { store } = renderSelectionMenu({ + nodes, + edges: [createEdge({ source: 'n1', target: 'n2' })], + initialStoreState: { + helpLineHorizontal: { y: 10 } as never, + helpLineVertical: { x: 10 } as never, + }, + }) + + act(() => { + store.setState({ selectionMenu: { left: 100, top: 100 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) + + expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(20) + expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(20) + expect(store.getState().selectionMenu).toBeUndefined() + expect(store.getState().helpLineHorizontal).toBeUndefined() + expect(store.getState().helpLineVertical).toBeUndefined() + + act(() => { + store.getState().flushPendingSync() + vi.advanceTimersByTime(600) + }) + + expect(hooksStoreProps.doSyncWorkflowDraft).toHaveBeenCalled() + expect(latestHistoryEvent).toBe('NodeDragStop') + vi.useRealTimers() + }) + + it('should distribute selected nodes horizontally', async () => { + const nodes = [ + createNode({ id: 'n1', selected: true, position: { x: 0, y: 10 }, width: 20, height: 20 }), + createNode({ id: 'n2', selected: true, position: { x: 100, y: 20 }, width: 20, height: 20 }), + createNode({ id: 'n3', selected: true, position: { x: 300, y: 30 }, width: 20, height: 20 }), + ] + + const { store } = renderSelectionMenu({ + nodes, + }) + + act(() => { + store.setState({ selectionMenu: { left: 160, top: 120 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-distributeHorizontal')) + + expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(150) + }) + + it('should ignore child nodes when the selected container is aligned', async () => { + const nodes = [ + createNode({ + id: 'container', + selected: true, + position: { x: 200, y: 0 }, + width: 100, + height: 80, + data: { _children: [{ nodeId: 'child', nodeType: 'code' as never }] }, + }), + createNode({ + id: 'child', + selected: true, + position: { x: 210, y: 10 }, + width: 30, + height: 20, + }), + createNode({ + id: 'other', + selected: true, + position: { x: 40, y: 60 }, + width: 40, + height: 20, + }), + ] + + const { store } = renderSelectionMenu({ + nodes, + }) + + act(() => { + store.setState({ selectionMenu: { left: 180, top: 120 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) + + expect(latestNodes.find(node => node.id === 'container')?.position.x).toBe(40) + expect(latestNodes.find(node => node.id === 'other')?.position.x).toBe(40) + expect(latestNodes.find(node => node.id === 'child')?.position.x).toBe(210) + }) + + it('should cancel when align bounds cannot be resolved', () => { + const nodes = [ + createNode({ id: 'n1', selected: true }), + createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 } }), + ] + + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { left: 100, top: 100 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) + + expect(store.getState().selectionMenu).toBeUndefined() + }) + + it('should cancel without aligning when nodes are read only', () => { + mockGetNodesReadOnly.mockReturnValue(true) + const nodes = [ + createNode({ id: 'n1', selected: true, width: 40, height: 20 }), + createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }), + ] + + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { left: 100, top: 100 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) + + expect(store.getState().selectionMenu).toBeUndefined() + expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(0) + expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(80) + }) + + it('should cancel when alignable nodes shrink to one item', () => { + const nodes = [ + createNode({ + id: 'container', + selected: true, + width: 40, + height: 20, + data: { _children: [{ nodeId: 'child', nodeType: 'code' as never }] }, + }), + createNode({ id: 'child', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }), + ] + + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { left: 100, top: 100 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) + + expect(store.getState().selectionMenu).toBeUndefined() + expect(latestNodes.find(node => node.id === 'container')?.position.x).toBe(0) + expect(latestNodes.find(node => node.id === 'child')?.position.x).toBe(80) + }) +}) diff --git a/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts b/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts new file mode 100644 index 0000000000..ac1cf67970 --- /dev/null +++ b/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts @@ -0,0 +1,79 @@ +import { DSLImportStatus } from '@/models/app' +import { AppModeEnum } from '@/types/app' +import { BlockEnum } from '../types' +import { + getInvalidNodeTypes, + isImportCompleted, + normalizeWorkflowFeatures, + validateDSLContent, +} from '../update-dsl-modal.helpers' + +describe('update-dsl-modal helpers', () => { + describe('dsl validation', () => { + it('should reject advanced chat dsl content with disallowed trigger nodes', () => { + const content = ` +workflow: + graph: + nodes: + - data: + type: trigger-webhook +` + + expect(validateDSLContent(content, AppModeEnum.ADVANCED_CHAT)).toBe(false) + }) + + it('should reject malformed yaml and answer nodes in non-advanced mode', () => { + expect(validateDSLContent('[', AppModeEnum.CHAT)).toBe(false) + expect(validateDSLContent(` +workflow: + graph: + nodes: + - data: + type: answer +`, AppModeEnum.CHAT)).toBe(false) + }) + + it('should accept valid node types for advanced chat mode', () => { + expect(validateDSLContent(` +workflow: + graph: + nodes: + - data: + type: tool +`, AppModeEnum.ADVANCED_CHAT)).toBe(true) + }) + + it('should expose the invalid node sets per mode', () => { + expect(getInvalidNodeTypes(AppModeEnum.ADVANCED_CHAT)).toEqual( + expect.arrayContaining([BlockEnum.End, BlockEnum.TriggerWebhook]), + ) + expect(getInvalidNodeTypes(AppModeEnum.CHAT)).toEqual([BlockEnum.Answer]) + }) + }) + + describe('status and feature normalization', () => { + it('should treat completed statuses as successful imports', () => { + expect(isImportCompleted(DSLImportStatus.COMPLETED)).toBe(true) + expect(isImportCompleted(DSLImportStatus.COMPLETED_WITH_WARNINGS)).toBe(true) + expect(isImportCompleted(DSLImportStatus.PENDING)).toBe(false) + }) + + it('should normalize workflow features with defaults', () => { + const features = normalizeWorkflowFeatures({ + file_upload: { + image: { + enabled: true, + }, + }, + opening_statement: 'hello', + suggested_questions: ['what can you do?'], + }) + + expect(features.file.enabled).toBe(true) + expect(features.file.number_limits).toBe(3) + expect(features.opening.enabled).toBe(true) + expect(features.suggested).toEqual({ enabled: false }) + expect(features.text2speech).toEqual({ enabled: false }) + }) + }) +}) diff --git a/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx b/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx new file mode 100644 index 0000000000..82645f2028 --- /dev/null +++ b/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx @@ -0,0 +1,365 @@ +import type { EventEmitter } from 'ahooks/lib/useEventEmitter' +import type { EventEmitterValue } from '@/context/event-emitter' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { toast } from '@/app/components/base/ui/toast' +import { EventEmitterContext } from '@/context/event-emitter' +import { DSLImportStatus } from '@/models/app' +import UpdateDSLModal from '../update-dsl-modal' + +class MockFileReader { + onload: ((this: FileReader, event: ProgressEvent) => void) | null = null + + readAsText(_file: Blob) { + const event = { target: { result: 'workflow:\n graph:\n nodes:\n - data:\n type: tool\n' } } as unknown as ProgressEvent + this.onload?.call(this as unknown as FileReader, event) + } +} + +vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader) +const mockEmit = vi.fn() + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + }, +})) + +const mockImportDSL = vi.fn() +const mockImportDSLConfirm = vi.fn() +vi.mock('@/service/apps', () => ({ + importDSL: (payload: unknown) => mockImportDSL(payload), + importDSLConfirm: (payload: unknown) => mockImportDSLConfirm(payload), +})) + +const mockFetchWorkflowDraft = vi.fn() +vi.mock('@/service/workflow', () => ({ + fetchWorkflowDraft: (path: string) => mockFetchWorkflowDraft(path), +})) + +const mockHandleCheckPluginDependencies = vi.fn() +vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ + usePluginDependencies: () => ({ + handleCheckPluginDependencies: mockHandleCheckPluginDependencies, + }), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { appDetail: { id: string, mode: string } }) => unknown) => selector({ + appDetail: { + id: 'app-1', + mode: 'chat', + }, + }), +})) + +vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ + default: ({ updateFile }: { updateFile: (file?: File) => void }) => ( + updateFile(event.target.files?.[0])} + /> + ), +})) + +describe('UpdateDSLModal', () => { + const mockToastError = vi.mocked(toast.error) + const defaultProps = { + onCancel: vi.fn(), + onBackup: vi.fn(), + onImport: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + mockFetchWorkflowDraft.mockResolvedValue({ + graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }, + features: {}, + hash: 'hash-1', + conversation_variables: [], + environment_variables: [], + }) + mockImportDSL.mockResolvedValue({ + id: 'import-1', + status: DSLImportStatus.COMPLETED, + app_id: 'app-1', + }) + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.COMPLETED, + app_id: 'app-1', + }) + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + }) + + const renderModal = (props = defaultProps) => { + const eventEmitter = { emit: mockEmit } as unknown as EventEmitter + + return render( + + + , + ) + } + + it('should keep import disabled until a file is selected', () => { + renderModal() + + expect(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })).toBeDisabled() + }) + + it('should call backup handler from the warning area', () => { + renderModal() + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.backupCurrentDraft' })) + + expect(defaultProps.onBackup).toHaveBeenCalledTimes(1) + }) + + it('should import a valid file and emit workflow update payload', async () => { + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(mockImportDSL).toHaveBeenCalledWith(expect.objectContaining({ + app_id: 'app-1', + yaml_content: expect.stringContaining('workflow:'), + })) + }) + + expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({ + type: 'WORKFLOW_DATA_UPDATE', + })) + expect(defaultProps.onImport).toHaveBeenCalledTimes(1) + expect(defaultProps.onCancel).toHaveBeenCalledTimes(1) + }) + + it('should show an error notification when import fails', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-1', + status: DSLImportStatus.FAILED, + app_id: 'app-1', + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['invalid'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) + + it('should open the version warning modal for pending imports and confirm them', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-2', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' })) + + await waitFor(() => { + expect(mockImportDSLConfirm).toHaveBeenCalledWith({ import_id: 'import-2' }) + }) + }) + + it('should open the pending modal after the timeout and allow dismissing it', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-5', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(mockImportDSL).toHaveBeenCalled() + }) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'app.newApp.Cancel' })).toBeInTheDocument() + }, { timeout: 1000 }) + + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Cancel' })) + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'app.newApp.Confirm' })).not.toBeInTheDocument() + }) + }) + + it('should show an error when the selected file content is invalid for the current app mode', async () => { + class InvalidDSLFileReader extends MockFileReader { + readAsText(_file: Blob) { + const event = { target: { result: 'workflow:\n graph:\n nodes:\n - data:\n type: answer\n' } } as unknown as ProgressEvent + this.onload?.call(this as unknown as FileReader, event) + } + } + + vi.stubGlobal('FileReader', InvalidDSLFileReader as unknown as typeof FileReader) + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + expect(mockImportDSL).not.toHaveBeenCalled() + + vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader) + }) + + it('should show an error notification when import throws', async () => { + mockImportDSL.mockRejectedValue(new Error('boom')) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) + + it('should show an error when completed import does not return an app id', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-3', + status: DSLImportStatus.COMPLETED, + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) + + it('should show an error when confirming a pending import fails', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-4', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.FAILED, + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) + + it('should show an error when confirming a pending import throws', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-6', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + mockImportDSLConfirm.mockRejectedValue(new Error('boom')) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) + + it('should show an error when a confirmed pending import completes without an app id', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-7', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.COMPLETED, + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/help-line/__tests__/index.spec.tsx b/web/app/components/workflow/help-line/__tests__/index.spec.tsx new file mode 100644 index 0000000000..f58c9c5d02 --- /dev/null +++ b/web/app/components/workflow/help-line/__tests__/index.spec.tsx @@ -0,0 +1,61 @@ +import { render } from '@testing-library/react' +import HelpLine from '../index' + +const mockUseViewport = vi.hoisted(() => vi.fn()) +const mockUseStore = vi.hoisted(() => vi.fn()) + +vi.mock('reactflow', () => ({ + useViewport: () => mockUseViewport(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { + helpLineHorizontal?: { top: number, left: number, width: number } + helpLineVertical?: { top: number, left: number, height: number } + }) => unknown) => mockUseStore(selector), +})) + +describe('HelpLine', () => { + let helpLineHorizontal: { top: number, left: number, width: number } | undefined + let helpLineVertical: { top: number, left: number, height: number } | undefined + + beforeEach(() => { + vi.clearAllMocks() + helpLineHorizontal = undefined + helpLineVertical = undefined + + mockUseViewport.mockReturnValue({ x: 10, y: 20, zoom: 2 }) + mockUseStore.mockImplementation((selector: (state: { + helpLineHorizontal?: { top: number, left: number, width: number } + helpLineVertical?: { top: number, left: number, height: number } + }) => unknown) => selector({ + helpLineHorizontal, + helpLineVertical, + })) + }) + + it('should render nothing when both help lines are absent', () => { + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + + it('should render the horizontal and vertical guide lines using viewport offsets and zoom', () => { + helpLineHorizontal = { top: 30, left: 40, width: 50 } + helpLineVertical = { top: 60, left: 70, height: 80 } + + const { container } = render() + const [horizontal, vertical] = Array.from(container.querySelectorAll('div')) + + expect(horizontal).toHaveStyle({ + top: '80px', + left: '90px', + width: '100px', + }) + expect(vertical).toHaveStyle({ + top: '140px', + left: '150px', + height: '160px', + }) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts b/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts new file mode 100644 index 0000000000..5811f14a60 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-config-vision.spec.ts @@ -0,0 +1,171 @@ +import type { ModelConfig, VisionSetting } from '@/app/components/workflow/types' +import { act, renderHook } from '@testing-library/react' +import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { Resolution } from '@/types/app' +import useConfigVision from '../use-config-vision' + +const mockUseTextGenerationCurrentProviderAndModelAndModelList = vi.hoisted(() => vi.fn()) +const mockUseIsChatMode = vi.hoisted(() => vi.fn()) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useTextGenerationCurrentProviderAndModelAndModelList: (...args: unknown[]) => + mockUseTextGenerationCurrentProviderAndModelAndModelList(...args), +})) + +vi.mock('../use-workflow', () => ({ + useIsChatMode: () => mockUseIsChatMode(), +})) + +const createModel = (overrides: Partial = {}): ModelConfig => ({ + provider: 'openai', + name: 'gpt-4o', + mode: 'chat', + completion_params: [], + ...overrides, +}) + +const createVisionPayload = (overrides: Partial<{ enabled: boolean, configs?: VisionSetting }> = {}) => ({ + enabled: false, + ...overrides, +}) + +describe('useConfigVision', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseIsChatMode.mockReturnValue(false) + mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({ + currentModel: { + features: [], + }, + }) + }) + + it('should expose vision capability and enable default chat configs for vision models', () => { + const onChange = vi.fn() + mockUseIsChatMode.mockReturnValue(true) + mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({ + currentModel: { + features: [ModelFeatureEnum.vision], + }, + }) + + const { result } = renderHook(() => useConfigVision(createModel(), { + payload: createVisionPayload(), + onChange, + })) + + expect(result.current.isVisionModel).toBe(true) + + act(() => { + result.current.handleVisionResolutionEnabledChange(true) + }) + + expect(onChange).toHaveBeenCalledWith({ + enabled: true, + configs: { + detail: Resolution.high, + variable_selector: ['sys', 'files'], + }, + }) + }) + + it('should clear configs when disabling vision resolution', () => { + const onChange = vi.fn() + + const { result } = renderHook(() => useConfigVision(createModel(), { + payload: createVisionPayload({ + enabled: true, + configs: { + detail: Resolution.low, + variable_selector: ['node', 'files'], + }, + }), + onChange, + })) + + act(() => { + result.current.handleVisionResolutionEnabledChange(false) + }) + + expect(onChange).toHaveBeenCalledWith({ + enabled: false, + }) + }) + + it('should update the resolution config payload directly', () => { + const onChange = vi.fn() + const config: VisionSetting = { + detail: Resolution.low, + variable_selector: ['upstream', 'images'], + } + + const { result } = renderHook(() => useConfigVision(createModel(), { + payload: createVisionPayload({ enabled: true }), + onChange, + })) + + act(() => { + result.current.handleVisionResolutionChange(config) + }) + + expect(onChange).toHaveBeenCalledWith({ + enabled: true, + configs: config, + }) + }) + + it('should disable vision settings when the selected model is no longer a vision model', () => { + const onChange = vi.fn() + + const { result } = renderHook(() => useConfigVision(createModel(), { + payload: createVisionPayload({ + enabled: true, + configs: { + detail: Resolution.high, + variable_selector: ['sys', 'files'], + }, + }), + onChange, + })) + + act(() => { + result.current.handleModelChanged() + }) + + expect(onChange).toHaveBeenCalledWith({ + enabled: false, + }) + }) + + it('should reset enabled vision configs when the model changes but still supports vision', () => { + const onChange = vi.fn() + mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({ + currentModel: { + features: [ModelFeatureEnum.vision], + }, + }) + + const { result } = renderHook(() => useConfigVision(createModel(), { + payload: createVisionPayload({ + enabled: true, + configs: { + detail: Resolution.low, + variable_selector: ['old', 'files'], + }, + }), + onChange, + })) + + act(() => { + result.current.handleModelChanged() + }) + + expect(onChange).toHaveBeenCalledWith({ + enabled: true, + configs: { + detail: Resolution.high, + variable_selector: [], + }, + }) + }) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx new file mode 100644 index 0000000000..d66e3ebe4a --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-dynamic-test-run-options.spec.tsx @@ -0,0 +1,146 @@ +import { renderHook } from '@testing-library/react' +import { BlockEnum } from '../../types' +import { useDynamicTestRunOptions } from '../use-dynamic-test-run-options' + +const mockUseTranslation = vi.hoisted(() => vi.fn()) +const mockUseNodes = vi.hoisted(() => vi.fn()) +const mockUseStore = vi.hoisted(() => vi.fn()) +const mockUseAllTriggerPlugins = vi.hoisted(() => vi.fn()) +const mockGetWorkflowEntryNode = vi.hoisted(() => vi.fn()) + +vi.mock('react-i18next', () => ({ + useTranslation: () => mockUseTranslation(), +})) + +vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ + __esModule: true, + default: () => mockUseNodes(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { + buildInTools: unknown[] + customTools: unknown[] + workflowTools: unknown[] + mcpTools: unknown[] + }) => unknown) => mockUseStore(selector), +})) + +vi.mock('@/service/use-triggers', () => ({ + useAllTriggerPlugins: () => mockUseAllTriggerPlugins(), +})) + +vi.mock('@/app/components/workflow/utils/workflow-entry', () => ({ + getWorkflowEntryNode: (...args: unknown[]) => mockGetWorkflowEntryNode(...args), +})) + +describe('useDynamicTestRunOptions', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseTranslation.mockReturnValue({ + t: (key: string) => key, + }) + mockUseStore.mockImplementation((selector: (state: { + buildInTools: unknown[] + customTools: unknown[] + workflowTools: unknown[] + mcpTools: unknown[] + }) => unknown) => selector({ + buildInTools: [], + customTools: [], + workflowTools: [], + mcpTools: [], + })) + mockUseAllTriggerPlugins.mockReturnValue({ + data: [{ + name: 'plugin-provider', + icon: '/plugin-icon.png', + }], + }) + }) + + it('should build user input, trigger options, and a run-all option from workflow nodes', () => { + mockUseNodes.mockReturnValue([ + { + id: 'start-1', + data: { type: BlockEnum.Start, title: 'User Input' }, + }, + { + id: 'schedule-1', + data: { type: BlockEnum.TriggerSchedule, title: 'Daily Schedule' }, + }, + { + id: 'webhook-1', + data: { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' }, + }, + { + id: 'plugin-1', + data: { + type: BlockEnum.TriggerPlugin, + title: '', + plugin_name: 'Plugin Trigger', + provider_id: 'plugin-provider', + }, + }, + ]) + + const { result } = renderHook(() => useDynamicTestRunOptions()) + + expect(result.current.userInput).toEqual(expect.objectContaining({ + id: 'start-1', + type: 'user_input', + name: 'User Input', + nodeId: 'start-1', + enabled: true, + })) + expect(result.current.triggers).toEqual([ + expect.objectContaining({ + id: 'schedule-1', + type: 'schedule', + name: 'Daily Schedule', + nodeId: 'schedule-1', + }), + expect.objectContaining({ + id: 'webhook-1', + type: 'webhook', + name: 'Webhook Trigger', + nodeId: 'webhook-1', + }), + expect.objectContaining({ + id: 'plugin-1', + type: 'plugin', + name: 'Plugin Trigger', + nodeId: 'plugin-1', + }), + ]) + expect(result.current.runAll).toEqual(expect.objectContaining({ + id: 'run-all', + type: 'all', + relatedNodeIds: ['schedule-1', 'webhook-1', 'plugin-1'], + })) + }) + + it('should fall back to the workflow entry node and omit run-all when only one trigger exists', () => { + mockUseNodes.mockReturnValue([ + { + id: 'webhook-1', + data: { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' }, + }, + ]) + mockGetWorkflowEntryNode.mockReturnValue({ + id: 'fallback-start', + data: { type: BlockEnum.Start, title: '' }, + }) + + const { result } = renderHook(() => useDynamicTestRunOptions()) + + expect(result.current.userInput).toEqual(expect.objectContaining({ + id: 'fallback-start', + type: 'user_input', + name: 'blocks.start', + nodeId: 'fallback-start', + })) + expect(result.current.triggers).toHaveLength(1) + expect(result.current.runAll).toBeUndefined() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx new file mode 100644 index 0000000000..6dda819a04 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx @@ -0,0 +1,135 @@ +import type { TFunction } from 'i18next' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' +import { NodeBody, NodeDescription, NodeHeaderMeta } from '../node-sections' + +describe('node sections', () => { + it('should render loop and loading metadata in the header section', () => { + const t = ((key: string) => key) as unknown as TFunction + + render( + loop-index
} + t={t} + />, + ) + + expect(screen.getByText('loop-index')).toBeInTheDocument() + expect(document.querySelector('.i-ri-loader-2-line')).toBeInTheDocument() + }) + + it('should render the container node body and description branches', () => { + const { rerender } = render( + body-content
} + />, + ) + + expect(screen.getByText('body-content').parentElement).toHaveClass('grow') + + rerender() + expect(screen.getByText('node description')).toBeInTheDocument() + }) + + it('should render iteration parallel metadata and running progress', async () => { + const t = ((key: string) => key) as unknown as TFunction + const user = userEvent.setup() + + render( + , + ) + + expect(screen.getByText('nodes.iteration.parallelModeUpper')).toBeInTheDocument() + await user.hover(screen.getByText('nodes.iteration.parallelModeUpper')) + expect(await screen.findByText('nodes.iteration.parallelModeEnableTitle')).toBeInTheDocument() + expect(screen.getByText('nodes.iteration.parallelModeEnableDesc')).toBeInTheDocument() + expect(screen.getByText('3/3')).toBeInTheDocument() + }) + + it('should render failed, exception, success and paused status icons', () => { + const t = ((key: string) => key) as unknown as TFunction + const { rerender } = render( + , + ) + + expect(document.querySelector('.i-ri-error-warning-fill')).toBeInTheDocument() + + rerender( + , + ) + expect(document.querySelector('.i-ri-alert-fill')).toBeInTheDocument() + + rerender( + , + ) + expect(document.querySelector('.i-ri-checkbox-circle-fill')).toBeInTheDocument() + + rerender( + , + ) + expect(document.querySelector('.i-ri-pause-circle-fill')).toBeInTheDocument() + }) + + it('should render success icon when inspect vars exist without running status and hide description for loop nodes', () => { + const t = ((key: string) => key) as unknown as TFunction + const { rerender } = render( + , + ) + + expect(document.querySelector('.i-ri-checkbox-circle-fill')).toBeInTheDocument() + + rerender() + expect(screen.queryByText('hidden')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts b/web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts new file mode 100644 index 0000000000..78e1f938c5 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts @@ -0,0 +1,34 @@ +import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' +import { + getLoopIndexTextKey, + getNodeStatusBorders, + isContainerNode, + isEntryWorkflowNode, +} from '../node.helpers' + +describe('node helpers', () => { + it('should derive node border states from running status and selection state', () => { + expect(getNodeStatusBorders(NodeRunningStatus.Running, false, false).showRunningBorder).toBe(true) + expect(getNodeStatusBorders(NodeRunningStatus.Succeeded, false, false).showSuccessBorder).toBe(true) + expect(getNodeStatusBorders(NodeRunningStatus.Failed, false, false).showFailedBorder).toBe(true) + expect(getNodeStatusBorders(NodeRunningStatus.Exception, false, false).showExceptionBorder).toBe(true) + expect(getNodeStatusBorders(NodeRunningStatus.Succeeded, false, true).showSuccessBorder).toBe(false) + }) + + it('should expose the correct loop translation key per running status', () => { + expect(getLoopIndexTextKey(NodeRunningStatus.Running)).toBe('nodes.loop.currentLoopCount') + expect(getLoopIndexTextKey(NodeRunningStatus.Succeeded)).toBe('nodes.loop.totalLoopCount') + expect(getLoopIndexTextKey(NodeRunningStatus.Failed)).toBe('nodes.loop.totalLoopCount') + expect(getLoopIndexTextKey(NodeRunningStatus.Paused)).toBeUndefined() + }) + + it('should identify entry and container nodes', () => { + expect(isEntryWorkflowNode(BlockEnum.Start)).toBe(true) + expect(isEntryWorkflowNode(BlockEnum.TriggerWebhook)).toBe(true) + expect(isEntryWorkflowNode(BlockEnum.Tool)).toBe(false) + + expect(isContainerNode(BlockEnum.Iteration)).toBe(true) + expect(isContainerNode(BlockEnum.Loop)).toBe(true) + expect(isContainerNode(BlockEnum.Tool)).toBe(false) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx new file mode 100644 index 0000000000..a7f88e983e --- /dev/null +++ b/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx @@ -0,0 +1,218 @@ +import type { PropsWithChildren } from 'react' +import type { CommonNodeType } from '@/app/components/workflow/types' +import { fireEvent, screen } from '@testing-library/react' +import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' +import BaseNode from '../node' + +const mockHasNodeInspectVars = vi.fn() +const mockUseNodePluginInstallation = vi.fn() +const mockHandleNodeIterationChildSizeChange = vi.fn() +const mockHandleNodeLoopChildSizeChange = vi.fn() +const mockUseNodeResizeObserver = vi.fn() + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesReadOnly: () => ({ nodesReadOnly: false }), + useToolIcon: () => undefined, +})) + +vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({ + default: () => ({ + hasNodeInspectVars: mockHasNodeInspectVars, + }), +})) + +vi.mock('@/app/components/workflow/hooks/use-node-plugin-installation', () => ({ + useNodePluginInstallation: (...args: unknown[]) => mockUseNodePluginInstallation(...args), +})) + +vi.mock('@/app/components/workflow/nodes/iteration/use-interactions', () => ({ + useNodeIterationInteractions: () => ({ + handleNodeIterationChildSizeChange: mockHandleNodeIterationChildSizeChange, + }), +})) + +vi.mock('@/app/components/workflow/nodes/loop/use-interactions', () => ({ + useNodeLoopInteractions: () => ({ + handleNodeLoopChildSizeChange: mockHandleNodeLoopChildSizeChange, + }), +})) + +vi.mock('../use-node-resize-observer', () => ({ + default: (options: { enabled: boolean, onResize: () => void }) => { + mockUseNodeResizeObserver(options) + if (options.enabled) + options.onResize() + }, +})) + +vi.mock('../components/add-variable-popup-with-position', () => ({ + default: () =>
, +})) +vi.mock('../components/entry-node-container', () => ({ + __esModule: true, + StartNodeTypeEnum: { Start: 'start', Trigger: 'trigger' }, + default: ({ children }: PropsWithChildren) =>
{children}
, +})) +vi.mock('../components/error-handle/error-handle-on-node', () => ({ + default: () =>
, +})) +vi.mock('../components/node-control', () => ({ + default: () =>
, +})) +vi.mock('../components/node-handle', () => ({ + NodeSourceHandle: () =>
, + NodeTargetHandle: () =>
, +})) +vi.mock('../components/node-resizer', () => ({ + default: () =>
, +})) +vi.mock('../components/retry/retry-on-node', () => ({ + default: () =>
, +})) +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: () =>
, +})) +vi.mock('@/app/components/workflow/nodes/tool/components/copy-id', () => ({ + default: ({ content }: { content: string }) =>
{content}
, +})) + +const createData = (overrides: Record = {}) => ({ + type: BlockEnum.Tool, + title: 'Node title', + desc: 'Node description', + selected: false, + width: 280, + height: 180, + provider_type: 'builtin', + provider_id: 'tool-1', + _runningStatus: undefined, + _singleRunningStatus: undefined, + ...overrides, +}) + +const toNodeData = (data: ReturnType) => data as CommonNodeType + +describe('BaseNode', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHasNodeInspectVars.mockReturnValue(false) + mockUseNodeResizeObserver.mockReset() + mockUseNodePluginInstallation.mockReturnValue({ + shouldDim: false, + isChecking: false, + isMissing: false, + canInstall: false, + uniqueIdentifier: undefined, + }) + }) + + it('should render content, handles and description for a regular node', () => { + renderWorkflowComponent( + +
Body
+
, + ) + + expect(screen.getByText('Node title')).toBeInTheDocument() + expect(screen.getByText('Node description')).toBeInTheDocument() + expect(screen.getByTestId('node-control')).toBeInTheDocument() + expect(screen.getByTestId('node-source-handle')).toBeInTheDocument() + expect(screen.getByTestId('node-target-handle')).toBeInTheDocument() + }) + + it('should render entry nodes inside the entry container', () => { + renderWorkflowComponent( + +
Body
+
, + ) + + expect(screen.getByTestId('entry-node-container')).toBeInTheDocument() + }) + + it('should block interaction when plugin installation is required', () => { + mockUseNodePluginInstallation.mockReturnValue({ + shouldDim: false, + isChecking: false, + isMissing: true, + canInstall: true, + uniqueIdentifier: 'plugin-1', + }) + + renderWorkflowComponent( + +
Body
+
, + ) + + const overlay = screen.getByTestId('workflow-node-install-overlay') + expect(overlay).toBeInTheDocument() + fireEvent.click(overlay) + }) + + it('should render running status indicators for loop nodes', () => { + renderWorkflowComponent( + +
Loop body
+
, + ) + + expect(screen.getByText(/workflow\.nodes\.loop\.currentLoopCount/)).toBeInTheDocument() + expect(screen.getByTestId('node-resizer')).toBeInTheDocument() + }) + + it('should render an iteration node resizer and dimmed overlay', () => { + mockUseNodePluginInstallation.mockReturnValue({ + shouldDim: true, + isChecking: false, + isMissing: false, + canInstall: false, + uniqueIdentifier: undefined, + }) + + renderWorkflowComponent( + +
Iteration body
+
, + ) + + expect(screen.getByTestId('node-resizer')).toBeInTheDocument() + expect(screen.getByTestId('workflow-node-install-overlay')).toBeInTheDocument() + expect(mockHandleNodeIterationChildSizeChange).toHaveBeenCalledWith('node-1') + }) + + it('should trigger loop resize updates when the selected node is inside a loop', () => { + renderWorkflowComponent( + +
Loop body
+
, + ) + + expect(mockHandleNodeLoopChildSizeChange).toHaveBeenCalledWith('node-2') + expect(mockUseNodeResizeObserver).toHaveBeenCalledTimes(2) + }) +}) diff --git a/web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx new file mode 100644 index 0000000000..9ee377be4d --- /dev/null +++ b/web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx @@ -0,0 +1,55 @@ +import { renderHook } from '@testing-library/react' +import useNodeResizeObserver from '../use-node-resize-observer' + +describe('useNodeResizeObserver', () => { + it('should observe and disconnect when enabled with a mounted node ref', () => { + const observe = vi.fn() + const disconnect = vi.fn() + const onResize = vi.fn() + let resizeCallback: (() => void) | undefined + + vi.stubGlobal('ResizeObserver', class { + constructor(callback: () => void) { + resizeCallback = callback + } + + observe = observe + disconnect = disconnect + unobserve = vi.fn() + }) + + const node = document.createElement('div') + const nodeRef = { current: node } + + const { unmount } = renderHook(() => useNodeResizeObserver({ + enabled: true, + nodeRef, + onResize, + })) + + expect(observe).toHaveBeenCalledWith(node) + resizeCallback?.() + expect(onResize).toHaveBeenCalledTimes(1) + + unmount() + expect(disconnect).toHaveBeenCalledTimes(1) + }) + + it('should do nothing when disabled', () => { + const observe = vi.fn() + + vi.stubGlobal('ResizeObserver', class { + observe = observe + disconnect = vi.fn() + unobserve = vi.fn() + }) + + renderHook(() => useNodeResizeObserver({ + enabled: false, + nodeRef: { current: document.createElement('div') }, + onResize: vi.fn(), + })) + + expect(observe).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx new file mode 100644 index 0000000000..49de788314 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx @@ -0,0 +1,410 @@ +import type { ComponentProps } from 'react' +import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' +import { VarKindType } from '../../types' +import FormInputItem from '../form-input-item' + +const { + mockFetchDynamicOptions, + mockTriggerDynamicOptionsState, +} = vi.hoisted(() => ({ + mockFetchDynamicOptions: vi.fn(), + mockTriggerDynamicOptionsState: { + data: undefined as { options: FormOption[] } | undefined, + isLoading: false, + }, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useLanguage: () => 'en_US', +})) + +vi.mock('@/service/use-plugins', () => ({ + useFetchDynamicOptions: () => ({ + mutateAsync: mockFetchDynamicOptions, + }), +})) + +vi.mock('@/service/use-triggers', () => ({ + useTriggerPluginDynamicOptions: () => mockTriggerDynamicOptionsState, +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({ + default: ({ onSelect }: { onSelect: (value: string) => void }) => ( + + ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({ + default: ({ setModel }: { setModel: (value: string) => void }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/tool/components/mixed-variable-text-input', () => ({ + default: ({ onChange, value }: { onChange: (value: string) => void, value: string }) => ( + onChange(e.target.value)} /> + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ onChange, value }: { onChange: (value: string) => void, value: string }) => ( +