Skip to content

Commit 225e576

Browse files
authored
feat(stage-wizard): guide cues COMPASS-6664 (#4346)
1 parent 8acf342 commit 225e576

File tree

10 files changed

+248
-141
lines changed

10 files changed

+248
-141
lines changed

packages/compass-aggregations/src/components/aggregation-side-panel/index.spec.tsx

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,27 @@ import React from 'react';
22
import type { ComponentProps } from 'react';
33
import { AggregationSidePanel } from './index';
44
import { cleanup, render, screen } from '@testing-library/react';
5+
import { DndContext } from '@dnd-kit/core';
56
import userEvent from '@testing-library/user-event';
67
import { expect } from 'chai';
78
import configureStore from '../../../test/configure-store';
89
import { Provider } from 'react-redux';
910
import sinon from 'sinon';
1011
import { STAGE_WIZARD_USE_CASES } from './stage-wizard-use-cases';
12+
import * as guideCueHook from '../use-guide-cue';
1113

1214
const renderAggregationSidePanel = (
1315
props: Partial<ComponentProps<typeof AggregationSidePanel>> = {}
1416
) => {
1517
return render(
1618
<Provider store={configureStore()}>
17-
<AggregationSidePanel
18-
onSelectUseCase={() => {}}
19-
onCloseSidePanel={() => {}}
20-
{...props}
21-
/>
19+
<DndContext>
20+
<AggregationSidePanel
21+
onSelectUseCase={() => {}}
22+
onCloseSidePanel={() => {}}
23+
{...props}
24+
/>
25+
</DndContext>
2226
</Provider>
2327
);
2428
};
@@ -98,4 +102,44 @@ describe('aggregation side panel', function () {
98102
screen.getByTestId('use-case-sort').click();
99103
expect(onSelectUseCase).to.have.been.calledOnceWith('sort', '$sort');
100104
});
105+
106+
context('guide cue', function () {
107+
const guideCueSandbox: sinon.SinonSandbox = sinon.createSandbox();
108+
afterEach(function () {
109+
guideCueSandbox.restore();
110+
});
111+
112+
context('shows guide cue', function () {
113+
let markCueVisitedSpy: sinon.SinonSpy;
114+
beforeEach(function () {
115+
markCueVisitedSpy = sinon.spy();
116+
guideCueSandbox.stub(guideCueHook, 'useGuideCue').returns({
117+
isCueVisible: true,
118+
markCueVisited: markCueVisitedSpy,
119+
cueRefEl: React.createRef(),
120+
} as any);
121+
renderAggregationSidePanel();
122+
});
123+
it('shows guide cue first time', function () {
124+
expect(
125+
screen.getByTestId('stage-wizard-use-case-list-guide-cue')
126+
).to.exist;
127+
});
128+
it('marks cue visited when use case is clicked', function () {
129+
expect(markCueVisitedSpy.callCount).to.equal(0);
130+
screen.getByTestId('use-case-sort').click();
131+
expect(markCueVisitedSpy.callCount).to.equal(1);
132+
});
133+
});
134+
135+
it('does not show guide cue when its already shown', function () {
136+
guideCueSandbox
137+
.stub(guideCueHook, 'useGuideCue')
138+
.returns({ isCueVisible: false, cueRefEl: React.createRef() } as any);
139+
renderAggregationSidePanel();
140+
expect(() =>
141+
screen.getByTestId('stage-wizard-use-case-list-guide-cue')
142+
).to.throw;
143+
});
144+
});
101145
});

packages/compass-aggregations/src/components/aggregation-side-panel/index.tsx

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,18 @@ import {
1010
spacing,
1111
useDarkMode,
1212
SearchInput,
13+
GuideCue,
1314
} from '@mongodb-js/compass-components';
1415
import { connect } from 'react-redux';
1516
import { createLoggerAndTelemetry } from '@mongodb-js/compass-logging';
17+
import { useDndMonitor } from '@dnd-kit/core';
1618

1719
import { toggleSidePanel } from '../../modules/side-panel';
1820
import { STAGE_WIZARD_USE_CASES } from './stage-wizard-use-cases';
1921
import { FeedbackLink } from './feedback-link';
2022
import { addWizard } from '../../modules/pipeline-builder/stage-editor';
2123
import { UseCaseCard } from './stage-wizard-use-cases';
24+
import { useGuideCue, GuideCueStorageKeys } from '../use-guide-cue';
2225

2326
const { track } = createLoggerAndTelemetry('COMPASS-AGGREGATIONS-UI');
2427

@@ -77,6 +80,9 @@ export const AggregationSidePanel = ({
7780
const [searchText, setSearchText] = useState<string>('');
7881
const darkMode = useDarkMode();
7982

83+
const { cueIntersectingRef, cueRefEl, isCueVisible, markCueVisited } =
84+
useGuideCue(GuideCueStorageKeys.STAGE_WIZARD_LIST);
85+
8086
const filteredUseCases = useMemo(() => {
8187
return STAGE_WIZARD_USE_CASES.filter(({ title, stageOperator }) => {
8288
const escapedSearchText = searchText.replace('$', '\\$');
@@ -104,12 +110,23 @@ export const AggregationSidePanel = ({
104110
track('Aggregation Use Case Added', {
105111
drag_and_drop: false,
106112
});
113+
markCueVisited();
107114
},
108-
[onSelectUseCase]
115+
[onSelectUseCase, markCueVisited]
109116
);
110117

118+
// Hide guide cue when use-case card is dragged from the list
119+
useDndMonitor({
120+
onDragStart(event) {
121+
if (event.active.data.current?.type === 'use-case') {
122+
markCueVisited();
123+
}
124+
},
125+
});
126+
111127
return (
112128
<KeylineCard
129+
ref={cueIntersectingRef}
113130
data-testid="aggregation-side-panel"
114131
className={cx(containerStyles, darkMode && darkModeContainerStyles)}
115132
>
@@ -136,14 +153,38 @@ export const AggregationSidePanel = ({
136153
aria-label="How can we help?"
137154
/>
138155
<div className={contentStyles} data-testid="side-panel-content">
139-
{filteredUseCases.map((useCase) => (
140-
<UseCaseCard
141-
key={useCase.id}
142-
id={useCase.id}
143-
title={useCase.title}
144-
stageOperator={useCase.stageOperator}
145-
onSelect={() => onSelect(useCase.id)}
146-
/>
156+
{filteredUseCases.map((useCase, index) => (
157+
<div key={index}>
158+
<GuideCue
159+
data-testid="stage-wizard-use-case-list-guide-cue"
160+
open={isCueVisible && index === 0}
161+
setOpen={markCueVisited}
162+
refEl={cueRefEl}
163+
numberOfSteps={1}
164+
popoverZIndex={2}
165+
title="Quick access to the stages"
166+
tooltipJustify="end"
167+
tooltipAlign="left"
168+
>
169+
Choose from the list and use our easy drag & drop functionality to
170+
add it in the pipeline overview.
171+
</GuideCue>
172+
<div
173+
ref={(r) => {
174+
if (index === 0) {
175+
cueRefEl.current = r;
176+
}
177+
}}
178+
>
179+
<UseCaseCard
180+
key={useCase.id}
181+
id={useCase.id}
182+
title={useCase.title}
183+
stageOperator={useCase.stageOperator}
184+
onSelect={() => onSelect(useCase.id)}
185+
/>
186+
</div>
187+
</div>
147188
))}
148189
<FeedbackLink />
149190
</div>

packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-settings/pipeline-extra-settings.spec.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event';
55
import { expect } from 'chai';
66
import type { SinonSandbox } from 'sinon';
77
import { spy, createSandbox } from 'sinon';
8+
import * as guideCueHook from '../../use-guide-cue';
89

910
import { PipelineExtraSettings } from './pipeline-extra-settings';
1011
import preferences from 'compass-preferences-model';
@@ -105,5 +106,57 @@ describe('PipelineExtraSettings', function () {
105106
.getAttribute('aria-disabled')
106107
).to.equal('true');
107108
});
109+
110+
context('guide cue', function () {
111+
const guideCueSandbox: sinon.SinonSandbox = sinon.createSandbox();
112+
113+
afterEach(function () {
114+
guideCueSandbox.restore();
115+
});
116+
117+
context('shows guide cue', function () {
118+
let markCueVisitedSpy: sinon.SinonSpy;
119+
beforeEach(function () {
120+
markCueVisitedSpy = sinon.spy();
121+
guideCueSandbox.stub(guideCueHook, 'useGuideCue').returns({
122+
isCueVisible: true,
123+
markCueVisited: markCueVisitedSpy,
124+
} as any);
125+
renderPipelineExtraSettings({
126+
pipelineMode: 'builder-ui',
127+
});
128+
});
129+
it('shows guide cue first time', function () {
130+
expect(screen.getByTestId('stage-wizard-guide-cue')).to.exist;
131+
});
132+
it('marks cue visited when stage wizard button is clicked', function () {
133+
expect(markCueVisitedSpy.callCount).to.equal(0);
134+
screen.getByTestId('pipeline-toolbar-side-panel-button').click();
135+
expect(markCueVisitedSpy.callCount).to.equal(1);
136+
});
137+
});
138+
139+
context('does not show guide cue', function () {
140+
it('when its already shown', function () {
141+
guideCueSandbox
142+
.stub(guideCueHook, 'useGuideCue')
143+
.returns({ isCueVisible: false } as any);
144+
renderPipelineExtraSettings({
145+
pipelineMode: 'builder-ui',
146+
});
147+
expect(() => screen.getByTestId('stage-wizard-guide-cue')).to.throw;
148+
});
149+
150+
it('in text mode', function () {
151+
guideCueSandbox
152+
.stub(guideCueHook, 'useGuideCue')
153+
.returns({ isCueVisible: true } as any);
154+
renderPipelineExtraSettings({
155+
pipelineMode: 'as-text',
156+
});
157+
expect(() => screen.getByTestId('stage-wizard-guide-cue')).to.throw;
158+
});
159+
});
160+
});
108161
});
109162
});

packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-settings/pipeline-extra-settings.tsx

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
spacing,
1010
SegmentedControl,
1111
SegmentedControlOption,
12+
GuideCue,
1213
} from '@mongodb-js/compass-components';
1314
import { toggleSettingsIsExpanded } from '../../../modules/settings';
1415
import { toggleAutoPreview } from '../../../modules/auto-preview';
@@ -20,6 +21,8 @@ import { toggleSidePanel } from '../../../modules/side-panel';
2021
import { usePreference } from 'compass-preferences-model';
2122

2223
import { createLoggerAndTelemetry } from '@mongodb-js/compass-logging';
24+
import { GuideCueStorageKeys, useGuideCue } from '../../use-guide-cue';
25+
2326
const { track } = createLoggerAndTelemetry('COMPASS-AGGREGATIONS-UI');
2427

2528
const containerStyles = css({
@@ -65,16 +68,25 @@ export const PipelineExtraSettings: React.FunctionComponent<
6568
}) => {
6669
const showStageWizard = usePreference('enableStageWizard', React);
6770

71+
const { cueRefEl, cueIntersectingRef, isCueVisible, markCueVisited } =
72+
useGuideCue(GuideCueStorageKeys.STAGE_WIZARD);
73+
6874
useEffect(() => {
6975
if (isSidePanelOpen) {
7076
track('Aggregation Side Panel Opened');
7177
}
7278
}, [isSidePanelOpen]);
7379

80+
const onClickWizardButton = () => {
81+
markCueVisited();
82+
onToggleSidePanel();
83+
};
84+
7485
return (
7586
<div
7687
className={containerStyles}
7788
data-testid="pipeline-toolbar-extra-settings"
89+
ref={cueIntersectingRef}
7890
>
7991
<div className={toggleStyles}>
8092
<Toggle
@@ -120,15 +132,30 @@ export const PipelineExtraSettings: React.FunctionComponent<
120132
</SegmentedControlOption>
121133
</SegmentedControl>
122134
{showStageWizard && (
123-
<IconButton
124-
title="Toggle Side Panel"
125-
aria-label="Toggle Side Panel"
126-
onClick={() => onToggleSidePanel()}
127-
data-testid="pipeline-toolbar-side-panel-button"
128-
disabled={pipelineMode === 'as-text'}
129-
>
130-
<Icon glyph="Wizard" />
131-
</IconButton>
135+
<>
136+
<GuideCue
137+
data-testid="stage-wizard-guide-cue"
138+
open={isCueVisible && pipelineMode === 'builder-ui'}
139+
setOpen={markCueVisited}
140+
refEl={cueRefEl}
141+
numberOfSteps={1}
142+
popoverZIndex={2}
143+
title="Stage Creator"
144+
>
145+
You can quickly build your stages based on your needs. You should
146+
try it out.
147+
</GuideCue>
148+
<IconButton
149+
ref={cueRefEl}
150+
title="Toggle Side Panel"
151+
aria-label="Toggle Side Panel"
152+
onClick={onClickWizardButton}
153+
data-testid="pipeline-toolbar-side-panel-button"
154+
disabled={pipelineMode === 'as-text'}
155+
>
156+
<Icon glyph="Wizard" />
157+
</IconButton>
158+
</>
132159
)}
133160
<IconButton
134161
title="More Settings"

packages/compass-aggregations/src/components/stage-toolbar/index.spec.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ const renderStageToolbar = (
1717
})}
1818
>
1919
<StageToolbar
20-
onFocusModeClicked={() => {}}
2120
hasServerError={false}
2221
hasSyntaxError={false}
2322
index={0}

packages/compass-aggregations/src/components/stage-toolbar/index.tsx

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
useDarkMode,
1111
IconButton,
1212
} from '@mongodb-js/compass-components';
13-
import type { PipelineBuilderThunkDispatch, RootState } from '../../modules';
13+
import type { RootState } from '../../modules';
1414
import ToggleStage from './toggle-stage';
1515
import StageCollapser from './stage-collapser';
1616
import StageOperatorSelect from './stage-operator-select';
@@ -91,8 +91,7 @@ type StageToolbarProps = {
9191
hasServerError?: boolean;
9292
isCollapsed?: boolean;
9393
isDisabled?: boolean;
94-
onFocusModeClicked: () => void;
95-
onOpenFocusMode: () => void;
94+
onOpenFocusMode: (index: number) => void;
9695
onStageOperatorChange?: (
9796
index: number,
9897
name: string | null,
@@ -140,7 +139,7 @@ export function StageToolbar({
140139
</div>
141140
<div className={rightStyles}>
142141
<IconButton
143-
onClick={onOpenFocusMode}
142+
onClick={() => onOpenFocusMode(index)}
144143
aria-label="Open stage in focus mode"
145144
title="Open stage in focus mode"
146145
data-testid="focus-mode-button"
@@ -154,10 +153,7 @@ export function StageToolbar({
154153
);
155154
}
156155

157-
type StageToolbarOwnProps = Pick<
158-
StageToolbarProps,
159-
'index' | 'onFocusModeClicked'
160-
>;
156+
type StageToolbarOwnProps = Pick<StageToolbarProps, 'index'>;
161157

162158
export default connect(
163159
(state: RootState, ownProps: StageToolbarOwnProps) => {
@@ -176,10 +172,7 @@ export default connect(
176172
isDisabled: stage.disabled,
177173
};
178174
},
179-
(dispatch: PipelineBuilderThunkDispatch, ownProps: StageToolbarOwnProps) => ({
180-
onOpenFocusMode: () => {
181-
dispatch(enableFocusMode(ownProps.index));
182-
ownProps.onFocusModeClicked();
183-
},
184-
})
175+
{
176+
onOpenFocusMode: enableFocusMode,
177+
}
185178
)(StageToolbar);

0 commit comments

Comments
 (0)