You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

IssueGuide.tsx 6.3KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2024 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. import { SpotlightTour, SpotlightTourStep } from 'design-system';
  21. import React, { useState } from 'react';
  22. import { FormattedMessage } from 'react-intl';
  23. import { CallBackProps } from 'react-joyride';
  24. import { dismissNotice } from '../../../api/users';
  25. import { CurrentUserContext } from '../../../app/components/current-user/CurrentUserContext';
  26. import DocLink from '../../../components/common/DocLink';
  27. import { SCREEN_POSITION_COMPUTE_DELAY } from '../../../components/common/ScreenPositionHelper';
  28. import { translate, translateWithParameters } from '../../../helpers/l10n';
  29. import { NoticeType } from '../../../types/users';
  30. interface Props {
  31. run: boolean;
  32. }
  33. const PLACEMENT_RIGHT = 'right';
  34. const SESSION_STORAGE_KEY = 'issueCleanCodeGuideStep';
  35. const EXTRA_DELAY = 50;
  36. export default function IssueGuide({ run }: Props) {
  37. const { currentUser, updateDismissedNotices } = React.useContext(CurrentUserContext);
  38. const [step, setStep] = useState(+(sessionStorage.getItem(SESSION_STORAGE_KEY) ?? 0));
  39. const canRun = currentUser.isLoggedIn && !currentUser.dismissedNotices[NoticeType.ISSUE_GUIDE];
  40. // IssueGuide can be called within context of a ScreenPositionHelper. When this happens,
  41. // React Floater (a lib used by React Joyride, which in turn is what powers SpotlightTour)
  42. // gets confused and cannot correctly position the first step. The only way around this is
  43. // to delay the rendering of the SpotlightTour until *after* ScreenPositionHelper has
  44. // recomputed its positioning. That's what this state + effect is about: if `run` is false,
  45. // it means we are not in a state to start running. This could either be because we really don't
  46. // want to start the tour at all, in which case `run` will remain false. OR, it means we are
  47. // waiting on something else (like ScreenPositionHelper), in which case `run` will turn true
  48. // later. We wait on the delay of ScreenPositionHelper + 50ms, and try again. If `run` is still
  49. // false, we don't start the tour. If `run` is now true, we start the tour.
  50. const [start, setStart] = React.useState(run);
  51. React.useEffect(() => {
  52. // Only trigger the timeout if start is false.
  53. if (!start && canRun) {
  54. setTimeout(() => {
  55. setStart(run);
  56. }, SCREEN_POSITION_COMPUTE_DELAY + EXTRA_DELAY);
  57. }
  58. }, [canRun, run, start]);
  59. React.useEffect(() => {
  60. if (start && canRun) {
  61. sessionStorage.setItem(SESSION_STORAGE_KEY, step.toString());
  62. }
  63. }, [step, start, canRun]);
  64. if (!start || !canRun) {
  65. return null;
  66. }
  67. const onToggle = (props: CallBackProps) => {
  68. switch (props.action) {
  69. case 'close':
  70. case 'skip':
  71. case 'reset':
  72. sessionStorage.removeItem(SESSION_STORAGE_KEY);
  73. dismissNotice(NoticeType.ISSUE_GUIDE)
  74. .then(() => {
  75. updateDismissedNotices(NoticeType.ISSUE_GUIDE, true);
  76. })
  77. .catch(() => {
  78. /* noop */
  79. });
  80. break;
  81. case 'next':
  82. if (props.lifecycle === 'complete') {
  83. setStep(step + 1);
  84. }
  85. break;
  86. case 'prev':
  87. if (props.lifecycle === 'complete') {
  88. setStep(step - 1);
  89. }
  90. break;
  91. default:
  92. break;
  93. }
  94. };
  95. const constructContent = (
  96. first: string,
  97. second: string,
  98. extraContent?: string | React.ReactNode,
  99. ) => (
  100. <>
  101. <span>{translate(first)}</span>
  102. <br />
  103. <br />
  104. <span>{translate(second)}</span>
  105. {extraContent ?? null}
  106. </>
  107. );
  108. const steps: SpotlightTourStep[] = [
  109. {
  110. target: '[data-guiding-id="issue-1"]',
  111. content: constructContent('guiding.issue_list.1.content.1', 'guiding.issue_list.1.content.2'),
  112. title: translate('guiding.issue_list.1.title'),
  113. placement: PLACEMENT_RIGHT,
  114. },
  115. {
  116. target: '[data-guiding-id="issue-2"]',
  117. content: constructContent('guiding.issue_list.2.content.1', 'guiding.issue_list.2.content.2'),
  118. title: translate('guiding.issue_list.2.title'),
  119. },
  120. {
  121. target: '[data-guiding-id="issue-3"]',
  122. content: constructContent('guiding.issue_list.3.content.1', 'guiding.issue_list.3.content.2'),
  123. title: translate('guiding.issue_list.3.title'),
  124. },
  125. {
  126. target: '[data-guiding-id="issue-4"]',
  127. content: constructContent(
  128. 'guiding.issue_list.4.content.1',
  129. 'guiding.issue_list.4.content.2',
  130. <ul className="sw-mt-2 sw-pl-5 sw-list-disc">
  131. <li>{translate('guiding.issue_list.4.content.list.1')}</li>
  132. <li>{translate('guiding.issue_list.4.content.list.2')}</li>
  133. <li>{translate('guiding.issue_list.4.content.list.3')}</li>
  134. </ul>,
  135. ),
  136. title: translate('guiding.issue_list.4.title'),
  137. },
  138. {
  139. target: '[data-guiding-id="issue-5"]',
  140. content: (
  141. <FormattedMessage
  142. id="guiding.issue_list.5.content"
  143. defaultMessage={translate('guiding.issue_list.5.content')}
  144. values={{
  145. link: (
  146. <DocLink to="/user-guide/clean-code/introduction" className="sw-capitalize">
  147. {translate('documentation')}
  148. </DocLink>
  149. ),
  150. }}
  151. />
  152. ),
  153. title: translate('guiding.issue_list.5.title'),
  154. },
  155. ];
  156. return (
  157. <SpotlightTour
  158. callback={onToggle}
  159. steps={steps}
  160. run={run}
  161. continuous
  162. stepIndex={step}
  163. skipLabel={translate('skip')}
  164. backLabel={translate('go_back')}
  165. closeLabel={translate('close')}
  166. nextLabel={translate('next')}
  167. stepXofYLabel={(x: number, y: number) => translateWithParameters('guiding.step_x_of_y', x, y)}
  168. />
  169. );
  170. }