Skip to content

Commit 7db3e3d

Browse files
committed
feat(communication): add guided tour
ref: #MANAGER-18777 Signed-off-by: Dustin Kroger <[email protected]>
1 parent f8cefd3 commit 7db3e3d

File tree

22 files changed

+729
-26
lines changed

22 files changed

+729
-26
lines changed

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,4 @@ packages/manager-wiki
5959
lint-runner.js
6060
lint-html-runner.js
6161
lint-cli.js
62+
packages/manager/apps/communication

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ packages/manager-ui-kit
1717
packages/manager-wiki
1818
packages/manager-tools/manager-forge-cli
1919
packages/manager-tools/manager-forge-cli/template
20+
packages/manager/apps/communication

.stylelintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ packages/manager-ui-kit
1010
packages/manager-wiki
1111
packages/manager-tools/manager-forge-cli
1212
packages/manager-tools/manager-forge-cli/template
13+
packages/manager/apps/communication
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Full adoption
2+
/*import { eslintSharedConfig } from '@ovh-ux/manager-static-analysis-kit';
3+
4+
export default eslintSharedConfig;
5+
*/
6+
7+
// Progressive adoption
8+
/*import { a11yEslintConfig } from '@ovh-ux/manager-static-analysis-kit/eslint/a11y';
9+
import {
10+
complexityJsxTsxConfig,
11+
complexityTsJsConfig,
12+
} from '@ovh-ux/manager-static-analysis-kit/eslint/complexity';
13+
import { cssEslintConfig } from '@ovh-ux/manager-static-analysis-kit/eslint/css';
14+
import { htmlEslintConfig } from '@ovh-ux/manager-static-analysis-kit/eslint/html';
15+
import { importEslintConfig } from '@ovh-ux/manager-static-analysis-kit/eslint/imports';
16+
import { javascriptEslintConfig } from '@ovh-ux/manager-static-analysis-kit/eslint/javascript';
17+
import { checkFileEslintConfig } from '@ovh-ux/manager-static-analysis-kit/eslint/naming-conventions';
18+
import { prettierEslintConfig } from '@ovh-ux/manager-static-analysis-kit/eslint/prettier';
19+
import { reactEslintConfig } from '@ovh-ux/manager-static-analysis-kit/eslint/react';
20+
import { tailwindJsxConfig } from '@ovh-ux/manager-static-analysis-kit/eslint/tailwind-jsx';
21+
import { tanStackQueryEslintConfig } from '@ovh-ux/manager-static-analysis-kit/eslint/tanstack';
22+
import { vitestEslintConfig } from '@ovh-ux/manager-static-analysis-kit/eslint/tests';
23+
import { typescriptEslintConfig } from '@ovh-ux/manager-static-analysis-kit/eslint/typescript';
24+
25+
// import { storybookEslintConfig } from '@ovh-ux/manager-static-analysis-kit/eslint/storybook';
26+
27+
export default [
28+
javascriptEslintConfig,
29+
typescriptEslintConfig,
30+
reactEslintConfig,
31+
a11yEslintConfig,
32+
htmlEslintConfig,
33+
tailwindJsxConfig,
34+
tanStackQueryEslintConfig,
35+
...importEslintConfig,
36+
...checkFileEslintConfig,
37+
vitestEslintConfig,
38+
prettierEslintConfig,
39+
complexityJsxTsxConfig,
40+
complexityTsJsConfig,
41+
{
42+
...cssEslintConfig,
43+
files: ['**\/*.css', '**\/*.scss'],
44+
},
45+
];*/
46+
47+
// Progressive and disable some rules
48+
/* import { typescriptEslintConfig } from '@ovh-ux/manager-static-analysis-kit/eslint/typescript';
49+
50+
export default [
51+
{
52+
...typescriptEslintConfig,
53+
rules: {
54+
...typescriptEslintConfig.rules,
55+
'@typescript-eslint/no-unsafe-return': 'off',
56+
'@typescript-eslint/no-unsafe-assignment': 'off',
57+
'@typescript-eslint/no-explicit-any': 'off',
58+
'@typescript-eslint/await-thenable': 'off'
59+
},
60+
},
61+
];
62+
*/
63+
64+
// Progressive and disable full rules
65+
import { typescriptEslintConfig } from '@ovh-ux/manager-static-analysis-kit/eslint/typescript';
66+
67+
export default [
68+
{
69+
...typescriptEslintConfig,
70+
rules: {},
71+
},
72+
];

packages/manager/apps/communication/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
"author": "OVH SAS",
1313
"scripts": {
1414
"build": "tsc && vite build",
15+
"build:strict": "tsc --project tsconfig.json && vite build",
1516
"coverage": "manager-test run --coverage",
1617
"lint": "eslint ./src",
18+
"lint:modern": "manager-lint --config eslint.config.mjs ./src",
19+
"lint:modern:fix": "manager-lint --fix --config eslint.config.mjs ./src",
1720
"start": "vite",
1821
"test": "manager-test run"
1922
},
@@ -26,6 +29,7 @@
2629
"@ovh-ux/manager-react-components": "^2.43.1",
2730
"@ovh-ux/manager-react-core-application": "^0.13.1",
2831
"@ovh-ux/manager-react-shell-client": "^0.11.2",
32+
"@ovh-ux/muk": "0.8.0",
2933
"@ovh-ux/request-tagger": "^0.4.1",
3034
"@ovhcloud/ods-components": "^18.6.2",
3135
"@ovhcloud/ods-themes": "^18.6.2",
@@ -45,6 +49,7 @@
4549
"zod": "^3.24.2"
4650
},
4751
"devDependencies": {
52+
"@ovh-ux/manager-static-analysis-kit": "*",
4853
"@ovh-ux/manager-tailwind-config": "^0.6.0",
4954
"@ovh-ux/manager-tests-setup": "^0.4.7",
5055
"@ovh-ux/manager-vite-config": "^0.15.0",

packages/manager/apps/communication/public/translations/common/Messages_fr_FR.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,10 @@
2525
"status_valid": "Actif",
2626
"status_to_validate": "En attente",
2727
"iam_display_content_message": "Vous ne disposez pas des permissions nécessaires pour afficher les données de cette ressource. Veuillez contacter votre administrateur.",
28-
"error_rate_limit_message": "Vous avez atteint la limite de requêtes. Veuillez réessayer plus tard."
28+
"error_rate_limit_message": "Vous avez atteint la limite de requêtes. Veuillez réessayer plus tard.",
29+
"guide_menu_header": "Guides",
30+
"guide_menu_start_guide": "Visite guidée",
31+
"guide_tab_communications": "Retrouvez ici l'ensemble des e-mails qu'OVHcloud vous a envoyés, sauf les demandes d'assistance.",
32+
"guide_tab_contacts": "Gérez ici votre liste de contacts et ajoutez des contacts pour les associer à vos paramètres d'envoi.",
33+
"guide_tab_settings": "Ajoutez et définissez vos règles d'envoi pour choisir quels contacts recevront les communications, en fonction des catégories et priorités sélectionnées."
2934
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useGuidedTour } from '@/hooks/useGuidedTour';
2+
import { useTranslation } from 'react-i18next';
3+
import { Button, BUTTON_SIZE, BUTTON_VARIANT, Icon, ICON_NAME, Popover, POPOVER_POSITION, PopoverContent, PopoverTrigger, Link as OdsLink } from '@ovh-ux/muk';
4+
5+
export const GuideMenu = () => {
6+
7+
const { start } = useGuidedTour();
8+
const { t } = useTranslation('common');
9+
10+
const onClickStartGuide = (e: React.MouseEvent<HTMLAnchorElement>) => {
11+
e.preventDefault();
12+
start();
13+
};
14+
15+
return (
16+
<Popover position={POPOVER_POSITION.bottom}>
17+
<PopoverTrigger asChild>
18+
<Button
19+
aria-label={t('guide_menu_header')}
20+
variant={BUTTON_VARIANT.ghost}
21+
size={BUTTON_SIZE.sm}
22+
>
23+
<>
24+
<Icon name={ICON_NAME.book} aria-hidden={true} />
25+
{t('guide_menu_header')}
26+
</>
27+
</Button>
28+
</PopoverTrigger>
29+
<PopoverContent>
30+
<div className="flex flex-col gap-2 py-1">
31+
<OdsLink onClick={onClickStartGuide}>
32+
{t('guide_menu_start_guide')}
33+
</OdsLink>
34+
</div>
35+
</PopoverContent>
36+
</Popover>
37+
);
38+
};
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { useEffect, useRef, useState, useCallback } from 'react';
2+
import { useGuidedTour } from '../../hooks/useGuidedTour/useGuidedTour.context';
3+
import { GuidePlacement } from '../../hooks/useGuidedTour/useGuidedTour.type';
4+
import { BADGE_COLOR, BADGE_SIZE, Button, BUTTON_SIZE, BUTTON_VARIANT } from '@ovh-ux/muk';
5+
import { OdsBadge } from '@ovhcloud/ods-components/react';
6+
import {
7+
computePopoverPosition,
8+
getTargetElementRect,
9+
updateStepHighlight,
10+
} from './GuidedTour.helpers';
11+
import { Card, Text } from '@ovh-ux/muk';
12+
import { useTranslation } from 'react-i18next';
13+
import { NAMESPACES } from '@ovh-ux/manager-common-translations';
14+
15+
const ELEMENT_OFFSET = 10;
16+
17+
export const GuidedTour = () => {
18+
const {
19+
steps,
20+
currentStep,
21+
isActive,
22+
goNext,
23+
goPrevious,
24+
isFirstStep,
25+
isLastStep,
26+
stop,
27+
} = useGuidedTour();
28+
29+
const { t } = useTranslation(NAMESPACES.ACTIONS);
30+
31+
const stepElementRef = useRef<HTMLDivElement>(null);
32+
const popoverRef = useRef<HTMLDivElement>(null);
33+
const [isPopoverVisible, setIsPopoverVisible] = useState(false);
34+
const [popoverPosition, setPopoverPosition] = useState<{
35+
top: number;
36+
left: number;
37+
} | null>(null);
38+
const [windowSize, setWindowSize] = useState({
39+
width: window.innerWidth,
40+
height: window.innerHeight,
41+
});
42+
43+
const currentStepData = steps[currentStep];
44+
45+
const calculateTargetBounds = useCallback(() => {
46+
if (!currentStepData || !stepElementRef.current) return;
47+
48+
const rect = getTargetElementRect(currentStepData.anchor);
49+
if (!rect) return;
50+
51+
updateStepHighlight(stepElementRef.current, rect, ELEMENT_OFFSET);
52+
53+
const placement = currentStepData.placement || GuidePlacement.Bottom;
54+
const popoverRect = popoverRef.current?.getBoundingClientRect();
55+
56+
const position = computePopoverPosition({
57+
rect,
58+
placement,
59+
popoverRect,
60+
});
61+
62+
setPopoverPosition(position);
63+
}, [currentStepData]);
64+
65+
const handleWindowResize = useCallback(() => {
66+
setWindowSize({
67+
width: window.innerWidth,
68+
height: window.innerHeight,
69+
});
70+
}, []);
71+
72+
useEffect(() => {
73+
if (!isActive || !currentStepData) {
74+
setIsPopoverVisible(false);
75+
return;
76+
}
77+
78+
let recalcTimer: NodeJS.Timeout | null = null;
79+
let rafId: number | null = null;
80+
let isScrolling = false;
81+
82+
// Optimized scroll handler
83+
const handleScroll = () => {
84+
if (!isScrolling) {
85+
isScrolling = true;
86+
rafId = requestAnimationFrame(() => {
87+
calculateTargetBounds();
88+
isScrolling = false;
89+
});
90+
}
91+
};
92+
93+
// Wait for DOM to update after navigation
94+
const timer = setTimeout(() => {
95+
calculateTargetBounds();
96+
setIsPopoverVisible(true);
97+
recalcTimer = setTimeout(() => {
98+
calculateTargetBounds();
99+
}, 50);
100+
}, 100);
101+
102+
window.addEventListener('resize', handleWindowResize);
103+
window.addEventListener('scroll', handleScroll, { passive: true, capture: true });
104+
document.addEventListener('scroll', handleScroll, { passive: true, capture: true });
105+
document.body.addEventListener('scroll', handleScroll, { passive: true, capture: true });
106+
107+
return () => {
108+
clearTimeout(timer);
109+
if (recalcTimer) {
110+
clearTimeout(recalcTimer);
111+
}
112+
if (rafId !== null) {
113+
cancelAnimationFrame(rafId);
114+
}
115+
window.removeEventListener('resize', handleWindowResize);
116+
window.removeEventListener('scroll', handleScroll, { capture: true });
117+
document.removeEventListener('scroll', handleScroll, { capture: true });
118+
document.body.removeEventListener('scroll', handleScroll, { capture: true });
119+
};
120+
}, [isActive, currentStep, currentStepData, calculateTargetBounds, handleWindowResize]);
121+
122+
useEffect(() => {
123+
if (isPopoverVisible) {
124+
calculateTargetBounds();
125+
}
126+
}, [isPopoverVisible, calculateTargetBounds]);
127+
128+
useEffect(() => {
129+
calculateTargetBounds();
130+
}, [windowSize, calculateTargetBounds]);
131+
132+
const handleNext = () => {
133+
if (currentStepData?.onAfterEnter) {
134+
currentStepData.onAfterEnter();
135+
}
136+
goNext();
137+
};
138+
139+
const handlePrevious = () => {
140+
goPrevious();
141+
};
142+
143+
const handleClose = () => {
144+
stop();
145+
};
146+
147+
if (!isActive || !currentStepData) {
148+
return null;
149+
}
150+
151+
return (
152+
<div className="fixed inset-0 z-[9999] pointer-events-none">
153+
<div className="absolute inset-0 pointer-events-auto" />
154+
<div
155+
ref={stepElementRef}
156+
className="absolute border-[3px] rounded shadow-[0_0_0_9999px_rgba(0,80,215,0.75),0_0_20px_rgba(0,80,215,0.75)] pointer-events-none z-[10000] transition-all duration-300"
157+
/>
158+
<Card
159+
ref={popoverRef}
160+
className={`absolute bg-white min-w-[300px] max-w-[450px] z-[10001] pointer-events-auto ${
161+
isPopoverVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2'
162+
} transition-all duration-300`}
163+
style={{
164+
display: isPopoverVisible ? 'block' : 'none',
165+
top: popoverPosition?.top ? `${popoverPosition.top}px` : 'auto',
166+
left: popoverPosition?.left ? `${popoverPosition.left}px` : 'auto',
167+
maxHeight: `${window.innerHeight - 32}px`,
168+
overflowY: 'auto',
169+
}}
170+
>
171+
<div className="p-5 relative flex flex-col max-h-full">
172+
<div className="flex justify-end">
173+
<OdsBadge
174+
color={BADGE_COLOR.information}
175+
label={`${currentStep + 1} / ${steps.length}`}
176+
size={BADGE_SIZE.sm}
177+
/>
178+
</div>
179+
<Text className="flex-1 min-h-min mb-5 flex-1">{currentStepData.text}</Text>
180+
<div className="flex flex-row gap-4 items-center">
181+
<div className="flex-grow">
182+
{!isFirstStep && (
183+
<Button
184+
variant={BUTTON_VARIANT.ghost}
185+
size={BUTTON_SIZE.sm}
186+
onClick={handlePrevious}
187+
>
188+
{t('previous')}
189+
</Button>
190+
)}
191+
</div>
192+
193+
{!isLastStep && <Button
194+
variant={BUTTON_VARIANT.outline}
195+
size={BUTTON_SIZE.sm}
196+
onClick={handleClose}
197+
>
198+
{t('close')}
199+
</Button>}
200+
<Button
201+
variant={BUTTON_VARIANT.default}
202+
size={BUTTON_SIZE.sm}
203+
onClick={handleNext}
204+
>
205+
{isLastStep ? t('end') : t('next')}
206+
</Button>
207+
</div>
208+
</div>
209+
</Card>
210+
</div>
211+
);
212+
};
213+
214+
export default GuidedTour;
215+
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const DEFAULT_POPOVER_WIDTH = 320;
2+
export const DEFAULT_POPOVER_HEIGHT = 150;
3+
export const POSITION_THRESHOLD = 1.5;
4+
export const DEFAULT_GAP = 12;
5+
export const VIEWPORT_PADDING = 16;

0 commit comments

Comments
 (0)