From af82ae0255eacbf9e2497a53205eb9ae09407af2 Mon Sep 17 00:00:00 2001 From: Mathieu Suen Date: Mon, 6 May 2024 17:41:13 +0200 Subject: [PATCH] SONAR-22148 Spotlight tour for cayc in branch overview --- .../design-system/config/jest/CSSStub.js | 21 +++ server/sonar-web/design-system/jest.config.js | 2 +- server/sonar-web/design-system/package.json | 3 +- .../src/components/SpotlightTour.tsx | 27 +++- .../design-system/src/theme/light.ts | 1 + server/sonar-web/package.json | 4 +- .../__tests__/GlobalFooter-test.tsx | 4 +- .../branch-like/BranchLikeNavigation.tsx | 5 +- .../overview/branches/BranchMetaTopBar.tsx | 27 +++- .../branches/BranchOverviewRenderer.tsx | 77 +++++++++- .../overview/branches/CaycPromotionGuide.tsx | 134 ++++++++++++++++++ .../overview/branches/PromotedSection.tsx | 96 +++++++++++++ .../branches/QualityGateStatusHeader.tsx | 2 +- .../js/apps/overview/branches/ReplayTour.tsx | 73 ++++++++++ .../js/apps/overview/branches/TabsPanel.tsx | 6 +- .../branches/__tests__/BranchOverview-it.tsx | 81 +++++++++-- .../overview/components/LastAnalysisLabel.tsx | 2 +- .../components/__tests__/SystemApp-it.tsx | 2 +- .../js/components/tutorials/test-utils.ts | 2 +- server/sonar-web/src/main/js/types/users.ts | 1 + server/sonar-web/yarn.lock | 92 ++++++------ .../server/user/ws/DismissNoticeActionIT.java | 2 +- .../server/user/ws/DismissNoticeAction.java | 4 +- .../resources/org/sonar/l10n/core.properties | 26 ++++ 24 files changed, 620 insertions(+), 74 deletions(-) create mode 100644 server/sonar-web/design-system/config/jest/CSSStub.js create mode 100644 server/sonar-web/src/main/js/apps/overview/branches/CaycPromotionGuide.tsx create mode 100644 server/sonar-web/src/main/js/apps/overview/branches/PromotedSection.tsx create mode 100644 server/sonar-web/src/main/js/apps/overview/branches/ReplayTour.tsx diff --git a/server/sonar-web/design-system/config/jest/CSSStub.js b/server/sonar-web/design-system/config/jest/CSSStub.js new file mode 100644 index 00000000000..e787e07baed --- /dev/null +++ b/server/sonar-web/design-system/config/jest/CSSStub.js @@ -0,0 +1,21 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +module.exports = {}; + diff --git a/server/sonar-web/design-system/jest.config.js b/server/sonar-web/design-system/jest.config.js index 7201800d75c..1e30ea83586 100644 --- a/server/sonar-web/design-system/jest.config.js +++ b/server/sonar-web/design-system/jest.config.js @@ -43,7 +43,7 @@ module.exports = { moduleNameMapper: { '^.+\\.(md|jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '/config/jest/FileStub.js', - // '^.+\\.css$': '/config/jest/CSSStub.js', + '^.+\\.css$': '/config/jest/CSSStub.js', }, setupFiles: [ '/config/jest/SetupTestEnvironment.js', diff --git a/server/sonar-web/design-system/package.json b/server/sonar-web/design-system/package.json index e81329f12e0..fcb250ff299 100644 --- a/server/sonar-web/design-system/package.json +++ b/server/sonar-web/design-system/package.json @@ -23,6 +23,7 @@ "@babel/preset-typescript": "7.23.3", "@emotion/babel-plugin": "11.11.0", "@emotion/babel-plugin-jsx-pragmatic": "0.2.1", + "@sonarsource/echoes-react": "0.2.2", "@testing-library/dom": "9.3.4", "@testing-library/jest-dom": "6.4.2", "@testing-library/react": "14.2.1", @@ -76,7 +77,7 @@ "react-helmet-async": "2.0.4", "react-highlight-words": "0.20.0", "react-intl": "6.6.2", - "react-joyride": "2.7.2", + "react-joyride": "2.8.1", "react-modal": "3.16.1", "react-router-dom": "6.22.0", "react-select": "5.7.7", diff --git a/server/sonar-web/design-system/src/components/SpotlightTour.tsx b/server/sonar-web/design-system/src/components/SpotlightTour.tsx index 62bc70df15b..c5be29f5a48 100644 --- a/server/sonar-web/design-system/src/components/SpotlightTour.tsx +++ b/server/sonar-web/design-system/src/components/SpotlightTour.tsx @@ -19,6 +19,7 @@ */ import { keyframes } from '@emotion/react'; import styled from '@emotion/styled'; +import { LinkStandalone } from '@sonarsource/echoes-react'; import React from 'react'; import { useIntl } from 'react-intl'; import ReactJoyride, { @@ -26,6 +27,7 @@ import ReactJoyride, { Step as JoyrideStep, TooltipRenderProps, } from 'react-joyride'; +import { LinkProps } from 'react-router-dom'; import tw from 'twin.macro'; import { GLOBAL_POPUP_Z_INDEX, PopupZLevel, themeColor } from '../helpers'; import { findAnchor } from '../helpers/dom'; @@ -37,6 +39,8 @@ import { PopupWrapper } from './popups'; type Placement = 'left' | 'right' | 'top' | 'bottom' | 'center'; export interface SpotlightTourProps extends Omit { + actionLabel?: string; + actionPath?: LinkProps['to']; backLabel?: string; closeLabel?: string; nextLabel?: string; @@ -60,6 +64,8 @@ const DEFAULT_WIDTH = 315; const defultRect = new DOMRect(0, 0, 0, 0); function TooltipComponent({ + actionLabel, + actionPath, continuous, index, step, @@ -73,6 +79,8 @@ function TooltipComponent({ tooltipProps, width = DEFAULT_WIDTH, }: TooltipRenderProps & { + actionLabel?: string; + actionPath?: LinkProps['to']; step: SpotlightTourStep; stepXofYLabel: SpotlightTourProps['stepXofYLabel']; width?: number; @@ -154,6 +162,13 @@ function TooltipComponent({
{step.content}
+ + {actionLabel && actionPath && ( +
+ {actionLabel} +
+ )} +
{(stepXofYLabel || size > 1) && ( @@ -183,6 +198,8 @@ function TooltipComponent({ export function SpotlightTour(props: SpotlightTourProps) { const { + actionLabel, + actionPath, steps, skipLabel, backLabel, @@ -227,7 +244,15 @@ export function SpotlightTour(props: SpotlightTourProps) { }))} tooltipComponent={( tooltipProps: React.PropsWithChildren, - ) => } + ) => ( + + )} {...otherProps} /> ); diff --git a/server/sonar-web/design-system/src/theme/light.ts b/server/sonar-web/design-system/src/theme/light.ts index f5f33fcac32..4c218db9f3d 100644 --- a/server/sonar-web/design-system/src/theme/light.ts +++ b/server/sonar-web/design-system/src/theme/light.ts @@ -525,6 +525,7 @@ export const lightTheme = { projectCardInfo: COLORS.blueGrey[35], // overview + backgroundPromotedSection: secondary.light, overviewCardDefaultIcon: secondary.light, iconOverviewIssue: COLORS.blueGrey[400], overviewCardWarningIcon: COLORS.yellow[50], diff --git a/server/sonar-web/package.json b/server/sonar-web/package.json index 3261c94e24f..b95f40629a6 100644 --- a/server/sonar-web/package.json +++ b/server/sonar-web/package.json @@ -13,7 +13,7 @@ "@primer/octicons-react": "19.8.0", "@react-spring/rafz": "9.7.3", "@react-spring/web": "9.7.3", - "@sonarsource/echoes-react": "0.2.1", + "@sonarsource/echoes-react": "0.2.2", "@tanstack/react-query": "5.18.1", "axios": "1.6.7", "classnames": "2.5.1", @@ -38,7 +38,7 @@ "react-helmet-async": "2.0.4", "react-highlight-words": "0.20.0", "react-intl": "6.6.2", - "react-joyride": "2.7.2", + "react-joyride": "2.8.1", "react-modal": "3.16.1", "react-router-dom": "6.22.0", "react-select": "5.7.7", diff --git a/server/sonar-web/src/main/js/app/components/__tests__/GlobalFooter-test.tsx b/server/sonar-web/src/main/js/app/components/__tests__/GlobalFooter-test.tsx index f6dd26a1e96..d8ace2117cf 100644 --- a/server/sonar-web/src/main/js/app/components/__tests__/GlobalFooter-test.tsx +++ b/server/sonar-web/src/main/js/app/components/__tests__/GlobalFooter-test.tsx @@ -132,9 +132,9 @@ const ui = { pluginsLink: byRole('link', { name: 'opens_in_new_window footer.plugins' }), apiLink: byRole('link', { name: 'footer.web_api' }), ltaDocumentationLinkActive: byRole('link', { - name: `footer.version.status.active open_in_new_window`, + name: `footer.version.status.active open_in_new_tab`, }), ltaDocumentationLinkInactive: byRole('link', { - name: `footer.version.status.inactive open_in_new_window`, + name: `footer.version.status.inactive open_in_new_tab`, }), }; diff --git a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/BranchLikeNavigation.tsx b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/BranchLikeNavigation.tsx index 5fa6f577023..4e92f2c6107 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/BranchLikeNavigation.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/BranchLikeNavigation.tsx @@ -69,7 +69,10 @@ export function BranchLikeNavigation(props: BranchLikeNavigationProps) { return ( <> -
+
void; } -export default function BranchMetaTopBar({ branch, measures, component }: Readonly) { +export default function BranchMetaTopBar({ + branch, + measures, + component, + showTakeTheTourButton, + startTour, +}: Readonly) { const intl = useIntl(); const currentPage = getCurrentPage(component, branch) as HomePage; @@ -66,6 +77,18 @@ export default function BranchMetaTopBar({ branch, measures, component }: Readon )} + {showTakeTheTourButton && ( + + + + {translate('overview.promoted_section.button_primary')} + + + )}
); diff --git a/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx b/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx index e1194406c2e..6021e0ec732 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx @@ -25,30 +25,38 @@ import { PageContentFontWrapper, } from 'design-system'; import * as React from 'react'; +import { useState } from 'react'; import A11ySkipTarget from '~sonar-aligned/components/a11y/A11ySkipTarget'; import { useLocation, useRouter } from '~sonar-aligned/components/hoc/withRouter'; import { isPortfolioLike } from '~sonar-aligned/helpers/component'; import { ComponentQualifier } from '~sonar-aligned/types/component'; +import { CurrentUserContext } from '../../../app/components/current-user/CurrentUserContext'; import AnalysisMissingInfoMessage from '../../../components/shared/AnalysisMissingInfoMessage'; import { parseDate } from '../../../helpers/dates'; +import { translate } from '../../../helpers/l10n'; import { areCCTMeasuresComputed, isDiffMetric } from '../../../helpers/measures'; import { CodeScope } from '../../../helpers/urls'; +import { useDismissNoticeMutation } from '../../../queries/users'; import { ApplicationPeriod } from '../../../types/application'; import { Branch } from '../../../types/branch-like'; import { Analysis, GraphType, MeasureHistory } from '../../../types/project-activity'; import { QualityGateStatus } from '../../../types/quality-gates'; import { Component, MeasureEnhanced, Metric, Period, QualityGate } from '../../../types/types'; +import { NoticeType } from '../../../types/users'; import { AnalysisStatus } from '../components/AnalysisStatus'; import LastAnalysisLabel from '../components/LastAnalysisLabel'; import ActivityPanel from './ActivityPanel'; import BranchMetaTopBar from './BranchMetaTopBar'; +import CaycPromotionGuide from './CaycPromotionGuide'; import FirstAnalysisNextStepsNotif from './FirstAnalysisNextStepsNotif'; import MeasuresPanelNoNewCode from './MeasuresPanelNoNewCode'; import NewCodeMeasuresPanel from './NewCodeMeasuresPanel'; import NoCodeWarning from './NoCodeWarning'; import OverallCodeMeasuresPanel from './OverallCodeMeasuresPanel'; +import PromotedSection from './PromotedSection'; import QualityGatePanel from './QualityGatePanel'; import { QualityGateStatusTitle } from './QualityGateStatusTitle'; +import ReplayTourGuide from './ReplayTour'; import SonarLintPromotion from './SonarLintPromotion'; import { TabsPanel } from './TabsPanel'; @@ -96,6 +104,18 @@ export default function BranchOverviewRenderer(props: BranchOverviewRendererProp const { query } = useLocation(); const router = useRouter(); + const { currentUser } = React.useContext(CurrentUserContext); + + const { mutateAsync: dismissNotice } = useDismissNoticeMutation(); + + const [startTour, setStartTour] = useState(false); + const [tourCompleted, setTourCompleted] = useState(false); + const [showReplay, setShowReplay] = useState(false); + const [dismissedTour, setDismissedTour] = useState( + currentUser.isLoggedIn && + !!currentUser.dismissedNotices[NoticeType.ONBOARDING_CAYC_BRANCH_SUMMARY_GUIDE], + ); + const tab = query.codeScope === CodeScope.Overall ? CodeScope.Overall : CodeScope.New; const leakPeriod = component.qualifier === ComponentQualifier.Application ? appLeak : period; const isNewCodeTab = tab === CodeScope.New; @@ -126,6 +146,33 @@ export default function BranchOverviewRenderer(props: BranchOverviewRendererProp /> ); + const dismissPromotedSection = () => { + dismissNotice(NoticeType.ONBOARDING_CAYC_BRANCH_SUMMARY_GUIDE); + + setDismissedTour(true); + setShowReplay(true); + }; + + const closeTour = (action: string) => { + setStartTour(false); + if (action === 'skip' && !dismissedTour) { + dismissPromotedSection(); + } + + if (action === 'close' && !dismissedTour) { + dismissPromotedSection(); + setTourCompleted(true); + } + }; + + const startTourGuide = () => { + if (!isNewCodeTab) { + selectTab(CodeScope.New); + } + setShowReplay(false); + setStartTour(true); + }; + return ( <> + + {showReplay && ( + setShowReplay(false)} + run={showReplay} + tourCompleted={tourCompleted} + /> + )}
@@ -144,7 +199,27 @@ export default function BranchOverviewRenderer(props: BranchOverviewRendererProp
{branch && ( <> - + {currentUser.isLoggedIn && ( + + )} + + )} diff --git a/server/sonar-web/src/main/js/apps/overview/branches/CaycPromotionGuide.tsx b/server/sonar-web/src/main/js/apps/overview/branches/CaycPromotionGuide.tsx new file mode 100644 index 00000000000..c0a713d45fa --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/branches/CaycPromotionGuide.tsx @@ -0,0 +1,134 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { SpotlightTour, SpotlightTourStep } from 'design-system'; +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { translate, translateWithParameters } from '../../../helpers/l10n'; + +interface Props { + closeTour: (action: string) => void; + run: boolean; +} + +function CaycPromotionGuide(props: Readonly) { + const { run } = props; + const onToggle = ({ action, type }: { action: string; type: string }) => { + if (type === 'tour:end' && (action === 'close' || action === 'skip')) { + props.closeTour(action); + } + }; + + const constructContent = (first: string) =>

{translate(first)}

; + + const constructContentLastStep = (first: string, second: string, third: string) => ( + <> +

+ {translate('ide')}, + }} + /> +

+

+ {translate('pull_request.small')}, + }} + /> +

+

+ {translate('branch.small')}, + }} + /> +

+ + ); + + const steps: SpotlightTourStep[] = [ + { + disableScrolling: false, + disableOverlayClose: true, + target: '[data-spotlight-id="cayc-promotion-1"]', + content: constructContent('guiding.cayc_promotion.1.content.1'), + title: translate('guiding.cayc_promotion.1.title'), + placement: 'left', + }, + { + disableScrolling: true, + disableOverlayClose: true, + target: '[data-spotlight-id="cayc-promotion-2"]', + content: constructContent('guiding.cayc_promotion.2.content.1'), + title: translate('guiding.cayc_promotion.2.title'), + placement: 'left', + }, + { + disableScrolling: true, + disableOverlayClose: true, + target: '[data-spotlight-id="cayc-promotion-3"]', + content: constructContent('guiding.cayc_promotion.3.content.1'), + title: translate('guiding.cayc_promotion.3.title'), + placement: 'right', + }, + { + disableScrolling: true, + disableOverlayClose: true, + target: '[data-spotlight-id="cayc-promotion-4"]', + content: constructContentLastStep( + 'guiding.cayc_promotion.4.content.1', + 'guiding.cayc_promotion.4.content.2', + 'guiding.cayc_promotion.4.content.3', + ), + title: translate('guiding.cayc_promotion.4.title'), + placement: 'right', + spotlightPadding: 0, + }, + ]; + + return ( + translateWithParameters('guiding.step_x_of_y', x, y)} + steps={steps} + styles={{ + options: { + zIndex: 1000, + }, + }} + /> + ); +} + +export default CaycPromotionGuide; diff --git a/server/sonar-web/src/main/js/apps/overview/branches/PromotedSection.tsx b/server/sonar-web/src/main/js/apps/overview/branches/PromotedSection.tsx new file mode 100644 index 00000000000..6e8d4385ad3 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/branches/PromotedSection.tsx @@ -0,0 +1,96 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import styled from '@emotion/styled'; +import { IconX } from '@sonarsource/echoes-react'; +import { + ButtonPrimary, + ButtonSecondary, + InteractiveIcon, + themeBorder, + themeColor, +} from 'design-system'; +import React, { useState } from 'react'; +import { translate } from '../../../helpers/l10n'; + +interface Props { + content: string; + dismissed: boolean; + onDismiss: () => void; + onPrimaryButtonClick: () => void; + primaryButtonLabel: string; + secondaryButtonLabel: string; + title: string; +} + +export default function PromotedSection({ + content, + primaryButtonLabel, + secondaryButtonLabel, + title, + dismissed, + onDismiss, + onPrimaryButtonClick, +}: Readonly) { + const [display, setDisplay] = useState(!dismissed); + + const handlePrimaryButtonClick = () => { + setDisplay(false); + onPrimaryButtonClick(); + }; + + const handleDismiss = () => { + setDisplay(false); + onDismiss(); + }; + + if (!display) { + return null; + } + + return ( + +
+ {title} + +
+

{content}

+
+ + {primaryButtonLabel} + + {secondaryButtonLabel} +
+
+ ); +} + +const StyledWrapper = styled.div` + background-color: ${themeColor('backgroundPromotedSection')}; + border: ${themeBorder('default')}; +`; + +const StyledTitle = styled.p` + color: ${themeColor('primary')}; +`; diff --git a/server/sonar-web/src/main/js/apps/overview/branches/QualityGateStatusHeader.tsx b/server/sonar-web/src/main/js/apps/overview/branches/QualityGateStatusHeader.tsx index 2188c540a8e..c5a82ca02df 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/QualityGateStatusHeader.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/QualityGateStatusHeader.tsx @@ -33,7 +33,7 @@ export default function QualityGateStatusHeader(props: Props) { const intl = useIntl(); return ( -
+
{translate('metric.level', status)} diff --git a/server/sonar-web/src/main/js/apps/overview/branches/ReplayTour.tsx b/server/sonar-web/src/main/js/apps/overview/branches/ReplayTour.tsx new file mode 100644 index 00000000000..5ea043a93a1 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/branches/ReplayTour.tsx @@ -0,0 +1,73 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { SpotlightTour, SpotlightTourStep } from 'design-system'; +import React from 'react'; +import { useDocUrl } from '../../../helpers/docs'; +import { translate } from '../../../helpers/l10n'; + +interface Props { + closeTour: () => void; + run: boolean; + tourCompleted: boolean; +} + +export default function ReplayTourGuide({ run, closeTour, tourCompleted }: Readonly) { + const onToggle = ({ action }: { action: string }) => { + if (action === 'skip' || action === 'close') { + closeTour(); + } + }; + + const constructContent = (first: string) =>

{translate(first)}

; + + const docUrl = useDocUrl('improving/clean-as-you-code/'); + + const steps: SpotlightTourStep[] = [ + { + disableOverlayClose: true, + target: '[data-spotlight-id="take-tour-1"]', + content: constructContent('guiding.replay_tour_button.1.content'), + title: tourCompleted + ? translate('guiding.replay_tour_button.tour_completed.1.title') + : translate('guiding.replay_tour_button.1.title'), + placement: 'left', + }, + ]; + + return ( +
+ ''} + steps={steps} + width={350} + /> +
+ ); +} diff --git a/server/sonar-web/src/main/js/apps/overview/branches/TabsPanel.tsx b/server/sonar-web/src/main/js/apps/overview/branches/TabsPanel.tsx index 0e8a0c68b55..c699725ca81 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/TabsPanel.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/TabsPanel.tsx @@ -119,7 +119,11 @@ export function TabsPanel(props: React.PropsWithChildren) { ]; return ( -
+
{loading ? (
diff --git a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverview-it.tsx b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverview-it.tsx index 8c996f697c9..7783cd67bea 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverview-it.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverview-it.tsx @@ -29,10 +29,12 @@ import { MeasuresServiceMock } from '../../../../api/mocks/MeasuresServiceMock'; import { ProjectActivityServiceMock } from '../../../../api/mocks/ProjectActivityServiceMock'; import { QualityGatesServiceMock } from '../../../../api/mocks/QualityGatesServiceMock'; import { TimeMachineServiceMock } from '../../../../api/mocks/TimeMachineServiceMock'; +import UsersServiceMock from '../../../../api/mocks/UsersServiceMock'; import { PARENT_COMPONENT_KEY } from '../../../../api/mocks/data/ids'; import { getProjectActivity } from '../../../../api/projectActivity'; import { getQualityGateProjectStatus } from '../../../../api/quality-gates'; import CurrentUserContextProvider from '../../../../app/components/current-user/CurrentUserContextProvider'; +import { Header } from '../../../../app/components/nav/component/Header'; import { parseDate } from '../../../../helpers/dates'; import { mockMainBranch } from '../../../../helpers/mocks/branch-like'; import { mockComponent } from '../../../../helpers/mocks/component'; @@ -51,6 +53,7 @@ let branchesHandler: BranchesServiceMock; let measuresHandler: MeasuresServiceMock; let applicationHandler: ApplicationServiceMock; let projectActivityHandler: ProjectActivityServiceMock; +let usersHandler: UsersServiceMock; let timeMarchineHandler: TimeMachineServiceMock; let qualityGatesHandler: QualityGatesServiceMock; @@ -59,6 +62,7 @@ beforeAll(() => { measuresHandler = new MeasuresServiceMock(); applicationHandler = new ApplicationServiceMock(); projectActivityHandler = new ProjectActivityServiceMock(); + usersHandler = new UsersServiceMock(); projectActivityHandler.setAnalysesList([ mockAnalysis({ key: 'a1', detectedCI: 'Cirrus CI' }), mockAnalysis({ key: 'a2' }), @@ -116,6 +120,7 @@ afterEach(() => { measuresHandler.reset(); applicationHandler.reset(); projectActivityHandler.reset(); + usersHandler.reset(); timeMarchineHandler.reset(); qualityGatesHandler.reset(); almHandler.reset(); @@ -453,6 +458,61 @@ describe('project overview', () => { expect(await screen.findByText('overview.missing_project_dataTRK')).toBeInTheDocument(); }, ); + + it('should dismiss CaYC promoted section', async () => { + qualityGatesHandler.setQualityGateProjectStatus( + mockQualityGateProjectStatus({ + status: 'OK', + }), + ); + const { user } = getPageObjects(); + renderBranchOverview(); + + // Meta info + expect(await byText('overview.promoted_section.title').find()).toBeInTheDocument(); + + await user.click( + byRole('button', { name: 'overview.promoted_section.button_secondary' }).get(), + ); + + expect(byText('overview.promoted_section.title').query()).not.toBeInTheDocument(); + + expect(byText('guiding.replay_tour_button.1.title').get()).toBeInTheDocument(); + }); + + it('should show CaYC tour', async () => { + qualityGatesHandler.setQualityGateProjectStatus( + mockQualityGateProjectStatus({ + status: 'OK', + }), + ); + const { user } = getPageObjects(); + renderBranchOverview(); + + expect(await byText('overview.promoted_section.title').find()).toBeInTheDocument(); + + await user.click(byRole('button', { name: 'overview.promoted_section.button_primary' }).get()); + + expect(byText('overview.promoted_section.title').query()).not.toBeInTheDocument(); + + expect(await byText('guiding.cayc_promotion.1.title').find()).toBeInTheDocument(); + + await user.click(byRole('button', { name: 'next' }).get()); + + expect(byText('guiding.cayc_promotion.2.title').get()).toBeInTheDocument(); + + await user.click(byRole('button', { name: 'next' }).get()); + + expect(byText('guiding.cayc_promotion.3.title').get()).toBeInTheDocument(); + + await user.click(await byRole('button', { name: 'next' }).find()); + + expect(byText('guiding.cayc_promotion.4.title').get()).toBeInTheDocument(); + + await user.click(byRole('button', { name: 'complete' }).get()); + + expect(byText('guiding.replay_tour_button.tour_completed.1.title').get()).toBeInTheDocument(); + }); }); describe('application overview', () => { @@ -716,18 +776,17 @@ it.each([ ); function renderBranchOverview(props: Partial = {}) { + const user = mockLoggedInUser(); + const component = mockComponent({ + breadcrumbs: [mockComponent({ key: 'foo' })], + key: 'foo', + name: 'Foo', + version: 'version-1.0', + }); return renderComponent( - - + +
+ , ); } diff --git a/server/sonar-web/src/main/js/apps/overview/components/LastAnalysisLabel.tsx b/server/sonar-web/src/main/js/apps/overview/components/LastAnalysisLabel.tsx index 5fcb961d0f3..22bd24d7b98 100644 --- a/server/sonar-web/src/main/js/apps/overview/components/LastAnalysisLabel.tsx +++ b/server/sonar-web/src/main/js/apps/overview/components/LastAnalysisLabel.tsx @@ -30,7 +30,7 @@ export default function LastAnalysisLabel({ analysisDate }: Readonly) { const intl = useIntl(); return analysisDate ? ( - + {intl.formatMessage( { id: 'overview.last_analysis_x', diff --git a/server/sonar-web/src/main/js/apps/system/components/__tests__/SystemApp-it.tsx b/server/sonar-web/src/main/js/apps/system/components/__tests__/SystemApp-it.tsx index 20baab75062..2c04a93b43a 100644 --- a/server/sonar-web/src/main/js/apps/system/components/__tests__/SystemApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/system/components/__tests__/SystemApp-it.tsx @@ -151,7 +151,7 @@ function getPageObjects() { versionLabel: (version?: string) => version ? byText(/footer\.version\s*(\d.\d)/) : byText(/footer\.version/), ltaDocumentationLinkActive: byRole('link', { - name: `footer.version.status.active open_in_new_window`, + name: `footer.version.status.active open_in_new_tab`, }), }; diff --git a/server/sonar-web/src/main/js/components/tutorials/test-utils.ts b/server/sonar-web/src/main/js/components/tutorials/test-utils.ts index 14195ed84c0..591b1e8e12f 100644 --- a/server/sonar-web/src/main/js/components/tutorials/test-utils.ts +++ b/server/sonar-web/src/main/js/components/tutorials/test-utils.ts @@ -50,7 +50,7 @@ export function getCommonNodes(ci: TutorialModes) { linkToRepo: byRole('link', { name: `onboarding.tutorial.with.${CI_TRANSLATE_MAP[ci]}.${ ci === TutorialModes.GitHubActions ? 'secret' : 'variables' - }.intro.link open_in_new_window`, + }.intro.link open_in_new_tab`, }), allSetSentence: byText('onboarding.tutorial.ci_outro.done'), }; diff --git a/server/sonar-web/src/main/js/types/users.ts b/server/sonar-web/src/main/js/types/users.ts index 1e6078b05ff..e58243d7a79 100644 --- a/server/sonar-web/src/main/js/types/users.ts +++ b/server/sonar-web/src/main/js/types/users.ts @@ -36,6 +36,7 @@ export enum NoticeType { ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE = 'issueNewIssueStatusAndTransitionGuide', QG_CAYC_CONDITIONS_SIMPLIFICATION = 'qualityGateCaYCConditionsSimplification', OVERVIEW_ZERO_NEW_ISSUES_SIMPLIFICATION = 'overviewZeroNewIssuesSimplification', + ONBOARDING_CAYC_BRANCH_SUMMARY_GUIDE = 'onboardingDismissCaycBranchSummaryGuide', } export interface LoggedInUser extends CurrentUser, UserActive { diff --git a/server/sonar-web/yarn.lock b/server/sonar-web/yarn.lock index 146903e68e0..2ead1c25259 100644 --- a/server/sonar-web/yarn.lock +++ b/server/sonar-web/yarn.lock @@ -3377,25 +3377,6 @@ __metadata: languageName: node linkType: hard -"@gilbarbara/helpers@npm:^0.9.0": - version: 0.9.2 - resolution: "@gilbarbara/helpers@npm:0.9.2" - dependencies: - "@gilbarbara/types": "npm:^0.2.2" - is-lite: "npm:^1.2.1" - checksum: 10/17a111aea44ce5368413042974d8d890f48244becb50bb51c9ba75f7c0dd6d3e918d2d2fbba445a9188993c2ab11a2ba72ed0a477219621a37a80b2998751bb9 - languageName: node - linkType: hard - -"@gilbarbara/types@npm:^0.2.2": - version: 0.2.2 - resolution: "@gilbarbara/types@npm:0.2.2" - dependencies: - type-fest: "npm:^4.1.0" - checksum: 10/fb71d2e577a48b68b2205146c4cc6180d4e1d175df8b37de47e7581feaeb68ff32918dade9ddb94627755f0d8270727ef7209a9b238bc3af79dc83493b14da5a - languageName: node - linkType: hard - "@highlightjs/cdn-assets@npm:^11.9.0": version: 11.9.0 resolution: "@highlightjs/cdn-assets@npm:11.9.0" @@ -3820,6 +3801,13 @@ __metadata: languageName: node linkType: hard +"@material-symbols/font-400@npm:0.17.2": + version: 0.17.2 + resolution: "@material-symbols/font-400@npm:0.17.2" + checksum: 10/f3dae732c3d16a1dcc4310f045608cd422e32e1b158189a546c2aeca3af449d3cb6d952a096caf267a1646f4b4ab98a19348735e5b39b60841e04c5a8b96339b + languageName: node + linkType: hard + "@microsoft/api-extractor-model@npm:7.28.3": version: 7.28.3 resolution: "@microsoft/api-extractor-model@npm:7.28.3" @@ -4364,6 +4352,26 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-visually-hidden@npm:1.0.3": + version: 1.0.3 + resolution: "@radix-ui/react-visually-hidden@npm:1.0.3" + dependencies: + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/react-primitive": "npm:1.0.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/2e9d0c8253f97e7d6ffb2e52a5cfd40ba719f813b39c3e2e42c496d54408abd09ef66b5aec4af9b8ab0553215e32452a5d0934597a49c51dd90dc39181ed0d57 + languageName: node + linkType: hard + "@react-spring/animated@npm:~9.7.3": version: 9.7.3 resolution: "@react-spring/animated@npm:9.7.3" @@ -4533,13 +4541,14 @@ __metadata: languageName: node linkType: hard -"@sonarsource/echoes-react@npm:0.2.1": - version: 0.2.1 - resolution: "@sonarsource/echoes-react@npm:0.2.1" +"@sonarsource/echoes-react@npm:0.2.2": + version: 0.2.2 + resolution: "@sonarsource/echoes-react@npm:0.2.2" dependencies: + "@material-symbols/font-400": "npm:0.17.2" "@radix-ui/react-checkbox": "npm:1.0.4" "@radix-ui/react-radio-group": "npm:1.1.3" - material-symbols: "npm:0.17.0" + "@radix-ui/react-visually-hidden": "npm:1.0.3" peerDependencies: "@emotion/react": ^11.0.0 "@emotion/styled": ^11.0.0 @@ -4547,7 +4556,7 @@ __metadata: react-dom: ^17.0.0 || ^18.0.0 react-intl: ^6.0.0 react-router-dom: ^6.0.0 - checksum: 10/dccb03e27d0ff13d32923255b984807fce119a212728eb69cd16019a927e1014c07c7facef91e9306f270d0929b2b36ee03a452b99ca53d51ce5434c24fc6951 + checksum: 10/ec5fed2ac473028ca489a8445190c5585ff7e41d9cad0221b315260647307c1c91d59efe0f7b708d099ca24d7a9005dcde49d91f1408d26d7c33ad6a8a80578a languageName: node linkType: hard @@ -5749,7 +5758,7 @@ __metadata: "@primer/octicons-react": "npm:19.8.0" "@react-spring/rafz": "npm:9.7.3" "@react-spring/web": "npm:9.7.3" - "@sonarsource/echoes-react": "npm:0.2.1" + "@sonarsource/echoes-react": "npm:0.2.2" "@swc/core": "npm:1.4.0" "@swc/jest": "npm:0.2.36" "@tanstack/react-query": "npm:5.18.1" @@ -5836,7 +5845,7 @@ __metadata: react-helmet-async: "npm:2.0.4" react-highlight-words: "npm:0.20.0" react-intl: "npm:6.6.2" - react-joyride: "npm:2.7.2" + react-joyride: "npm:2.8.1" react-modal: "npm:3.16.1" react-router-dom: "npm:6.22.0" react-select: "npm:5.7.7" @@ -7676,6 +7685,7 @@ __metadata: "@babel/preset-typescript": "npm:7.23.3" "@emotion/babel-plugin": "npm:11.11.0" "@emotion/babel-plugin-jsx-pragmatic": "npm:0.2.1" + "@sonarsource/echoes-react": "npm:0.2.2" "@testing-library/dom": "npm:9.3.4" "@testing-library/jest-dom": "npm:6.4.2" "@testing-library/react": "npm:14.2.1" @@ -7733,7 +7743,7 @@ __metadata: react-helmet-async: 2.0.4 react-highlight-words: 0.20.0 react-intl: 6.6.2 - react-joyride: 2.7.2 + react-joyride: 2.8.1 react-modal: 3.16.1 react-router-dom: 6.22.0 react-select: 5.7.7 @@ -11701,13 +11711,6 @@ __metadata: languageName: node linkType: hard -"material-symbols@npm:0.17.0": - version: 0.17.0 - resolution: "material-symbols@npm:0.17.0" - checksum: 10/d432e18203d38b83a645783b03ac4b321d30cd6a8bec1aae5e47fccf4340387d4f7bc703d3aebb5a25639c316bb04f2027518e592869361d8bfbbd46ca8c9835 - languageName: node - linkType: hard - "memoize-one@npm:^4.0.0": version: 4.0.3 resolution: "memoize-one@npm:4.0.3" @@ -13112,26 +13115,25 @@ __metadata: languageName: node linkType: hard -"react-joyride@npm:2.7.2": - version: 2.7.2 - resolution: "react-joyride@npm:2.7.2" +"react-joyride@npm:2.8.1": + version: 2.8.1 + resolution: "react-joyride@npm:2.8.1" dependencies: "@gilbarbara/deep-equal": "npm:^0.3.1" - "@gilbarbara/helpers": "npm:^0.9.0" deep-diff: "npm:^1.0.2" deepmerge: "npm:^4.3.1" - is-lite: "npm:^1.2.0" + is-lite: "npm:^1.2.1" react-floater: "npm:^0.7.9" react-innertext: "npm:^1.1.5" react-is: "npm:^16.13.1" scroll: "npm:^3.0.1" scrollparent: "npm:^2.1.0" tree-changes: "npm:^0.11.2" - type-fest: "npm:^4.8.3" + type-fest: "npm:^4.15.0" peerDependencies: react: 15 - 18 react-dom: 15 - 18 - checksum: 10/1eb93d7edcfd662bc9a942b62430b5e92bf396002586d2c807dab5b5d599805209ee854ea1adb48d9a7855aada8dba6872be76ff6f2448820a42ca4c0be1661a + checksum: 10/55ff023104f708c3d4c17e2dcc27e3b54a268872b0baf60bd57ee41971e6e8a9e1d4e4c74d4dec084ec4b69eb21edcc985ef4979611a775327e856e03d1335d3 languageName: node linkType: hard @@ -14800,10 +14802,10 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:^4.1.0, type-fest@npm:^4.8.3": - version: 4.10.2 - resolution: "type-fest@npm:4.10.2" - checksum: 10/2b1ad1270d9fabeeb506ba831d513caeb05bfc852e5e012511d785ce9dc68d773fe0a42bddf857a362c7f3406244809c5b8a698b743bb7617d4a8c470672087f +"type-fest@npm:^4.15.0": + version: 4.18.2 + resolution: "type-fest@npm:4.18.2" + checksum: 10/2c176de28384a247fac1503165774e874c15ac39434a775f32ecda3aef5a0cefcfa2f5fb670c3da1f81cf773c355999154078c8d9657db19b65de78334b27933 languageName: node linkType: hard diff --git a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DismissNoticeActionIT.java b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DismissNoticeActionIT.java index ffd1c2038c6..41d34bd5a07 100644 --- a/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DismissNoticeActionIT.java +++ b/server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/DismissNoticeActionIT.java @@ -79,7 +79,7 @@ public class DismissNoticeActionIT { .isInstanceOf(IllegalArgumentException.class) .hasMessage( "Value of parameter 'notice' (not_supported_value) must be one of: [educationPrinciples, sonarlintAd, issueCleanCodeGuide, qualityGateCaYCConditionsSimplification, " + - "overviewZeroNewIssuesSimplification, issueNewIssueStatusAndTransitionGuide]"); + "overviewZeroNewIssuesSimplification, issueNewIssueStatusAndTransitionGuide, onboardingDismissCaycBranchSummaryGuide]"); } @Test diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DismissNoticeAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DismissNoticeAction.java index 19b24874538..a064114abd5 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DismissNoticeAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DismissNoticeAction.java @@ -40,9 +40,10 @@ public class DismissNoticeAction implements UsersWsAction { private static final String QUALITY_GATE_CAYC_CONDITIONS_SIMPLIFICATION = "qualityGateCaYCConditionsSimplification"; private static final String OVERVIEW_ZERO_NEW_ISSUES_SIMPLIFICATION = "overviewZeroNewIssuesSimplification"; private static final String ISSUE_NEW_ISSUE_STATUS_AND_TRANSITION_GUIDE = "issueNewIssueStatusAndTransitionGuide"; + private static final String ONBOARDING_CAYC_BRANCH_SUMMARY_GUIDE = "onboardingDismissCaycBranchSummaryGuide"; protected static final List AVAILABLE_NOTICE_KEYS = List.of(EDUCATION_PRINCIPLES, SONARLINT_AD, ISSUE_CLEAN_CODE_GUIDE, QUALITY_GATE_CAYC_CONDITIONS_SIMPLIFICATION, - OVERVIEW_ZERO_NEW_ISSUES_SIMPLIFICATION, ISSUE_NEW_ISSUE_STATUS_AND_TRANSITION_GUIDE); + OVERVIEW_ZERO_NEW_ISSUES_SIMPLIFICATION, ISSUE_NEW_ISSUE_STATUS_AND_TRANSITION_GUIDE, ONBOARDING_CAYC_BRANCH_SUMMARY_GUIDE); public static final String USER_DISMISS_CONSTANT = "user.dismissedNotices."; public static final String SUPPORT_FOR_NEW_NOTICE_MESSAGE = "Support for new notice '%s' was added."; @@ -58,6 +59,7 @@ public class DismissNoticeAction implements UsersWsAction { public void define(WebService.NewController context) { WebService.NewAction action = context.createAction("dismiss_notice") .setDescription("Dismiss a notice for the current user. Silently ignore if the notice is already dismissed.") + .setChangelog(new Change("10.6", SUPPORT_FOR_NEW_NOTICE_MESSAGE.formatted(ONBOARDING_CAYC_BRANCH_SUMMARY_GUIDE))) .setChangelog(new Change("10.4", SUPPORT_FOR_NEW_NOTICE_MESSAGE.formatted(ISSUE_NEW_ISSUE_STATUS_AND_TRANSITION_GUIDE))) .setChangelog(new Change("10.3", SUPPORT_FOR_NEW_NOTICE_MESSAGE.formatted(QUALITY_GATE_CAYC_CONDITIONS_SIMPLIFICATION))) .setChangelog(new Change("10.2", SUPPORT_FOR_NEW_NOTICE_MESSAGE.formatted(ISSUE_CLEAN_CODE_GUIDE))) diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 6f05b21e39b..46eb48c9a60 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -30,6 +30,7 @@ beta=BETA blocker=Blocker bold=Bold branch=Branch +branch.small=branch breadcrumbs=Breadcrumbs expand_breadcrumbs=Expand breadcrumbs by_=by @@ -52,6 +53,7 @@ code=Code color=Color collapse_all=Collapse all compare=Compare +complete=Complete component=Component configure=Configure confirm=Confirm @@ -104,6 +106,7 @@ from=From global=Global github=GitHub go_back=Go back +got_it=Got it help=Help here=here hide=Hide @@ -118,6 +121,7 @@ language=Language last_analysis=Last Analysis learn_more=Learn More learn_more_x=Learn More: {link} +learn_more.clean_code=Learn more: Clean as You Code library=Library line_number=Line Number links=Links @@ -170,6 +174,7 @@ password=Password path=Path permalink=Permanent Link plugin=Plugin +previous=Previous previous_=previous previous_month_x=previous month {month} project=Project @@ -179,6 +184,7 @@ projects_=project(s) x_projects_={0} project(s) project_plural=projects projects_management=Projects Management +pull_request.small=pull request quality_profile=Quality Profile raw=Raw recent_history=Recent History @@ -4071,6 +4077,26 @@ overview.quality_profiles_update_after_sq_upgrade.link=See more details overview.activity.variations.new_analysis=New analysis: overview.activity.variations.first_analysis=First analysis: +overview.promoted_section.title=Struggling with too many issues? Discover ‘Clean as You Code’! +overview.promoted_section.content=Learn how to improve your code base by cleaning only new code. +overview.promoted_section.button_primary=Take the Tour +overview.promoted_section.button_secondary=Not now +overview.promoted_section.button_tooltip=Learn how to improve your code base by cleaning only new code. + +guiding.cayc_promotion.1.title=The power of new code +guiding.cayc_promotion.1.content.1=Cleaning only new code is easy and guarantees no debt will be added. As you change old code, it also gets cleaner over time. We call this ‘Clean as You Code’. +guiding.cayc_promotion.2.title=Define your new code +guiding.cayc_promotion.2.content.1=Your team decides when a new code period for your project should start, for example, each time a project is released. +guiding.cayc_promotion.3.title=Green is clean +guiding.cayc_promotion.3.content.1=Quality Gate Status tells you if your new code is clean or not. Keep it green as often as possible, and your project will always be production-ready. +guiding.cayc_promotion.4.title=Clean at all levels +guiding.cayc_promotion.4.content.1=With SonarLint, clean code as you write it in your {value}. +guiding.cayc_promotion.4.content.2=When a feature is ready, analyze your {value} and make sure no issue is missed. +guiding.cayc_promotion.4.content.3=Finally, rely on a thorough {value} analysis to ensure the new code is clean. +guiding.replay_tour_button.1.title=Replay tour +guiding.replay_tour_button.tour_completed.1.title=Tour complete! +guiding.replay_tour_button.1.content=You can replay this product tour any time here. + #------------------------------------------------------------------------------ # -- 2.39.5