From: Jeremy Davis Date: Wed, 22 Feb 2023 15:18:48 +0000 (+0100) Subject: SONAR-18524 New Main App bar X-Git-Tag: 10.0.0.68432~156 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=b33a7cd2193a47f90b22568dd0d58f404bc5f6d7;p=sonarqube.git SONAR-18524 New Main App bar --- diff --git a/.cirrus.yml b/.cirrus.yml index 0c5d418fe89..9f0cddeecba 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -144,6 +144,7 @@ eslint_report_cache_template: &ESLINT_REPORT_CACHE_TEMPLATE eslint_report_cache: folders: - server/sonar-web/eslint-report/ + - server/sonar-web/design-system/eslint-report/ - private/core-extension-securityreport/eslint-report/ - private/core-extension-license/eslint-report/ - private/core-extension-enterprise-server/eslint-report/ @@ -154,6 +155,7 @@ jest_report_cache_template: &JEST_REPORT_CACHE_TEMPLATE jest_report_cache: folders: - server/sonar-web/coverage/ + - server/sonar-web/design-system/coverage/ - private/core-extension-securityreport/coverage/ - private/core-extension-license/coverage/ - private/core-extension-enterprise-server/coverage/ diff --git a/server/sonar-web/build.gradle b/server/sonar-web/build.gradle index 77062307f76..c588b6e7bc1 100644 --- a/server/sonar-web/build.gradle +++ b/server/sonar-web/build.gradle @@ -31,7 +31,7 @@ task yarn_run(type: Exec) { ['config', 'public', 'scripts', 'src'].each { inputs.dir(it).withPathSensitivity(PathSensitivity.RELATIVE) } - ['package.json', 'tsconfig.json', 'yarn.lock'].each { + ['package.json', 'tsconfig.json', 'yarn.lock', 'tailwind.config.js', 'tailwind.base.config.js'].each { inputs.file(it).withPathSensitivity(PathSensitivity.RELATIVE) } outputs.dir(webappDir) diff --git a/server/sonar-web/config/jest/SetupTheme.js b/server/sonar-web/config/jest/SetupTheme.js new file mode 100644 index 00000000000..55c454899b6 --- /dev/null +++ b/server/sonar-web/config/jest/SetupTheme.js @@ -0,0 +1,26 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { ThemeContext } from '@emotion/react'; +import { lightTheme } from 'design-system'; + +// Hack : override the default value of the context used for theme by emotion +// This allows tests to get the theme value without specifiying a theme provider +ThemeContext['_currentValue'] = lightTheme; +ThemeContext['_currentValue2'] = lightTheme; diff --git a/server/sonar-web/design-system/babel.config.js b/server/sonar-web/design-system/babel.config.js index 039a97f749d..1066ebdbecc 100644 --- a/server/sonar-web/design-system/babel.config.js +++ b/server/sonar-web/design-system/babel.config.js @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -export default { +module.exports = { plugins: [ 'babel-plugin-macros', [ @@ -30,5 +30,6 @@ export default { }, ], ['@babel/plugin-transform-react-jsx', { pragma: '__cssprop' }, 'twin.macro'], + '@emotion', ], }; diff --git a/server/sonar-web/design-system/build.gradle b/server/sonar-web/design-system/build.gradle new file mode 100644 index 00000000000..24e1bbdc446 --- /dev/null +++ b/server/sonar-web/design-system/build.gradle @@ -0,0 +1,40 @@ +sonar { + properties { + property 'sonar.projectName', "${projectTitle} :: Web :: Design System" + property "sonar.sources", "src" + property "sonar.exclusions", "src/**/__tests__/**,src/types/**,src/@types/**,src/helpers/testUtils.tsx" + property "sonar.tests", "src" + property "sonar.test.inclusions", "src/**/__tests__/**" + property "sonar.javascript.lcov.reportPaths", "./coverage/lcov.info" + } +} + +task "yarn_validate-ci"(type: Exec) { + dependsOn ":server:sonar-web:yarn_design-system" + + inputs.dir('src') + + ['package.json', '../yarn.lock', 'jest.config.js'].each { + inputs.file(it).withPathSensitivity(PathSensitivity.RELATIVE) + } + + outputs.dir('coverage') + outputs.cacheIf { true } + + commandLine osAdaptiveCommand(['npm', 'run', 'validate-ci']) +} + +task "yarn_lint-report-ci"(type: Exec) { + dependsOn ":server:sonar-web:yarn_design-system" + + ['src'].each { + inputs.dir(it) + } + ['package.json', '../yarn.lock', 'tsconfig.json', '.eslintrc'].each { + inputs.file(it) + } + outputs.dir('eslint-report') + outputs.cacheIf { true } + + commandLine osAdaptiveCommand(['npm', 'run', 'lint-report-ci']) +} \ No newline at end of file diff --git a/server/sonar-web/design-system/config/jest/SetupReactTestingLibrary.ts b/server/sonar-web/design-system/config/jest/SetupReactTestingLibrary.ts new file mode 100644 index 00000000000..afaa0a4fcfb --- /dev/null +++ b/server/sonar-web/design-system/config/jest/SetupReactTestingLibrary.ts @@ -0,0 +1,25 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 '@testing-library/jest-dom'; +import { configure } from '@testing-library/react'; + +configure({ + asyncUtilTimeout: 3000, +}); diff --git a/server/sonar-web/design-system/config/jest/SetupTestEnvironment.js b/server/sonar-web/design-system/config/jest/SetupTestEnvironment.js new file mode 100644 index 00000000000..3c7139c2b4c --- /dev/null +++ b/server/sonar-web/design-system/config/jest/SetupTestEnvironment.js @@ -0,0 +1,50 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 'whatwg-fetch'; + +const content = document.createElement('div'); +content.id = 'content'; +document.documentElement.appendChild(content); + +Element.prototype.scrollIntoView = () => {}; + +global.___loader = { + enqueue: jest.fn(), +}; + +const MockResizeObserverEntries = [ + { + contentRect: { + width: 100, + height: 200, + }, + }, +]; + +const MockResizeObserver = { + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +}; + +global.ResizeObserver = jest.fn().mockImplementation((callback) => { + callback(MockResizeObserverEntries, MockResizeObserver); + return MockResizeObserver; +}); diff --git a/server/sonar-web/design-system/config/jest/SetupTheme.js b/server/sonar-web/design-system/config/jest/SetupTheme.js new file mode 100644 index 00000000000..ac30c5a83bd --- /dev/null +++ b/server/sonar-web/design-system/config/jest/SetupTheme.js @@ -0,0 +1,26 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { ThemeContext } from '@emotion/react'; +import { lightTheme } from '../../src/theme'; + +// Hack : override the default value of the context used for theme by emotion +// This allows tests to get the theme value without specifiying a theme provider +ThemeContext['_currentValue'] = lightTheme; +ThemeContext['_currentValue2'] = lightTheme; diff --git a/server/sonar-web/design-system/jest.config.js b/server/sonar-web/design-system/jest.config.js new file mode 100644 index 00000000000..7da6e0a75ad --- /dev/null +++ b/server/sonar-web/design-system/jest.config.js @@ -0,0 +1,61 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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. + */ + +const babelConfig = require('./babel.config'); + +babelConfig.presets = [ + ['@babel/preset-env', { targets: { node: 'current' } }], + '@babel/preset-typescript', +]; + +module.exports = { + coverageDirectory: '/coverage', + collectCoverageFrom: [ + 'src/components/**/*.{ts,tsx,js}', + 'src/helpers/**/*.{ts,tsx,js}', + '!src/helpers/{keycodes,testUtils}.{ts,tsx}', + ], + coverageReporters: ['lcovonly', 'text'], + globals: { + 'ts-jest': { + diagnostics: false, + }, + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], + 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', + }, + setupFiles: [ + '/config/jest/SetupTestEnvironment.js', + '/config/jest/SetupTheme.js', + ], + setupFilesAfterEnv: ['/config/jest/SetupReactTestingLibrary.ts'], + snapshotSerializers: ['@emotion/jest/serializer'], + testEnvironment: 'jsdom', + testPathIgnorePatterns: ['/config/jest', '/node_modules', '/scripts'], + testRegex: '(/__tests__/.*|\\-test)\\.(ts|tsx|js)$', + transform: { + '^.+\\.(t|j)sx?$': ['babel-jest', babelConfig], + }, + transformIgnorePatterns: ['/node_modules/(?!(d3-.+))/'], + testTimeout: 30000, +}; diff --git a/server/sonar-web/design-system/package.json b/server/sonar-web/design-system/package.json index 11fbab61a09..ff2d3f8d877 100644 --- a/server/sonar-web/design-system/package.json +++ b/server/sonar-web/design-system/package.json @@ -1,32 +1,63 @@ { "name": "design-system", "version": "1.0.0", - "main": "./lib/index.js", - "types": "./lib/index.d.ts", + "main": "lib/index.js", + "types": "lib/index.d.ts", "scripts": { "build": "yarn lint && vite build", "build-release": "yarn install --immutable && yarn build", - "lint": "npx eslint --ext js,ts,tsx,snap --quiet src" + "lint": "eslint --ext js,ts,tsx,snap --quiet src", + "lint-report-ci": "yarn install --immutable && eslint --ext js,ts,tsx -f json -o eslint-report/eslint-report.json src || yarn lint", + "test": "jest", + "validate-ci": "yarn install --immutable && yarn test --coverage --ci" }, "devDependencies": { "@babel/core": "7.20.5", "@babel/plugin-transform-react-jsx": "7.20.13", + "@babel/preset-env": "7.20.2", + "@babel/preset-typescript": "7.18.6", + "@emotion/babel-plugin": "11.10.6", "@emotion/babel-plugin-jsx-pragmatic": "0.2.0", + "@testing-library/dom": "8.20.0", + "@testing-library/jest-dom": "5.16.5", + "@testing-library/react": "12.1.5", + "@testing-library/user-event": "14.4.3", + "@types/react": "16.14.34", + "@typescript-eslint/parser": "5.49.0", "@vitejs/plugin-react": "3.1.0", + "autoprefixer": "10.4.13", + "eslint": "8.32.0", "eslint-plugin-header": "3.1.1", "eslint-plugin-typescript-sort-keys": "2.1.0", - "twin.macro": "3.1.0", + "history": "5.3.0", + "jest": "29.3.1", + "postcss": "8.4.21", + "postcss-calc": "8.2.4", + "postcss-custom-properties": "12.1.11", + "twin.macro": "2.8.2", + "typescript": "4.9.4", "vite": "4.1.1", - "vite-plugin-dts": "1.7.2" + "vite-plugin-dts": "2.0.2", + "whatwg-fetch": "3.6.2" }, "peerDependencies": { "@emotion/react": "11.10.5", "@emotion/styled": "11.10.5", - "@typescript-eslint/parser": "5.49.0", - "eslint": "8.32.0", + "@primer/octicons-react": "17.11.1", + "classnames": "2.3.2", + "clipboard": "2.0.11", + "lodash": "4.17.21", "react": "16.14.0", "react-dom": "16.14.0", - "tailwindcss": "3.2.6", - "typescript": "4.9.4" + "react-helmet-async": "1.3.0", + "react-intl": "6.2.5", + "react-router-dom": "6.7.0", + "tailwindcss": "2.2.19" + }, + "babelMacros": { + "twin": { + "config": "../tailwind.config.js", + "preset": "emotion" + } } } diff --git a/server/sonar-web/design-system/src/@types/css.d.ts b/server/sonar-web/design-system/src/@types/css.d.ts new file mode 100644 index 00000000000..446d5d09539 --- /dev/null +++ b/server/sonar-web/design-system/src/@types/css.d.ts @@ -0,0 +1,27 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 * as CSS from 'csstype'; + +declare module 'csstype' { + interface Properties extends CSS.Properties { + // Support any CSS Custom Property in style prop of components + [index: `--${string}`]: string | number; + } +} diff --git a/server/sonar-web/design-system/src/@types/emotion.d.ts b/server/sonar-web/design-system/src/@types/emotion.d.ts new file mode 100644 index 00000000000..6ab3a1a59bb --- /dev/null +++ b/server/sonar-web/design-system/src/@types/emotion.d.ts @@ -0,0 +1,25 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 '@emotion/react'; +import { Theme as SQTheme } from '../types/theme'; + +declare module '@emotion/react' { + export interface Theme extends SQTheme {} +} diff --git a/server/sonar-web/design-system/src/components/Avatar.tsx b/server/sonar-web/design-system/src/components/Avatar.tsx new file mode 100644 index 00000000000..8b454295681 --- /dev/null +++ b/server/sonar-web/design-system/src/components/Avatar.tsx @@ -0,0 +1,117 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { ReactEventHandler, useState } from 'react'; +import tw from 'twin.macro'; +import { themeBorder, themeColor } from '../helpers/theme'; +import { GenericAvatar } from './GenericAvatar'; + +type Size = 'xs' | 'sm' | 'md' | 'lg'; + +const sizeMap: Record = { + xs: 16, + sm: 24, + md: 40, + lg: 64, +}; + +interface AvatarProps { + border?: boolean; + className?: string; + enableGravatar?: boolean; + gravatarServerUrl?: string; + hash?: string; + name?: string; + organizationAvatar?: string; + organizationName?: string; + size?: Size; +} + +export function Avatar({ + className, + enableGravatar, + gravatarServerUrl, + hash, + name, + organizationAvatar, + organizationName, + size = 'sm', + border, +}: AvatarProps) { + const [imgError, setImgError] = useState(false); + const numberSize = sizeMap[size]; + const resolvedName = organizationName ?? name; + + const handleImgError: ReactEventHandler = () => { + setImgError(true); + }; + + if (!imgError) { + if (enableGravatar && gravatarServerUrl && hash) { + const url = gravatarServerUrl + .replace('{EMAIL_MD5}', hash) + .replace('{SIZE}', String(numberSize * 2)); + + return ( + + ); + } + + if (resolvedName && organizationAvatar) { + return ( + + ); + } + } + + if (!resolvedName) { + return ; + } + + return ; +} + +const StyledAvatar = styled.img<{ border?: boolean }>` + ${tw`sw-inline-flex`}; + ${tw`sw-items-center`}; + ${tw`sw-justify-center`}; + ${tw`sw-align-top`}; + ${tw`sw-rounded-1`}; + border: ${({ border }) => (border ? themeBorder('default', 'avatarBorder') : '')}; + background: ${themeColor('avatarBackground')}; +`; diff --git a/server/sonar-web/design-system/src/components/Checkbox.tsx b/server/sonar-web/design-system/src/components/Checkbox.tsx new file mode 100644 index 00000000000..7e352d04d3d --- /dev/null +++ b/server/sonar-web/design-system/src/components/Checkbox.tsx @@ -0,0 +1,174 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 React from 'react'; +import tw from 'twin.macro'; +import { themeBorder, themeColor, themeContrast } from '../helpers/theme'; +import DeferredSpinner from './DeferredSpinner'; +import CheckIcon from './icons/CheckIcon'; +import { CustomIcon } from './icons/Icon'; + +interface Props { + checked: boolean; + children?: React.ReactNode; + className?: string; + disabled?: boolean; + id?: string; + loading?: boolean; + onCheck: (checked: boolean, id?: string) => void; + onClick?: (event: React.MouseEvent) => void; + onFocus?: VoidFunction; + right?: boolean; + thirdState?: boolean; + title?: string; +} + +export default function Checkbox({ + checked, + disabled, + children, + className, + id, + loading = false, + onCheck, + onFocus, + onClick, + right, + thirdState = false, + title, +}: Props) { + const handleChange = () => { + if (!disabled) { + onCheck(!checked, id); + } + }; + + return ( + + {right && children} + + + + + + + {!right && children} + + ); +} + +interface CheckIconProps { + checked?: boolean; + thirdState?: boolean; +} + +function CheckboxIcon({ checked, thirdState }: CheckIconProps) { + if (checked && thirdState) { + return ( + + + + ); + } else if (checked) { + return ; + } + return null; +} + +const CheckboxContainer = styled.label<{ disabled?: boolean }>` + color: ${themeContrast('backgroundSecondary')}; + user-select: none; + + ${tw`sw-inline-flex sw-items-center`}; + + &:hover { + ${tw`sw-cursor-pointer`} + } + + &:disabled { + color: ${themeContrast('checkboxDisabled')}; + ${tw`sw-cursor-not-allowed`} + } +`; + +export const StyledCheckbox = styled.span` + border: ${themeBorder('default', 'primary')}; + color: ${themeContrast('primary')}; + + ${tw`sw-w-4 sw-h-4`}; + ${tw`sw-rounded-1/2`}; + ${tw`sw-box-border`} + ${tw`sw-inline-flex sw-items-center sw-justify-center`}; +`; + +export const AccessibleCheckbox = styled.input` + // Following css makes the checkbox accessible and invisible + border: 0; + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + padding: 0; + white-space: nowrap; + width: 1px; + + &:focus, + &:active { + &:not(:disabled) + ${StyledCheckbox} { + outline: ${themeBorder('focus', 'primary')}; + } + } + + &:checked { + & + ${StyledCheckbox} { + background: ${themeColor('primary')}; + } + &:disabled + ${StyledCheckbox} { + background: ${themeColor('checkboxDisabledChecked')}; + } + } + + &:hover { + &:not(:disabled) + ${StyledCheckbox} { + background: ${themeColor('checkboxHover')}; + border: ${themeBorder('default', 'primary')}; + } + + &:checked:not(:disabled) + ${StyledCheckbox} { + background: ${themeColor('checkboxCheckedHover')}; + border: ${themeBorder('default', 'checkboxCheckedHover')}; + } + } + + &:disabled + ${StyledCheckbox} { + background: ${themeColor('checkboxDisabled')}; + color: ${themeColor('checkboxDisabled')}; + border: ${themeBorder('default', 'checkboxDisabledChecked')}; + } +`; diff --git a/server/sonar-web/design-system/src/components/ClickEventBoundary.tsx b/server/sonar-web/design-system/src/components/ClickEventBoundary.tsx new file mode 100644 index 00000000000..d2c5b85f0e7 --- /dev/null +++ b/server/sonar-web/design-system/src/components/ClickEventBoundary.tsx @@ -0,0 +1,35 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 React from 'react'; + +export interface ClickEventBoundaryProps { + children: React.ReactElement; +} + +export default function ClickEventBoundary({ children }: ClickEventBoundaryProps) { + return React.cloneElement(children, { + onClick: (e: React.SyntheticEvent) => { + e.stopPropagation(); + if (typeof children.props.onClick === 'function') { + children.props.onClick(e); + } + }, + }); +} diff --git a/server/sonar-web/design-system/src/components/DeferredSpinner.tsx b/server/sonar-web/design-system/src/components/DeferredSpinner.tsx new file mode 100644 index 00000000000..50df8bc2bf7 --- /dev/null +++ b/server/sonar-web/design-system/src/components/DeferredSpinner.tsx @@ -0,0 +1,138 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { keyframes } from '@emotion/react'; +import styled from '@emotion/styled'; +import React from 'react'; +import tw, { theme } from 'twin.macro'; +import { translate } from '../helpers/l10n'; +import { themeColor } from '../helpers/theme'; +import { InputSearchWrapper } from './InputSearch'; + +interface Props { + children?: React.ReactNode; + className?: string; + customSpinner?: JSX.Element; + loading?: boolean; + placeholder?: boolean; + timeout?: number; +} + +interface State { + showSpinner: boolean; +} + +const DEFAULT_TIMEOUT = 100; + +export default class DeferredSpinner extends React.PureComponent { + timer?: number; + + state: State = { showSpinner: false }; + + componentDidMount() { + if (this.props.loading == null || this.props.loading === true) { + this.startTimer(); + } + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.loading === false && this.props.loading === true) { + this.stopTimer(); + this.startTimer(); + } + if (prevProps.loading === true && this.props.loading === false) { + this.stopTimer(); + this.setState({ showSpinner: false }); + } + } + + componentWillUnmount() { + this.stopTimer(); + } + + startTimer = () => { + this.timer = window.setTimeout( + () => this.setState({ showSpinner: true }), + this.props.timeout || DEFAULT_TIMEOUT + ); + }; + + stopTimer = () => { + window.clearTimeout(this.timer); + }; + + render() { + const { showSpinner } = this.state; + const { customSpinner, className, children, placeholder } = this.props; + if (showSpinner) { + if (customSpinner) { + return customSpinner; + } + return ; + } + if (children) { + return children; + } + if (placeholder) { + return ; + } + return null; + } +} + +const spinAnimation = keyframes` + from { + transform: rotate(0deg); + } + + to { + transform: rotate(-360deg); + } +`; + +const Spinner = styled.div` + border: 2px solid transparent; + background: linear-gradient(0deg, ${themeColor('primary')} 50%, transparent 50% 100%) border-box, + linear-gradient(90deg, ${themeColor('primary')} 25%, transparent 75% 100%) border-box; + mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + animation: ${spinAnimation} 1s infinite linear; + + ${tw`sw-h-4 sw-w-4`}; + ${tw`sw-inline-block`}; + ${tw`sw-box-border`}; + ${tw`sw-rounded-pill`} + + ${InputSearchWrapper} & { + top: calc((2.25rem - ${theme('spacing.4')}) / 2); + ${tw`sw-left-3`}; + ${tw`sw-absolute`}; + } +`; + +Spinner.defaultProps = { 'aria-label': translate('loading'), role: 'status' }; + +const Placeholder = styled.div` + position: relative; + visibility: hidden; + + ${tw`sw-inline-flex sw-items-center sw-justify-center`}; + ${tw`sw-h-4 sw-w-4`}; +`; diff --git a/server/sonar-web/design-system/src/components/Dropdown.tsx b/server/sonar-web/design-system/src/components/Dropdown.tsx new file mode 100644 index 00000000000..f04b595decd --- /dev/null +++ b/server/sonar-web/design-system/src/components/Dropdown.tsx @@ -0,0 +1,140 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 React from 'react'; +import { translate } from '../helpers/l10n'; +import { PopupPlacement, PopupZLevel } from '../helpers/positioning'; +import { InputSizeKeys } from '../types/theme'; +import { DropdownMenu } from './DropdownMenu'; +import DropdownToggler from './DropdownToggler'; +import MenuIcon from './icons/MenuIcon'; +import { InteractiveIcon } from './InteractiveIcon'; + +type OnClickCallback = (event?: React.MouseEvent) => void; +type A11yAttrs = Pick & { + id: string; + role: React.AriaRole; +}; +interface RenderProps { + a11yAttrs: A11yAttrs; + closeDropdown: VoidFunction; + onToggleClick: OnClickCallback; + open: boolean; +} + +interface Props { + allowResizing?: boolean; + children: + | ((renderProps: RenderProps) => JSX.Element) + | React.ReactElement<{ onClick: OnClickCallback }>; + className?: string; + closeOnClick?: boolean; + id: string; + onOpen?: VoidFunction; + overlay: React.ReactNode; + placement?: PopupPlacement; + size?: InputSizeKeys; + zLevel?: PopupZLevel; +} + +interface State { + open: boolean; +} + +export default class Dropdown extends React.PureComponent { + state: State = { open: false }; + + componentDidUpdate(_: Props, prevState: State) { + if (!prevState.open && this.state.open && this.props.onOpen) { + this.props.onOpen(); + } + } + + handleClose = () => { + this.setState({ open: false }); + }; + + handleToggleClick: OnClickCallback = (event) => { + if (event) { + event.preventDefault(); + event.currentTarget.blur(); + } + this.setState((state) => ({ open: !state.open })); + }; + + render() { + const { open } = this.state; + const { allowResizing, className, closeOnClick = true, id, size = 'full', zLevel } = this.props; + const a11yAttrs: A11yAttrs = { + 'aria-controls': `${id}-dropdown`, + 'aria-expanded': open, + 'aria-haspopup': 'menu', + id: `${id}-trigger`, + role: 'button', + }; + + const children = React.isValidElement(this.props.children) + ? React.cloneElement(this.props.children, { onClick: this.handleToggleClick, ...a11yAttrs }) + : this.props.children({ + a11yAttrs, + closeDropdown: this.handleClose, + onToggleClick: this.handleToggleClick, + open, + }); + + return ( + + {this.props.overlay} + + } + placement={this.props.placement} + zLevel={zLevel} + > + {children} + + ); + } +} + +interface ActionsDropdownProps extends Omit { + buttonSize?: 'small' | 'medium'; + children: React.ReactNode; +} + +export function ActionsDropdown(props: ActionsDropdownProps) { + const { children, buttonSize, ...dropdownProps } = props; + return ( + + + + ); +} diff --git a/server/sonar-web/design-system/src/components/DropdownMenu.tsx b/server/sonar-web/design-system/src/components/DropdownMenu.tsx new file mode 100644 index 00000000000..d16011785b9 --- /dev/null +++ b/server/sonar-web/design-system/src/components/DropdownMenu.tsx @@ -0,0 +1,370 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import classNames from 'classnames'; +import React from 'react'; +import tw from 'twin.macro'; +import { INPUT_SIZES } from '../helpers/constants'; +import { translate } from '../helpers/l10n'; +import { themeBorder, themeColor, themeContrast } from '../helpers/theme'; +import { InputSizeKeys, ThemedProps } from '../types/theme'; +import Checkbox from './Checkbox'; +import { ClipboardBase } from './clipboard'; +import { BaseLink, LinkProps } from './Link'; +import NavLink from './NavLink'; +import RadioButton from './RadioButton'; +import Tooltip from './Tooltip'; + +interface Props extends React.HtmlHTMLAttributes { + children?: React.ReactNode; + className?: string; + innerRef?: React.Ref; + maxHeight?: string; + size?: InputSizeKeys; +} + +export function DropdownMenu({ + children, + className, + innerRef, + maxHeight = 'inherit', + size = 'small', + ...menuProps +}: Props) { + return ( + + {children} + + ); +} + +interface ListItemProps { + children?: React.ReactNode; + className?: string; + innerRef?: React.Ref; + onFocus?: VoidFunction; + onPointerEnter?: VoidFunction; + onPointerLeave?: VoidFunction; +} + +type ItemLinkProps = Omit & + Pick & { + innerRef?: React.Ref; + }; + +export function ItemLink(props: ItemLinkProps) { + const { children, className, disabled, icon, onClick, innerRef, to, ...liProps } = props; + return ( +
  • + + {children} + +
  • + ); +} + +interface ItemNavLinkProps extends ItemLinkProps { + end?: boolean; +} + +export function ItemNavLink(props: ItemNavLinkProps) { + const { children, className, disabled, end, icon, onClick, innerRef, to, ...liProps } = props; + return ( +
  • + + {icon} + {children} + +
  • + ); +} + +interface ItemButtonProps extends ListItemProps { + disabled?: boolean; + icon?: React.ReactNode; + onClick: React.MouseEventHandler; +} + +export function ItemButton(props: ItemButtonProps) { + const { children, className, disabled, icon, innerRef, onClick, ...liProps } = props; + return ( +
  • + + {icon} + {children} + +
  • + ); +} + +export const ItemDangerButton = styled(ItemButton)` + --color: ${themeContrast('dropdownMenuDanger')}; +`; + +interface ItemCheckboxProps extends ListItemProps { + checked: boolean; + disabled?: boolean; + id?: string; + onCheck: (checked: boolean, id?: string) => void; +} + +export function ItemCheckbox(props: ItemCheckboxProps) { + const { checked, children, className, disabled, id, innerRef, onCheck, onFocus, ...liProps } = + props; + return ( +
  • + + {children} + +
  • + ); +} + +interface ItemRadioButtonProps extends ListItemProps { + checked: boolean; + disabled?: boolean; + onCheck: (value: string) => void; + value: string; +} + +export function ItemRadioButton(props: ItemRadioButtonProps) { + const { checked, children, className, disabled, innerRef, onCheck, value, ...liProps } = props; + return ( +
  • + + {children} + +
  • + ); +} + +interface ItemCopyProps { + children?: React.ReactNode; + className?: string; + copyValue: string; +} + +export function ItemCopy(props: ItemCopyProps) { + const { children, className, copyValue } = props; + return ( + + {({ setCopyButton, copySuccess }) => ( + +
  • + + {children} + +
  • +
    + )} +
    + ); +} + +interface ItemDownloadProps extends ListItemProps { + download: string; + href: string; +} + +export function ItemDownload(props: ItemDownloadProps) { + const { children, className, download, href, innerRef, ...liProps } = props; + return ( +
  • + + {children} + +
  • + ); +} + +export const ItemHeaderHighlight = styled.span` + color: ${themeContrast('searchHighlight')}; + font-weight: 600; +`; + +export const ItemHeader = styled.li` + background-color: ${themeColor('dropdownMenuHeader')}; + color: ${themeContrast('dropdownMenuHeader')}; + + ${tw`sw-py-2 sw-px-3`} +`; +ItemHeader.defaultProps = { className: 'dropdown-menu-header', role: 'menuitem' }; + +export const ItemDivider = styled.li` + height: 1px; + background-color: ${themeColor('popupBorder')}; + + ${tw`sw-my-1 sw--mx-2`} + ${tw`sw-overflow-hidden`}; +`; +ItemDivider.defaultProps = { role: 'separator' }; + +const DropdownMenuWrapper = styled.ul` + background-color: ${themeColor('dropdownMenu')}; + color: ${themeContrast('dropdownMenu')}; + width: var(--inputSize); + list-style: none; + + ${tw`sw-flex sw-flex-col`} + ${tw`sw-box-border`}; + ${tw`sw-min-w-input-small`} + ${tw`sw-py-2`} + ${tw`sw-body-sm`} + + &:focus { + outline: none; + } +`; + +const itemStyle = (props: ThemedProps) => css` + color: var(--color); + background-color: ${themeColor('dropdownMenu')(props)}; + border: none; + border-bottom: none; + text-decoration: none; + transition: none; + + ${tw`sw-flex sw-items-center`} + ${tw`sw-body-sm`} + ${tw`sw-box-border`} + ${tw`sw-w-full`} + ${tw`sw-text-left`} + ${tw`sw-py-2 sw-px-3`} + ${tw`sw-truncate`}; + ${tw`sw-cursor-pointer`} + + &.active, + &:active, + &.active:active, + &:hover, + &.active:hover { + color: var(--color); + background-color: ${themeColor('dropdownMenuHover')(props)}; + text-decoration: none; + outline: none; + border: none; + border-bottom: none; + } + + &:focus, + &:focus-within, + &.active:focus, + &.active:focus-within { + color: var(--color); + background-color: ${themeColor('dropdownMenuFocus')(props)}; + text-decoration: none; + outline: ${themeBorder('focus', 'dropdownMenuFocusBorder')(props)}; + outline-offset: -4px; + border: none; + border-bottom: none; + } + + &:disabled, + &.disabled { + color: ${themeContrast('dropdownMenuDisabled')(props)}; + background-color: ${themeColor('dropdownMenuDisabled')(props)}; + pointer-events: none !important; + + ${tw`sw-cursor-not-allowed`}; + } + + & > svg { + ${tw`sw-mr-2`} + } +`; + +const ItemNavLinkStyled = styled(NavLink)` + --color: ${themeContrast('dropdownMenu')}; + ${itemStyle}; +`; + +const ItemLinkStyled = styled(BaseLink)` + --color: ${themeContrast('dropdownMenu')}; + ${itemStyle} +`; + +const ItemButtonStyled = styled.button` + --color: ${themeContrast('dropdownMenu')}; + ${itemStyle} +`; + +const ItemDownloadStyled = styled.a` + --color: ${themeContrast('dropdownMenu')}; + ${itemStyle} +`; + +const ItemCheckboxStyled = styled(Checkbox)` + --color: ${themeContrast('dropdownMenu')}; + ${itemStyle} +`; + +const ItemRadioButtonStyled = styled(RadioButton)` + --color: ${themeContrast('dropdownMenu')}; + ${itemStyle} +`; diff --git a/server/sonar-web/design-system/src/components/DropdownToggler.tsx b/server/sonar-web/design-system/src/components/DropdownToggler.tsx new file mode 100644 index 00000000000..f46f3dc8456 --- /dev/null +++ b/server/sonar-web/design-system/src/components/DropdownToggler.tsx @@ -0,0 +1,48 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 EscKeydownHandler from './EscKeydownHandler'; +import OutsideClickHandler from './OutsideClickHandler'; +import { PortalPopup } from './popups'; + +type PopupProps = PortalPopup['props']; + +interface Props extends PopupProps { + onRequestClose: VoidFunction; + open: boolean; +} + +export default function DropdownToggler(props: Props) { + const { children, open, onRequestClose, overlay, ...popupProps } = props; + + return ( + + {overlay} + + ) : undefined + } + {...popupProps} + > + {children} + + ); +} diff --git a/server/sonar-web/design-system/src/components/DummyComponent.tsx b/server/sonar-web/design-system/src/components/DummyComponent.tsx deleted file mode 100644 index 8470a1351a3..00000000000 --- a/server/sonar-web/design-system/src/components/DummyComponent.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2023 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. - */ - -export function DummyComponent() { - return
    I'm a dummy
    ; -} diff --git a/server/sonar-web/design-system/src/components/EscKeydownHandler.tsx b/server/sonar-web/design-system/src/components/EscKeydownHandler.tsx new file mode 100644 index 00000000000..9b0155ab6dd --- /dev/null +++ b/server/sonar-web/design-system/src/components/EscKeydownHandler.tsx @@ -0,0 +1,48 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 React from 'react'; +import { Key } from '../helpers/keyboard'; + +interface Props { + children: React.ReactNode; + onKeydown: () => void; +} + +export default class EscKeydownHandler extends React.Component { + componentDidMount() { + setTimeout(() => { + document.addEventListener('keydown', this.handleKeyDown, false); + }, 0); + } + + componentWillUnmount() { + document.removeEventListener('keydown', this.handleKeyDown, false); + } + + handleKeyDown = (event: KeyboardEvent) => { + if (event.code === Key.Escape) { + this.props.onKeydown(); + } + }; + + render() { + return this.props.children; + } +} diff --git a/server/sonar-web/design-system/src/components/GenericAvatar.tsx b/server/sonar-web/design-system/src/components/GenericAvatar.tsx new file mode 100644 index 00000000000..4d8fa6901be --- /dev/null +++ b/server/sonar-web/design-system/src/components/GenericAvatar.tsx @@ -0,0 +1,60 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import React from 'react'; +import tw from 'twin.macro'; +import { themeAvatarColor } from '../helpers/theme'; +import { IconProps } from './icons/Icon'; + +export interface GenericAvatarProps { + Icon?: React.ComponentType; + className?: string; + name: string; + size?: number; +} + +export function GenericAvatar({ className, Icon, name, size = 24 }: GenericAvatarProps) { + const theme = useTheme(); + const text = name.length > 0 ? name[0].toUpperCase() : ''; + + return ( + + {Icon ? : text} + + ); +} + +export const StyledGenericAvatar = styled.div<{ name: string; size: number }>` + ${tw`sw-text-center`}; + ${tw`sw-align-top`}; + ${tw`sw-select-none`}; + ${tw`sw-font-regular`}; + ${tw`sw-rounded-1`}; + ${tw`sw-inline-flex`}; + ${tw`sw-items-center`}; + ${tw`sw-justify-center`}; + height: ${({ size }) => size}px; + width: ${({ size }) => size}px; + background-color: ${({ name, theme }) => themeAvatarColor(name)({ theme })}; + color: ${({ name, theme }) => themeAvatarColor(name, true)({ theme })}; + font-size: ${({ size }) => Math.max(Math.floor(size / 2), 8)}px; + line-height: ${({ size }) => size}px; +`; diff --git a/server/sonar-web/design-system/src/components/InputSearch.tsx b/server/sonar-web/design-system/src/components/InputSearch.tsx new file mode 100644 index 00000000000..5e5e9c0e3ee --- /dev/null +++ b/server/sonar-web/design-system/src/components/InputSearch.tsx @@ -0,0 +1,243 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 classNames from 'classnames'; +import { debounce } from 'lodash'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import tw, { theme } from 'twin.macro'; +import { DEBOUNCE_DELAY, INPUT_SIZES } from '../helpers/constants'; +import { Key } from '../helpers/keyboard'; +import { themeBorder, themeColor, themeContrast } from '../helpers/theme'; +import { isDefined } from '../helpers/types'; +import { InputSizeKeys } from '../types/theme'; +import DeferredSpinner from './DeferredSpinner'; +import CloseIcon from './icons/CloseIcon'; +import SearchIcon from './icons/SearchIcon'; +import { InteractiveIcon } from './InteractiveIcon'; + +interface Props { + autoFocus?: boolean; + className?: string; + clearIconAriaLabel: string; + id?: string; + innerRef?: React.RefCallback; + loading?: boolean; + maxLength?: number; + minLength?: number; + onBlur?: React.FocusEventHandler; + onChange: (value: string) => void; + onFocus?: React.FocusEventHandler; + onKeyDown?: React.KeyboardEventHandler; + onMouseDown?: React.MouseEventHandler; + placeholder: string; + searchInputAriaLabel: string; + size?: InputSizeKeys; + tooShortText: string; + value?: string; +} + +const DEFAULT_MAX_LENGTH = 100; + +export default function InputSearch({ + autoFocus, + id, + className, + innerRef, + onBlur, + onChange, + onFocus, + onKeyDown, + onMouseDown, + placeholder, + loading, + minLength, + maxLength = DEFAULT_MAX_LENGTH, + size = 'medium', + value: parentValue, + tooShortText, + searchInputAriaLabel, + clearIconAriaLabel, +}: Props) { + const input = useRef(null); + const [value, setValue] = useState(parentValue ?? ''); + const debouncedOnChange = useMemo(() => debounce(onChange, DEBOUNCE_DELAY), [onChange]); + + const tooShort = isDefined(minLength) && value.length > 0 && value.length < minLength; + const inputClassName = classNames('js-input-search', { + touched: value.length > 0 && (!minLength || minLength > value.length), + 'sw-pr-10': value.length > 0, + }); + + useEffect(() => { + if (parentValue !== undefined) { + setValue(parentValue); + } + }, [parentValue]); + + const changeValue = (newValue: string) => { + if (newValue.length === 0 || !minLength || minLength <= newValue.length) { + debouncedOnChange(newValue); + } + }; + + const handleInputChange = (event: React.SyntheticEvent) => { + const eventValue = event.currentTarget.value; + setValue(eventValue); + changeValue(eventValue); + }; + + const handleInputKeyDown = (event: React.KeyboardEvent) => { + if (event.key === Key.Escape) { + event.preventDefault(); + handleClearClick(); + } + onKeyDown?.(event); + }; + + const handleClearClick = () => { + onChange(''); + if (parentValue === undefined || parentValue === '') { + setValue(''); + } + input.current?.focus(); + }; + const ref = (node: HTMLInputElement | null) => { + input.current = node; + innerRef?.(node); + }; + + return ( + + + + + + + {value && ( + + )} + + {tooShort && isDefined(minLength) && ( + + {tooShortText} + + )} + + + ); +} + +export const InputSearchWrapper = styled.div` + width: var(--inputSize); + + ${tw`sw-relative sw-inline-block`} + ${tw`sw-whitespace-nowrap`} + ${tw`sw-align-middle`} + ${tw`sw-h-control`} +`; + +export const StyledInputWrapper = styled.div` + input { + background: ${themeColor('inputBackground')}; + color: ${themeContrast('inputBackground')}; + border: ${themeBorder('default', 'inputBorder')}; + + ${tw`sw-rounded-2`} + ${tw`sw-box-border`} + ${tw`sw-pl-10`} + ${tw`sw-body-sm`} + ${tw`sw-w-full sw-h-control`} + + &::placeholder { + color: ${themeColor('inputPlaceholder')}; + + ${tw`sw-truncate`} + } + + &:hover { + border: ${themeBorder('default', 'inputFocus')}; + } + + &:focus, + &:active { + border: ${themeBorder('default', 'inputFocus')}; + outline: ${themeBorder('focus', 'inputFocus')}; + } + + &::-webkit-search-decoration, + &::-webkit-search-cancel-button, + &::-webkit-search-results-button, + &::-webkit-search-results-decoration { + ${tw`sw-hidden sw-appearance-none`} + } + } +`; + +const StyledSearchIcon = styled(SearchIcon)` + color: ${themeColor('inputBorder')}; + top: calc((${theme('height.control')} - ${theme('spacing.4')}) / 2); + + ${tw`sw-left-3`} + ${tw`sw-absolute`} +`; + +export const StyledInteractiveIcon = styled(InteractiveIcon)` + ${tw`sw-absolute`} + ${tw`sw-right-2`} +`; + +const StyledNote = styled.span` + color: ${themeColor('inputPlaceholder')}; + top: calc(1px + ${theme('inset.2')}); + + ${tw`sw-absolute`} + ${tw`sw-left-12 sw-right-10`} + ${tw`sw-body-sm`} + ${tw`sw-text-right`} + ${tw`sw-truncate`} + ${tw`sw-pointer-events-none`} +`; diff --git a/server/sonar-web/design-system/src/components/InteractiveIcon.tsx b/server/sonar-web/design-system/src/components/InteractiveIcon.tsx new file mode 100644 index 00000000000..ebd9cb73e9a --- /dev/null +++ b/server/sonar-web/design-system/src/components/InteractiveIcon.tsx @@ -0,0 +1,182 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import classNames from 'classnames'; +import React from 'react'; +import tw from 'twin.macro'; +import { themeBorder, themeColor, themeContrast } from '../helpers/theme'; +import { isDefined } from '../helpers/types'; +import { ThemedProps } from '../types/theme'; +import { IconProps } from './icons/Icon'; +import { BaseLink, LinkProps } from './Link'; + +export type InteractiveIconSize = 'small' | 'medium'; + +export interface InteractiveIconProps { + Icon: React.ComponentType; + 'aria-label': string; + children?: React.ReactNode; + className?: string; + currentColor?: boolean; + disabled?: boolean; + id?: string; + innerRef?: React.Ref; + onClick?: VoidFunction; + size?: InteractiveIconSize; + stopPropagation?: boolean; + to?: LinkProps['to']; +} + +export class InteractiveIconBase extends React.PureComponent { + handleClick = (event: React.MouseEvent) => { + const { disabled, onClick, stopPropagation = true } = this.props; + event.currentTarget.blur(); + + if (stopPropagation) { + event.stopPropagation(); + } + + if (onClick && !disabled) { + onClick(); + } + }; + + render() { + const { + Icon, + children, + disabled, + innerRef, + onClick, + size = 'medium', + to, + ...htmlProps + } = this.props; + + const props = { + ...htmlProps, + 'aria-disabled': disabled, + disabled, + size, + type: 'button' as const, + }; + + if (to) { + return ( + + + {children} + + ); + } + + return ( + + + {children} + + ); + } +} + +const buttonIconStyle = (props: ThemedProps & { size: InteractiveIconSize }) => css` + box-sizing: border-box; + border: none; + outline: none; + text-decoration: none; + color: var(--color); + background-color: var(--background); + transition: background-color 0.2s ease, outline 0.2s ease, color 0.2s ease; + + ${tw`sw-inline-flex sw-items-center sw-justify-center`} + ${tw`sw-cursor-pointer`} + + ${{ + small: tw`sw-h-6 sw-px-1 sw-rounded-1/2`, + medium: tw`sw-h-control sw-px-[0.625rem] sw-rounded-2`, + }[props.size]} + + + &:hover, + &:focus, + &:active { + color: var(--colorHover); + background-color: var(--backgroundHover); + } + + &:focus, + &:active { + outline: ${themeBorder('focus', 'var(--focus)')(props)}; + } + + &:disabled, + &:disabled:hover { + color: ${themeContrast('buttonDisabled')(props)}; + background-color: var(--background); + + ${tw`sw-cursor-not-allowed`} + } +`; + +const IconLink = styled(BaseLink)` + ${buttonIconStyle} +`; + +const IconButton = styled.button` + ${buttonIconStyle} +`; + +export const InteractiveIcon: React.FC = styled(InteractiveIconBase)` + --background: ${themeColor('interactiveIcon')}; + --backgroundHover: ${themeColor('interactiveIconHover')}; + --color: ${({ currentColor, theme }) => + currentColor ? 'currentColor' : themeContrast('interactiveIcon')({ theme })}; + --colorHover: ${themeContrast('interactiveIconHover')}; + --focus: ${themeColor('interactiveIconFocus', 0.2)}; +`; + +export const DiscreetInteractiveIcon: React.FC = styled(InteractiveIcon)` + --color: ${themeColor('discreetInteractiveIcon')}; +`; + +export const DestructiveIcon: React.FC = styled(InteractiveIconBase)` + --background: ${themeColor('destructiveIcon')}; + --backgroundHover: ${themeColor('destructiveIconHover')}; + --color: ${themeContrast('destructiveIcon')}; + --colorHover: ${themeContrast('destructiveIconHover')}; + --focus: ${themeColor('destructiveIconFocus', 0.2)}; +`; + +export const DismissProductNewsIcon: React.FC = styled(InteractiveIcon)` + --background: ${themeColor('productNews')}; + --backgroundHover: ${themeColor('productNewsHover')}; + --color: ${themeContrast('productNews')}; + --colorHover: ${themeContrast('productNewsHover')}; + --focus: ${themeColor('interactiveIconFocus', 0.2)}; + + height: 28px; +`; diff --git a/server/sonar-web/design-system/src/components/Link.tsx b/server/sonar-web/design-system/src/components/Link.tsx new file mode 100644 index 00000000000..5f427ece7e2 --- /dev/null +++ b/server/sonar-web/design-system/src/components/Link.tsx @@ -0,0 +1,173 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import React, { HTMLAttributeAnchorTarget } from 'react'; +import { Link as RouterLink, LinkProps as RouterLinkProps } from 'react-router-dom'; +import tw, { theme as twTheme } from 'twin.macro'; +import { themeBorder, themeColor } from '../helpers/theme'; +import OpenNewTabIcon from './icons/OpenNewTabIcon'; +import { TooltipWrapperInner } from './Tooltip'; + +export interface LinkProps extends RouterLinkProps { + blurAfterClick?: boolean; + disabled?: boolean; + forceExternal?: boolean; + icon?: React.ReactNode; + onClick?: (event: React.MouseEvent) => void; + preventDefault?: boolean; + showExternalIcon?: boolean; + stopPropagation?: boolean; + target?: HTMLAttributeAnchorTarget; +} + +function BaseLinkWithRef(props: LinkProps, ref: React.ForwardedRef) { + const { + children, + blurAfterClick, + disabled, + icon, + onClick, + preventDefault, + showExternalIcon = !icon, + stopPropagation, + target = '_blank', + to, + ...rest + } = props; + const isExternal = typeof to === 'string' && to.startsWith('http'); + const handleClick = React.useCallback( + (event: React.MouseEvent) => { + if (blurAfterClick) { + event.currentTarget.blur(); + } + + if (preventDefault || disabled) { + event.preventDefault(); + } + + if (stopPropagation) { + event.stopPropagation(); + } + + if (onClick && !disabled) { + onClick(event); + } + }, + [onClick, blurAfterClick, preventDefault, stopPropagation, disabled] + ); + + return isExternal ? ( + + {icon} + {children} + {showExternalIcon && } + + ) : ( + + {icon} + {children} + + ); +} + +export const BaseLink = React.forwardRef(BaseLinkWithRef); + +const StyledBaseLink = styled(BaseLink)` + color: var(--color); + border-bottom: ${({ children, icon, theme }) => + icon && !children ? themeBorder('default', 'transparent')({ theme }) : 'var(--border)'}; + + &:visited { + color: var(--color); + } + + &:hover, + &:focus, + &:active { + color: var(--active); + border-bottom: ${({ children, icon, theme }) => + icon && !children ? themeBorder('default', 'transparent')({ theme }) : 'var(--borderActive)'}; + } + + & > svg { + ${tw`sw-align-text-bottom!`} + } + + ${({ icon }) => + icon && + css` + margin-left: calc(${twTheme('width.icon')} + ${twTheme('spacing.1')}); + + & > svg, + & > img { + ${tw`sw-mr-1`} + + margin-left: calc(-1 * (${twTheme('width.icon')} + ${twTheme('spacing.1')})); + } + `}; +`; + +export const HoverLink = styled(StyledBaseLink)` + text-decoration: none; + + --color: ${themeColor('linkDiscreet')}; + --active: ${themeColor('linkActive')}; + --border: ${themeBorder('default', 'transparent')}; + --borderActive: ${themeBorder('default', 'linkActive')}; + + ${TooltipWrapperInner} & { + --active: ${themeColor('linkTooltipActive')}; + --borderActive: ${themeBorder('default', 'linkTooltipActive')}; + } +`; +HoverLink.displayName = 'HoverLink'; + +export const DiscreetLink = styled(HoverLink)` + --border: ${themeBorder('default', 'linkDiscreet')}; +`; +DiscreetLink.displayName = 'DiscreetLink'; + +const StandoutLink = styled(StyledBaseLink)` + ${tw`sw-font-semibold`} + ${tw`sw-no-underline`} + + --color: ${themeColor('linkDefault')}; + --active: ${themeColor('linkActive')}; + --border: ${themeBorder('default', 'linkDefault')}; + --borderActive: ${themeBorder('default', 'linkDefault')}; + + ${TooltipWrapperInner} & { + --color: ${themeColor('linkTooltipDefault')}; + --active: ${themeColor('linkTooltipActive')}; + --border: ${themeBorder('default', 'linkTooltipDefault')}; + --borderActive: ${themeBorder('default', 'linkTooltipActive')}; + } +`; +StandoutLink.displayName = 'StandoutLink'; + +export default StandoutLink; diff --git a/server/sonar-web/design-system/src/components/MainAppBar.tsx b/server/sonar-web/design-system/src/components/MainAppBar.tsx new file mode 100644 index 00000000000..97303a00158 --- /dev/null +++ b/server/sonar-web/design-system/src/components/MainAppBar.tsx @@ -0,0 +1,89 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 tw from 'twin.macro'; +import { + LAYOUT_GLOBAL_NAV_HEIGHT, + LAYOUT_LOGO_MARGIN_RIGHT, + LAYOUT_LOGO_MAX_HEIGHT, + LAYOUT_LOGO_MAX_WIDTH, +} from '../helpers/constants'; +import { themeBorder, themeColor, themeContrast } from '../helpers/theme'; + +const MainAppBarContainerDiv = styled.div` + height: ${LAYOUT_GLOBAL_NAV_HEIGHT}px; +`; + +const MainAppBarDiv = styled.div` + ${tw`sw-fixed`} + ${tw`sw-flex`}; + ${tw`sw-items-center`}; + ${tw`sw-left-0`}; + ${tw`sw-px-6`}; + ${tw`sw-right-0`}; + ${tw`sw-w-full`}; + ${tw`sw-box-border`}; + ${tw`sw-z-global-navbar`}; + + background: ${themeColor('mainBar')}; + border-bottom: ${themeBorder('default')}; + color: ${themeContrast('mainBar')}; + height: ${LAYOUT_GLOBAL_NAV_HEIGHT}px; +`; + +const MainAppBarNavLogoDiv = styled.div` + margin-right: ${LAYOUT_LOGO_MARGIN_RIGHT}px; + + img, + svg { + ${tw`sw-object-contain`}; + + max-height: ${LAYOUT_LOGO_MAX_HEIGHT}px; + max-width: ${LAYOUT_LOGO_MAX_WIDTH}px; + } +`; + +const MainAppBarNavLogoLink = styled.a` + border: none; +`; + +const MainAppBarNavRightDiv = styled.div` + flex-grow: 2; + height: 100%; +`; + +export function MainAppBar({ + children, + Logo, +}: React.PropsWithChildren<{ Logo: React.ElementType }>) { + return ( + + + + + + + + {children} + + + ); +} diff --git a/server/sonar-web/design-system/src/components/MainMenu.tsx b/server/sonar-web/design-system/src/components/MainMenu.tsx new file mode 100644 index 00000000000..e61964a77e3 --- /dev/null +++ b/server/sonar-web/design-system/src/components/MainMenu.tsx @@ -0,0 +1,30 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 tw from 'twin.macro'; + +const MainMenuUl = styled.ul` + ${tw`sw-flex sw-gap-8 sw-items-center`} +`; + +export function MainMenu({ children }: React.PropsWithChildren<{}>) { + return {children}; +} diff --git a/server/sonar-web/design-system/src/components/MainMenuItem.tsx b/server/sonar-web/design-system/src/components/MainMenuItem.tsx new file mode 100644 index 00000000000..9749ba9028a --- /dev/null +++ b/server/sonar-web/design-system/src/components/MainMenuItem.tsx @@ -0,0 +1,59 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 tw from 'twin.macro'; +import { LAYOUT_GLOBAL_NAV_HEIGHT } from '../helpers/constants'; +import { themeBorder, themeContrast } from '../helpers/theme'; + +export const MainMenuItem = styled.li` + & a { + ${tw`sw-block sw-box-border`}; + ${tw`sw-text-sm sw-font-semibold`}; + ${tw`sw-whitespace-nowrap`}; + ${tw`sw-no-underline`}; + ${tw`sw-select-none`}; + ${tw`sw-font-sans`}; + + color: ${themeContrast('mainBar')}; + letter-spacing: 0.03em; + line-height: calc(${LAYOUT_GLOBAL_NAV_HEIGHT}px - 3px); // - 3px border bottom + border-bottom: ${themeBorder('active', 'transparent', 1)}; + + &:visited { + border-bottom: ${themeBorder('active', 'transparent', 1)}; + color: ${themeContrast('mainBar')}; + } + + &:active, + &.active, + &:focus { + border-bottom: ${themeBorder('active', 'menuBorder', 1)}; + color: ${themeContrast('mainBar')}; + } + + &:hover, + &.hover, + &[aria-expanded='true'] { + border-bottom: ${themeBorder('active', 'menuBorder', 1)}; + color: ${themeContrast('mainBarHover')}; + } + } +`; diff --git a/server/sonar-web/design-system/src/components/NavLink.tsx b/server/sonar-web/design-system/src/components/NavLink.tsx new file mode 100644 index 00000000000..8075c5e182c --- /dev/null +++ b/server/sonar-web/design-system/src/components/NavLink.tsx @@ -0,0 +1,74 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 React from 'react'; +import { NavLink as RouterNavLink, NavLinkProps as RouterNavLinkProps } from 'react-router-dom'; + +export interface NavLinkProps extends RouterNavLinkProps { + blurAfterClick?: boolean; + disabled?: boolean; + onClick?: (event: React.MouseEvent) => void; + preventDefault?: boolean; + stopPropagation?: boolean; +} + +// Styling this component directly with Emotion should be avoided due to conflicts with react-router's classname. +// Use NavBarTabs as an example of this exception. +function NavLinkWithRef(props: NavLinkProps, ref: React.ForwardedRef) { + const { + blurAfterClick, + children, + disabled, + onClick, + preventDefault, + stopPropagation, + ...otherProps + } = props; + + const handleClick = React.useCallback( + (event: React.MouseEvent) => { + if (blurAfterClick) { + // explicitly lose focus after click + event.currentTarget.blur(); + } + + if (preventDefault || disabled) { + event.preventDefault(); + } + + if (stopPropagation) { + event.stopPropagation(); + } + + if (onClick && !disabled) { + onClick(event); + } + }, + [onClick, blurAfterClick, preventDefault, stopPropagation, disabled] + ); + + return ( + + {children} + + ); +} + +const NavLink = React.forwardRef(NavLinkWithRef); +export default NavLink; diff --git a/server/sonar-web/design-system/src/components/OutsideClickHandler.tsx b/server/sonar-web/design-system/src/components/OutsideClickHandler.tsx new file mode 100644 index 00000000000..07de4f5422f --- /dev/null +++ b/server/sonar-web/design-system/src/components/OutsideClickHandler.tsx @@ -0,0 +1,68 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 React from 'react'; +import { findDOMNode } from 'react-dom'; + +export type MouseEventListener = 'click' | 'mousedown'; +interface Props { + children: React.ReactNode; + listenerType?: MouseEventListener; + onClickOutside: () => void; +} + +export default class OutsideClickHandler extends React.Component { + mounted = false; + + componentDidMount() { + this.mounted = true; + setTimeout(() => { + this.addClickHandler(); + }, 0); + } + + componentWillUnmount() { + this.mounted = false; + this.removeClickHandler(); + } + + addClickHandler = () => { + const { listenerType = 'click' } = this.props; + window.addEventListener(listenerType, this.handleWindowClick); + }; + + removeClickHandler = () => { + const { listenerType = 'click' } = this.props; + window.removeEventListener(listenerType, this.handleWindowClick); + }; + + handleWindowClick = (event: MouseEvent) => { + if (this.mounted) { + // eslint-disable-next-line react/no-find-dom-node + const node = findDOMNode(this); + if (!node || !node.contains(event.target as Node)) { + this.props.onClickOutside(); + } + } + }; + + render() { + return this.props.children; + } +} diff --git a/server/sonar-web/design-system/src/components/RadioButton.tsx b/server/sonar-web/design-system/src/components/RadioButton.tsx new file mode 100644 index 00000000000..89858e73574 --- /dev/null +++ b/server/sonar-web/design-system/src/components/RadioButton.tsx @@ -0,0 +1,125 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 classNames from 'classnames'; +import React from 'react'; +import tw from 'twin.macro'; +import { themeBorder, themeColor } from '../helpers/theme'; + +type AllowedRadioButtonAttributes = Pick< + React.InputHTMLAttributes, + 'aria-label' | 'autoFocus' | 'id' | 'name' | 'style' | 'title' | 'type' +>; + +interface Props extends AllowedRadioButtonAttributes { + checked: boolean; + children?: React.ReactNode; + className?: string; + disabled?: boolean; + onCheck: (value: string) => void; + value: string; +} + +export default function RadioButton({ + checked, + children, + className, + disabled, + onCheck, + value, + ...htmlProps +}: Props) { + const handleChange = () => { + if (!disabled) { + onCheck(value); + } + }; + + return ( + + ); +} + +export const RadioButtonStyled = styled.input` + appearance: none; //disables native style + border: ${themeBorder('default', 'radioBorder')}; + + ${tw`sw-w-4 sw-min-w-4 sw-h-4 sw-min-h-4`} + ${tw`sw-p-1 sw-mr-2`} + ${tw`sw-inline-block`} + ${tw`sw-box-border`} + ${tw`sw-rounded-pill`} + + &:hover { + background: ${themeColor('radioHover')}; + } + + &:focus, + &:focus-visible { + background: ${themeColor('radioHover')}; + border: ${themeBorder('default', 'radioFocusBorder')}; + outline: ${themeBorder('focus', 'radioFocusOutline')}; + } + + &:focus:checked, + &:focus-visible:checked, + &:hover:checked, + &:checked { + // Color cannot be used with multiple backgrounds, only image is allowed + background-image: linear-gradient(to right, ${themeColor('radio')}, ${themeColor('radio')}), + linear-gradient(to right, ${themeColor('radioChecked')}, ${themeColor('radioChecked')}); + background-clip: content-box, padding-box; + border: ${themeBorder('default', 'radioBorder')}; + } + + &:disabled { + background: ${themeColor('radioDisabledBackground')}; + border: ${themeBorder('default', 'radioDisabledBorder')}; + background-clip: unset; + + ${tw`sw-cursor-not-allowed`} + + &:checked { + background-image: linear-gradient( + to right, + ${themeColor('radioDisabled')}, + ${themeColor('radioDisabled')} + ), + linear-gradient( + to right, + ${themeColor('radioDisabledBackground')}, + ${themeColor('radioDisabledBackground')} + ); + background-clip: content-box, padding-box; + border: ${themeBorder('default', 'radioDisabledBorder')}; + } + } +`; diff --git a/server/sonar-web/design-system/src/components/SonarQubeLogo.tsx b/server/sonar-web/design-system/src/components/SonarQubeLogo.tsx new file mode 100644 index 00000000000..fcbe6b22798 --- /dev/null +++ b/server/sonar-web/design-system/src/components/SonarQubeLogo.tsx @@ -0,0 +1,50 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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'; + +const SonarQubeLogoSvg = styled.svg` + height: 40px; + width: 132px; +`; + +export function SonarQubeLogo() { + return ( + + + + + + + ); +} diff --git a/server/sonar-web/design-system/src/components/Text.tsx b/server/sonar-web/design-system/src/components/Text.tsx new file mode 100644 index 00000000000..277f5eca4b5 --- /dev/null +++ b/server/sonar-web/design-system/src/components/Text.tsx @@ -0,0 +1,62 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 tw from 'twin.macro'; +import { themeColor, themeContrast } from '../helpers/theme'; + +interface MainTextProps { + match?: string; + name: string; +} + +export function SearchText({ match, name }: MainTextProps) { + return match ? ( + + ) : ( + {name} + ); +} + +export function TextMuted({ text }: { text: string }) { + return {text}; +} + +export const StyledText = styled.span` + ${tw`sw-inline-block`}; + ${tw`sw-truncate`}; + ${tw`sw-font-semibold`}; + ${tw`sw-max-w-abs-600`} + + mark { + ${tw`sw-inline-block`}; + + background: ${themeColor('searchHighlight')}; + color: ${themeContrast('searchHighlight')}; + } +`; + +const StyledMutedText = styled(StyledText)` + ${tw`sw-font-regular`}; + color: ${themeColor('dropdownMenuSubTitle')}; +`; diff --git a/server/sonar-web/design-system/src/components/Tooltip.tsx b/server/sonar-web/design-system/src/components/Tooltip.tsx new file mode 100644 index 00000000000..a298b7fdfd0 --- /dev/null +++ b/server/sonar-web/design-system/src/components/Tooltip.tsx @@ -0,0 +1,504 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { keyframes, ThemeContext } from '@emotion/react'; +import styled from '@emotion/styled'; +import classNames from 'classnames'; +import { throttle } from 'lodash'; +import React from 'react'; +import { createPortal, findDOMNode } from 'react-dom'; +import tw from 'twin.macro'; +import { THROTTLE_SCROLL_DELAY } from '../helpers/constants'; +import { + BasePlacement, + PLACEMENT_FLIP_MAP, + PopupPlacement, + popupPositioning, +} from '../helpers/positioning'; +import { themeColor, themeContrast } from '../helpers/theme'; + +const MILLISECONDS_IN_A_SECOND = 1000; + +export interface TooltipProps { + children: React.ReactElement<{}>; + mouseEnterDelay?: number; + mouseLeaveDelay?: number; + onHide?: VoidFunction; + onShow?: VoidFunction; + overlay: React.ReactNode; + placement?: BasePlacement; + visible?: boolean; +} + +interface Measurements { + height: number; + left: number; + leftFix: number; + top: number; + topFix: number; + width: number; +} + +interface OwnState { + flipped: boolean; + placement?: PopupPlacement; + visible: boolean; +} + +type State = OwnState & Partial; + +function isMeasured(state: State): state is OwnState & Measurements { + return state.height !== undefined; +} + +export default function Tooltip(props: TooltipProps) { + // overlay is a ReactNode, so it can be a boolean, `undefined` or `null` + // this allows to easily render a tooltip conditionally + // more generaly we avoid rendering empty tooltips + return props.overlay ? {props.children} : props.children; +} + +export class TooltipInner extends React.Component { + throttledPositionTooltip: VoidFunction; + mouseEnterTimeout?: number; + mouseLeaveTimeout?: number; + tooltipNode?: HTMLElement | null; + mounted = false; + mouseIn = false; + + static defaultProps = { + mouseEnterDelay: 0.1, + }; + + constructor(props: TooltipProps) { + super(props); + this.state = { + flipped: false, + placement: props.placement, + visible: props.visible !== undefined ? props.visible : false, + }; + this.throttledPositionTooltip = throttle(this.positionTooltip, THROTTLE_SCROLL_DELAY); + } + + componentDidMount() { + this.mounted = true; + if (this.props.visible === true) { + this.positionTooltip(); + this.addEventListeners(); + } + } + + componentDidUpdate(prevProps: TooltipProps, prevState: State) { + if (this.props.placement !== prevProps.placement) { + this.setState({ placement: this.props.placement }, () => + this.onUpdatePlacement(this.hasVisibleChanged(prevState.visible, prevProps.visible)) + ); + } else if (this.hasVisibleChanged(prevState.visible, prevProps.visible)) { + this.onUpdateVisible(); + } else if (!this.state.flipped && this.needsFlipping(this.state)) { + this.setState( + ({ placement = PopupPlacement.Bottom }) => ({ + flipped: true, + placement: PLACEMENT_FLIP_MAP[placement], + }), + () => { + if (this.state.visible) { + // Force a re-positioning, as "only" updating the state doesn't + // recompute the position, only re-renders with the previous + // position (which is no longer correct). + this.positionTooltip(); + } + } + ); + } + } + + componentWillUnmount() { + this.mounted = false; + this.removeEventListeners(); + this.clearTimeouts(); + } + + static contextType = ThemeContext; + + onUpdatePlacement = (visibleHasChanged: boolean) => { + this.setState({ placement: this.props.placement }, () => { + if (this.isVisible()) { + this.positionTooltip(); + if (visibleHasChanged) { + this.addEventListeners(); + } + } + }); + }; + + onUpdateVisible = () => { + if (this.isVisible()) { + this.positionTooltip(); + this.addEventListeners(); + } else { + this.clearPosition(); + this.removeEventListeners(); + } + }; + + addEventListeners = () => { + window.addEventListener('resize', this.throttledPositionTooltip); + window.addEventListener('scroll', this.throttledPositionTooltip); + }; + + removeEventListeners = () => { + window.removeEventListener('resize', this.throttledPositionTooltip); + window.removeEventListener('scroll', this.throttledPositionTooltip); + }; + + clearTimeouts = () => { + window.clearTimeout(this.mouseEnterTimeout); + window.clearTimeout(this.mouseLeaveTimeout); + }; + + hasVisibleChanged = (prevStateVisible: boolean, prevPropsVisible?: boolean) => { + if (this.props.visible === undefined) { + return prevPropsVisible || this.state.visible !== prevStateVisible; + } + return this.props.visible !== prevPropsVisible; + }; + + isVisible = () => { + return this.props.visible ?? this.state.visible; + }; + + getPlacement = (): PopupPlacement => { + return this.state.placement || PopupPlacement.Bottom; + }; + + tooltipNodeRef = (node: HTMLElement | null) => { + this.tooltipNode = node; + }; + + adjustArrowPosition = ( + placement: PopupPlacement, + { leftFix, topFix, height, width }: Measurements + ) => { + switch (placement) { + case PopupPlacement.Left: + case PopupPlacement.Right: + return { + marginTop: Math.max(0, Math.min(-topFix, height / 2 - ARROW_WIDTH * 2)), + }; + default: + return { + marginLeft: Math.max(0, Math.min(-leftFix, width / 2 - ARROW_WIDTH * 2)), + }; + } + }; + + positionTooltip = () => { + // `findDOMNode(this)` will search for the DOM node for the current component + // first it will find a React.Fragment (see `render`), + // so it will get the DOM node of the first child, i.e. DOM node of `this.props.children` + // docs: https://reactjs.org/docs/refs-and-the-dom.html#exposing-dom-refs-to-parent-components + + // eslint-disable-next-line react/no-find-dom-node + const toggleNode = findDOMNode(this); + if (toggleNode && toggleNode instanceof Element && this.tooltipNode) { + const { height, left, leftFix, top, topFix, width } = popupPositioning( + toggleNode, + this.tooltipNode, + this.getPlacement() + ); + + // save width and height (and later set in `render`) to avoid resizing the popup element, + // when it's placed close to the window edge + this.setState({ + left: window.scrollX + left, + leftFix, + top: window.scrollY + top, + topFix, + width, + height, + }); + } + }; + + clearPosition = () => { + this.setState({ + flipped: false, + left: undefined, + leftFix: undefined, + top: undefined, + topFix: undefined, + width: undefined, + height: undefined, + placement: this.props.placement, + }); + }; + + handlePointerEnter = () => { + this.mouseEnterTimeout = window.setTimeout(() => { + // for some reason even after the `this.mouseEnterTimeout` is cleared, it still triggers + // to workaround this issue, check that its value is not `undefined` + // (if it's `undefined`, it means the timer has been reset) + if ( + this.mounted && + this.props.visible === undefined && + this.mouseEnterTimeout !== undefined + ) { + this.setState({ visible: true }); + } + }, (this.props.mouseEnterDelay || 0) * MILLISECONDS_IN_A_SECOND); + + if (this.props.onShow) { + this.props.onShow(); + } + }; + + handlePointerLeave = () => { + if (this.mouseEnterTimeout !== undefined) { + window.clearTimeout(this.mouseEnterTimeout); + this.mouseEnterTimeout = undefined; + } + + if (!this.mouseIn) { + this.mouseLeaveTimeout = window.setTimeout(() => { + if (this.mounted && this.props.visible === undefined && !this.mouseIn) { + this.setState({ visible: false }); + } + }, (this.props.mouseLeaveDelay || 0) * MILLISECONDS_IN_A_SECOND); + + if (this.props.onHide) { + this.props.onHide(); + } + } + }; + + handleOverlayPointerEnter = () => { + this.mouseIn = true; + }; + + handleOverlayPointerLeave = () => { + this.mouseIn = false; + this.handlePointerLeave(); + }; + + handleChildPointerEnter = () => { + this.handlePointerEnter(); + + const { children } = this.props; + if (typeof children.props.onPointerEnter === 'function') { + children.props.onPointerEnter(); + } + }; + + handleChildPointerLeave = () => { + this.handlePointerLeave(); + + const { children } = this.props; + if (typeof children.props.onPointerLeave === 'function') { + children.props.onPointerLeave(); + } + }; + + needsFlipping = ({ leftFix, topFix }: State) => { + // We can live with a tooltip that's slightly positioned over the toggle + // node. Only trigger if it really starts overlapping, as the re-positioning + // is quite expensive, needing 2 re-renders. + const repositioningThreshold = 8; + switch (this.getPlacement()) { + case PopupPlacement.Left: + case PopupPlacement.Right: + return Boolean(leftFix && Math.abs(leftFix) > repositioningThreshold); + case PopupPlacement.Top: + case PopupPlacement.Bottom: + return Boolean(topFix && Math.abs(topFix) > repositioningThreshold); + default: + return false; + } + }; + + render() { + const placement = this.getPlacement(); + const style = isMeasured(this.state) + ? { + left: this.state.left, + top: this.state.top, + width: this.state.width, + height: this.state.height, + } + : undefined; + + return ( + <> + {React.cloneElement(this.props.children, { + onPointerEnter: this.handleChildPointerEnter, + onPointerLeave: this.handleChildPointerLeave, + })} + {this.isVisible() && ( + + + {this.props.overlay} + + + + )} + + ); + } +} + +class TooltipPortal extends React.Component { + el: HTMLElement; + + constructor(props: {}) { + super(props); + this.el = document.createElement('div'); + } + + componentDidMount() { + document.body.appendChild(this.el); + } + + componentWillUnmount() { + document.body.removeChild(this.el); + } + + render() { + return createPortal(this.props.children, this.el); + } +} + +const fadeIn = keyframes` + from { + opacity: 0; + } + + to { + opacity: 1; + } +`; + +const ARROW_WIDTH = 6; +const ARROW_HEIGHT = 7; +const ARROW_MARGIN = 3; + +export const TooltipWrapper = styled.div` + animation: ${fadeIn} 0.3s forwards; + + ${tw`sw-absolute`} + ${tw`sw-z-tooltip`}; + ${tw`sw-block`}; + ${tw`sw-box-border`}; + ${tw`sw-h-auto`}; + ${tw`sw-body-sm`}; + + &.top { + margin-top: -${ARROW_MARGIN}px; + padding: ${ARROW_HEIGHT}px 0; + } + + &.right { + margin-left: ${ARROW_MARGIN}px; + padding: 0 ${ARROW_HEIGHT}px; + } + + &.bottom { + margin-top: ${ARROW_MARGIN}px; + padding: ${ARROW_HEIGHT}px 0; + } + + &.left { + margin-left: -${ARROW_MARGIN}px; + padding: 0 ${ARROW_HEIGHT}px; + } +`; + +const TooltipWrapperArrow = styled.div` + ${tw`sw-absolute`}; + ${tw`sw-w-0`}; + ${tw`sw-h-0`}; + ${tw`sw-border-solid`}; + ${tw`sw-border-transparent`}; + ${TooltipWrapper}.top & { + border-width: ${ARROW_HEIGHT}px ${ARROW_WIDTH}px 0; + border-top-color: ${themeColor('tooltipBackground')}; + transform: translateX(-${ARROW_WIDTH}px); + + ${tw`sw-bottom-0`}; + ${tw`sw-left-1/2`}; + } + + ${TooltipWrapper}.right & { + border-width: ${ARROW_WIDTH}px ${ARROW_HEIGHT}px ${ARROW_WIDTH}px 0; + border-right-color: ${themeColor('tooltipBackground')}; + transform: translateY(-${ARROW_WIDTH}px); + + ${tw`sw-top-1/2`}; + ${tw`sw-left-0`}; + } + + ${TooltipWrapper}.left & { + border-width: ${ARROW_WIDTH}px 0 ${ARROW_WIDTH}px ${ARROW_HEIGHT}px; + border-left-color: ${themeColor('tooltipBackground')}; + transform: translateY(-${ARROW_WIDTH}px); + + ${tw`sw-top-1/2`}; + ${tw`sw-right-0`}; + } + + ${TooltipWrapper}.bottom & { + border-width: 0 ${ARROW_WIDTH}px ${ARROW_HEIGHT}px; + border-bottom-color: ${themeColor('tooltipBackground')}; + transform: translateX(-${ARROW_WIDTH}px); + + ${tw`sw-top-0`}; + ${tw`sw-left-1/2`}; + } +`; + +export const TooltipWrapperInner = styled.div` + color: ${themeContrast('tooltipBackground')}; + background-color: ${themeColor('tooltipBackground')}; + + ${tw`sw-max-w-[22rem]`} + ${tw`sw-py-3 sw-px-4`}; + ${tw`sw-overflow-hidden`}; + ${tw`sw-text-left`}; + ${tw`sw-no-underline`}; + ${tw`sw-break-words`}; + ${tw`sw-rounded-2`}; + + hr { + background-color: ${themeColor('tooltipSeparator')}; + + ${tw`sw-mx-4`}; + } +`; diff --git a/server/sonar-web/design-system/src/components/__tests__/Avatar-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Avatar-test.tsx new file mode 100644 index 00000000000..d0aa180d0fa --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/Avatar-test.tsx @@ -0,0 +1,69 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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. + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +import { fireEvent, screen } from '@testing-library/react'; +import { render } from '../../helpers/testUtils'; +import { FCProps } from '../../types/misc'; +import { Avatar } from '../Avatar'; + +const gravatarServerUrl = 'http://example.com/{EMAIL_MD5}.jpg?s={SIZE}'; + +it('should render avatar with border', () => { + setupWithProps({ border: true, hash: '7daf6c79d4802916d83f6266e24850af' }); + expect(screen.getByRole('img')).toHaveStyle('border: 1px solid rgb(225,230,243)'); +}); + +it('should be able to render with hash only', () => { + setupWithProps({ hash: '7daf6c79d4802916d83f6266e24850af' }); + expect(screen.getByRole('img')).toHaveAttribute( + 'src', + 'http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=48' + ); +}); + +it('should fall back to generated on error', () => { + setupWithProps({ hash: '7daf6c79d4802916d83f6266e24850af' }); + fireEvent(screen.getByRole('img'), new Event('error')); + expect(screen.getByRole('img')).not.toHaveAttribute('src'); +}); + +it('should fall back to dummy avatar', () => { + setupWithProps({ enableGravatar: false }); + expect(screen.getByRole('img')).not.toHaveAttribute('src'); +}); + +it('should return null if no name is set', () => { + setupWithProps({ name: undefined }); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); +}); + +it('should display organization avatar correctly', () => { + const avatar = 'http://example.com/avatar.png'; + setupWithProps({ organizationAvatar: avatar, organizationName: 'my-org' }); + expect(screen.getByRole('img')).toHaveAttribute('src', avatar); +}); + +function setupWithProps(props: Partial> = {}) { + return render( + + ); +} diff --git a/server/sonar-web/design-system/src/components/__tests__/DeferredSpinner-test.tsx b/server/sonar-web/design-system/src/components/__tests__/DeferredSpinner-test.tsx new file mode 100644 index 00000000000..d6b7c43d467 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/DeferredSpinner-test.tsx @@ -0,0 +1,73 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { render, screen } from '@testing-library/react'; +import * as React from 'react'; +import DeferredSpinner from '../DeferredSpinner'; + +beforeAll(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.runOnlyPendingTimers(); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +it('renders children before timeout', () => { + renderDeferredSpinner({ children: foo }); + expect(screen.getByRole('link')).toBeInTheDocument(); + jest.runAllTimers(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); +}); + +it('renders spinner after timeout', () => { + renderDeferredSpinner(); + expect(screen.queryByLabelText('loading')).not.toBeInTheDocument(); + jest.runAllTimers(); + expect(screen.getByLabelText('loading')).toBeInTheDocument(); +}); + +it('allows setting a custom class name', () => { + renderDeferredSpinner({ className: 'foo' }); + jest.runAllTimers(); + expect(screen.getByLabelText('loading')).toHaveClass('foo'); +}); + +it('can be controlled by the loading prop', () => { + const { rerender } = renderDeferredSpinner({ loading: true }); + jest.runAllTimers(); + expect(screen.getByLabelText('loading')).toBeInTheDocument(); + + rerender(prepareDeferredSpinner({ loading: false })); + expect(screen.queryByLabelText('loading')).not.toBeInTheDocument(); +}); + +function renderDeferredSpinner(props: Partial = {}) { + // We don't use our renderComponent() helper here, as we have some tests that + // require changes in props. + return render(prepareDeferredSpinner(props)); +} + +function prepareDeferredSpinner(props: Partial = {}) { + return ; +} diff --git a/server/sonar-web/design-system/src/components/__tests__/Dropdown-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Dropdown-test.tsx new file mode 100644 index 00000000000..52139a0489d --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/Dropdown-test.tsx @@ -0,0 +1,65 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { screen } from '@testing-library/react'; +import { renderWithRouter } from '../../helpers/testUtils'; +import { ButtonSecondary } from '../buttons'; +import Dropdown, { ActionsDropdown } from '../Dropdown'; + +describe('Dropdown', () => { + it('renders', async () => { + const { user } = setupWithChildren(); + expect(screen.getByRole('button')).toBeInTheDocument(); + + await user.click(screen.getByRole('button')); + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); + + it('toggles with render prop', async () => { + const { user } = setupWithChildren(({ onToggleClick }) => ( + + )); + + await user.click(screen.getByRole('button')); + expect(screen.getByRole('menu')).toBeVisible(); + }); + + function setupWithChildren(children?: Dropdown['props']['children']) { + return renderWithRouter( + }> + {children ?? } + + ); + } +}); + +describe('ActionsDropdown', () => { + it('renders', () => { + setup(); + expect(screen.getByRole('button')).toHaveAccessibleName('menu'); + }); + + function setup() { + return renderWithRouter( + +
    + + ); + } +}); diff --git a/server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx b/server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx new file mode 100644 index 00000000000..350c6874e22 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx @@ -0,0 +1,100 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { screen } from '@testing-library/react'; +import { noop } from 'lodash'; +import { render, renderWithRouter } from '../../helpers/testUtils'; +import { + DropdownMenu, + ItemButton, + ItemCheckbox, + ItemCopy, + ItemDangerButton, + ItemDivider, + ItemHeader, + ItemLink, + ItemNavLink, + ItemRadioButton, +} from '../DropdownMenu'; +import MenuIcon from '../icons/MenuIcon'; +import Tooltip from '../Tooltip'; + +beforeEach(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); +}); + +it('should render a full menu correctly', () => { + renderDropdownMenu(); + expect(screen.getByRole('menuitem', { name: 'My header' })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Test menu item' })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Test disabled item' })).toHaveClass('disabled'); +}); + +it('menu items should work with tooltips', async () => { + const { user } = render( + + button + , + {}, + { delay: null } + ); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + + await user.hover(screen.getByRole('menuitem')); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + + jest.runAllTimers(); + expect(screen.getByRole('tooltip')).toBeVisible(); + + await user.unhover(screen.getByRole('menuitem')); + expect(screen.getByRole('tooltip')).toBeVisible(); + + jest.runAllTimers(); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); +}); + +function renderDropdownMenu() { + return renderWithRouter( + + My header + Test menu item + + + Test disabled item + + } onClick={noop}> + Button + + DangerButton + Copy + + Checkbox item + + + Radio item + + + ); +} diff --git a/server/sonar-web/design-system/src/components/__tests__/GenericAvatar-test.tsx b/server/sonar-web/design-system/src/components/__tests__/GenericAvatar-test.tsx new file mode 100644 index 00000000000..83b7fdf6bc3 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/GenericAvatar-test.tsx @@ -0,0 +1,51 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { screen } from '@testing-library/react'; +import { render } from '../../helpers/testUtils'; +import { GenericAvatar } from '../GenericAvatar'; +import { CustomIcon, IconProps } from '../icons/Icon'; + +function TestIcon(props: IconProps) { + return ( + + + + ); +} + +it('should render single word and size', () => { + render(); + const image = screen.getByRole('img'); + expect(image).toHaveAttribute('size', '15'); + expect(screen.getByText('F')).toBeInTheDocument(); +}); + +it('should render multiple word with default size', () => { + render(); + const image = screen.getByRole('img'); + expect(image).toHaveAttribute('size', '24'); + expect(screen.getByText('F')).toBeInTheDocument(); +}); + +it('should render without name', () => { + render(); + const image = screen.getByRole('img'); + expect(image).toHaveAttribute('size', '32'); +}); diff --git a/server/sonar-web/design-system/src/components/__tests__/InputSearch-test.tsx b/server/sonar-web/design-system/src/components/__tests__/InputSearch-test.tsx new file mode 100644 index 00000000000..1d9f6068e56 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/InputSearch-test.tsx @@ -0,0 +1,90 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { screen, waitFor } from '@testing-library/react'; +import { render } from '../../helpers/testUtils'; +import { FCProps } from '../../types/misc'; +import InputSearch from '../InputSearch'; + +it('should warn when input is too short', async () => { + const { user } = setupWithProps({ value: 'f' }); + expect(screen.getByRole('note')).toBeInTheDocument(); + await user.type(screen.getByRole('searchbox'), 'oo'); + expect(screen.queryByRole('note')).not.toBeInTheDocument(); +}); + +it('should show clear button only when there is a value', async () => { + const { user } = setupWithProps({ value: 'f' }); + expect(screen.getByRole('button')).toBeInTheDocument(); + await user.clear(screen.getByRole('searchbox')); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); +}); + +it('should attach ref', () => { + const ref = jest.fn(); + setupWithProps({ innerRef: ref }); + expect(ref).toHaveBeenCalled(); + expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLInputElement); +}); + +it('should trigger reset correctly with clear button', async () => { + const onChange = jest.fn(); + const { user } = setupWithProps({ onChange }); + await user.click(screen.getByRole('button')); + expect(onChange).toHaveBeenCalledWith(''); +}); + +it('should trigger change correctly', async () => { + const onChange = jest.fn(); + const { user } = setupWithProps({ onChange, value: 'f' }); + await user.type(screen.getByRole('searchbox'), 'oo'); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith('foo'); + }); +}); + +it('should not change when value is too short', async () => { + const onChange = jest.fn(); + const { user } = setupWithProps({ onChange, value: '', minLength: 3 }); + await user.type(screen.getByRole('searchbox'), 'fo'); + expect(onChange).not.toHaveBeenCalled(); +}); + +it('should clear input using escape', async () => { + const onChange = jest.fn(); + const { user } = setupWithProps({ onChange, value: 'foo' }); + await user.type(screen.getByRole('searchbox'), '{Escape}'); + expect(onChange).toHaveBeenCalledWith(''); +}); + +function setupWithProps(props: Partial> = {}) { + return render( + + ); +} diff --git a/server/sonar-web/design-system/src/components/__tests__/Link-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Link-test.tsx new file mode 100644 index 00000000000..295469720f0 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/Link-test.tsx @@ -0,0 +1,129 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { screen } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { render } from '../../helpers/testUtils'; +import Link, { DiscreetLink } from '../Link'; + +beforeAll(() => { + const { location } = window; + delete (window as any).location; + window.location = { ...location, href: '' }; +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +// This functionality won't be needed once we update the breadcrumbs +it('should remove focus after link is clicked', async () => { + const { user } = setupWithMemoryRouter( + Icon
    } to="/initial" /> + ); + + await user.click(screen.getByRole('link')); + + expect(screen.getByRole('link')).not.toHaveFocus(); +}); + +it('should prevent default when preventDefault is true', async () => { + const { user } = setupWithMemoryRouter(); + + expect(screen.getByText('/initial')).toBeVisible(); + + await user.click(screen.getByRole('link')); + + // prevent default behavior of page navigation + expect(screen.getByText('/initial')).toBeVisible(); + expect(screen.queryByText('/second')).not.toBeInTheDocument(); +}); + +it('should stop propagation when stopPropagation is true', async () => { + const buttonOnClick = jest.fn(); + + const { user } = setupWithMemoryRouter( + + ); + + await user.click(screen.getByRole('link')); + + expect(buttonOnClick).not.toHaveBeenCalled(); +}); + +it('should call onClick when one is passed', async () => { + const onClick = jest.fn(); + const { user } = setupWithMemoryRouter( + + ); + + await user.click(screen.getByRole('link')); + + expect(onClick).toHaveBeenCalled(); +}); + +it('internal link should be clickable', async () => { + const { user } = setupWithMemoryRouter(internal link); + expect(screen.getByRole('link')).toBeVisible(); + + await user.click(screen.getByRole('link')); + + expect(screen.getByText('/second')).toBeVisible(); +}); + +it('external links are indicated by OpenNewTabIcon', () => { + setupWithMemoryRouter(external link); + expect(screen.getByRole('link')).toBeVisible(); + + expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument(); +}); + +it('discreet links also can be external indicated by the OpenNewTabIcon', () => { + setupWithMemoryRouter(external link); + expect(screen.getByRole('link')).toBeVisible(); + + expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument(); +}); + +function ShowPath() { + const { pathname } = useLocation(); + return
    {pathname}
    ; +} + +const setupWithMemoryRouter = (component: JSX.Element, initialEntries = ['/initial']) => { + return render( + + + + {component} + + + } + path="/initial" + /> + } path="/second" /> + + + ); +}; diff --git a/server/sonar-web/design-system/src/components/__tests__/MainAppBar-test.tsx b/server/sonar-web/design-system/src/components/__tests__/MainAppBar-test.tsx new file mode 100644 index 00000000000..fdc66f2a441 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/MainAppBar-test.tsx @@ -0,0 +1,54 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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. + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +import { screen } from '@testing-library/react'; +import { LAYOUT_LOGO_MAX_HEIGHT, LAYOUT_LOGO_MAX_WIDTH } from '../../helpers/constants'; +import { render } from '../../helpers/testUtils'; +import { FCProps } from '../../types/misc'; +import { MainAppBar } from '../MainAppBar'; +import { SonarQubeLogo } from '../SonarQubeLogo'; + +it('should render the main app bar with max-height and max-width constraints on the logo', () => { + setupWithProps(); + + expect(screen.getByRole('img')).toHaveStyle({ + border: 'none', + 'max-height': `${LAYOUT_LOGO_MAX_HEIGHT}px`, + 'max-width': `${LAYOUT_LOGO_MAX_WIDTH}px`, + 'object-fit': 'contain', + }); +}); + +it('should render the logo', () => { + const element = setupWithProps({ Logo: SonarQubeLogo }); + + // eslint-disable-next-line testing-library/no-node-access + expect(element.container.querySelector('svg')).toHaveStyle({ height: '40px', width: '132px' }); +}); + +function setupWithProps( + props: FCProps = { + Logo: () => logo, + } +) { + return render(); +} diff --git a/server/sonar-web/design-system/src/components/__tests__/MainMenuItem-test.tsx b/server/sonar-web/design-system/src/components/__tests__/MainMenuItem-test.tsx new file mode 100644 index 00000000000..b3120afbe71 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/MainMenuItem-test.tsx @@ -0,0 +1,64 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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. + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +import { screen } from '@testing-library/react'; +import { render } from '../../helpers/testUtils'; +import { MainMenuItem } from '../MainMenuItem'; + +it('should render default', () => { + render( + + Hi + + ); + + expect(screen.getByText('Hi')).toHaveStyle({ + color: 'rgb(62, 67, 87)', + 'border-bottom': '3px solid transparent', + }); +}); + +it('should render active link', () => { + render( + + Hi + + ); + + expect(screen.getByText('Hi')).toHaveStyle({ + color: 'rgb(62, 67, 87)', + 'border-bottom': '3px solid rgba(123,135,217,1)', + }); +}); + +it('should render hovered link', () => { + render( + + Hi + + ); + + expect(screen.getByText('Hi')).toHaveStyle({ + color: 'rgb(42, 47, 64)', + 'border-bottom': '3px solid rgba(123,135,217,1)', + }); +}); diff --git a/server/sonar-web/design-system/src/components/__tests__/NavLink-test.tsx b/server/sonar-web/design-system/src/components/__tests__/NavLink-test.tsx new file mode 100644 index 00000000000..548cfb6c238 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/NavLink-test.tsx @@ -0,0 +1,112 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { screen } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { render } from '../../helpers/testUtils'; +import NavLink from '../NavLink'; + +beforeAll(() => { + const { location } = window; + delete (window as any).location; + window.location = { ...location, href: '' }; +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('should remove focus after link is clicked', async () => { + const { user } = setupWithMemoryRouter(); + + await user.click(screen.getByRole('link')); + + expect(screen.getByRole('link')).not.toHaveFocus(); +}); + +it('should prevent default when preventDefault is true', async () => { + const { user } = setupWithMemoryRouter(); + + expect(screen.getByText('/initial')).toBeVisible(); + + await user.click(screen.getByRole('link')); + + // prevent default behavior of page navigation + expect(screen.getByText('/initial')).toBeVisible(); + expect(screen.queryByText('/second')).not.toBeInTheDocument(); +}); + +it('should stop propagation when stopPropagation is true', async () => { + const buttonOnClick = jest.fn(); + + const { user } = setupWithMemoryRouter( + + ); + + await user.click(screen.getByRole('link')); + + expect(buttonOnClick).not.toHaveBeenCalled(); +}); + +it('should call onClick when one is passed', async () => { + const onClick = jest.fn(); + const { user } = setupWithMemoryRouter( + + ); + + await user.click(screen.getByRole('link')); + + expect(onClick).toHaveBeenCalled(); +}); + +it('NavLink should be clickable', async () => { + const { user } = setupWithMemoryRouter(internal link); + expect(screen.getByRole('link')).toBeVisible(); + + await user.click(screen.getByRole('link')); + + expect(screen.getByText('/second')).toBeVisible(); +}); + +function ShowPath() { + const { pathname } = useLocation(); + return
    {pathname}
    ; +} + +const setupWithMemoryRouter = (component: JSX.Element, initialEntries = ['/initial']) => { + return render( + + + + {component} + + + } + path="/initial" + /> + } path="/second" /> + + + ); +}; diff --git a/server/sonar-web/design-system/src/components/__tests__/Text-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Text-test.tsx new file mode 100644 index 00000000000..5743a92a7b0 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/Text-test.tsx @@ -0,0 +1,41 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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. + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +import { screen } from '@testing-library/react'; +import { render } from '../../helpers/testUtils'; +import { SearchText, TextMuted } from '../Text'; + +it('should render SearchText', () => { + render(); + + expect(screen.getByText('hi')).toHaveStyle({ + 'font-weight': '600', + }); +}); + +it('should render TextMuted', () => { + render(); + + expect(screen.getByText('Hi')).toHaveStyle({ + color: 'rgb(106, 117, 144)', + }); +}); diff --git a/server/sonar-web/design-system/src/components/__tests__/Tooltip-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Tooltip-test.tsx new file mode 100644 index 00000000000..8b448d17521 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/Tooltip-test.tsx @@ -0,0 +1,126 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { screen } from '@testing-library/react'; +import { render } from '../../helpers/testUtils'; +import { FCProps } from '../../types/misc'; +import Tooltip, { TooltipInner } from '../Tooltip'; + +jest.mock('react-dom', () => { + const reactDom = jest.requireActual('react-dom'); + return { ...reactDom, findDOMNode: jest.fn().mockReturnValue(undefined) }; +}); + +describe('TooltipInner', () => { + it('should open & close', async () => { + const onShow = jest.fn(); + const onHide = jest.fn(); + const { user } = setupWithProps({ onHide, onShow }); + + await user.hover(screen.getByRole('note')); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); + expect(onShow).toHaveBeenCalled(); + + await user.unhover(screen.getByRole('note')); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + expect(onHide).toHaveBeenCalled(); + }); + + it('should not shadow children pointer events', async () => { + const onShow = jest.fn(); + const onHide = jest.fn(); + const onPointerEnter = jest.fn(); + const onPointerLeave = jest.fn(); + const { user } = setupWithProps( + { onHide, onShow }, +
    + ); + + await user.hover(screen.getByRole('note')); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); + expect(onShow).toHaveBeenCalled(); + expect(onPointerEnter).toHaveBeenCalled(); + + await user.unhover(screen.getByRole('note')); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + expect(onHide).toHaveBeenCalled(); + expect(onPointerLeave).toHaveBeenCalled(); + }); + + it('should not open when mouse goes away quickly', async () => { + const { user } = setupWithProps(); + + await user.hover(screen.getByRole('note')); + await user.unhover(screen.getByRole('note')); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('should position the tooltip correctly', async () => { + const onShow = jest.fn(); + const onHide = jest.fn(); + const { user } = setupWithProps({ onHide, onShow }); + + await user.hover(screen.getByRole('note')); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); + expect(screen.getByRole('tooltip')).toHaveClass('bottom'); + }); + + function setupWithProps( + props: Partial = {}, + children =
    + ) { + return render( + } {...props}> + {children} + + ); + } +}); + +describe('Tooltip', () => { + it('should not render tooltip without overlay', async () => { + const { user } = setupWithProps({ overlay: undefined }); + await user.hover(screen.getByRole('note')); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('should not render undefined tooltips', async () => { + const { user } = setupWithProps({ overlay: undefined, visible: true }); + await user.hover(screen.getByRole('note')); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('should not render empty tooltips', async () => { + const { user } = setupWithProps({ overlay: '', visible: true }); + await user.hover(screen.getByRole('note')); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + function setupWithProps( + props: Partial> = {}, + children =
    + ) { + return render( + } {...props}> + {children} + + ); + } +}); diff --git a/server/sonar-web/design-system/src/components/__tests__/clipboard-test.tsx b/server/sonar-web/design-system/src/components/__tests__/clipboard-test.tsx new file mode 100644 index 00000000000..84a44f103b1 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/clipboard-test.tsx @@ -0,0 +1,69 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { screen, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithContext } from '../../helpers/testUtils'; +import { ClipboardButton, ClipboardIconButton } from '../clipboard'; + +beforeEach(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); +}); + +describe('ClipboardButton', () => { + it('should display correctly', async () => { + /* Delay: null is necessary to play well with fake timers + * https://github.com/testing-library/user-event/issues/833 + */ + const user = userEvent.setup({ delay: null }); + renderClipboardButton(); + + expect(screen.getByRole('button', { name: 'copy' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'copy' })); + + expect(await screen.findByText('copied_action')).toBeVisible(); + + await waitForElementToBeRemoved(() => screen.queryByText('copied_action')); + jest.runAllTimers(); + }); + + it('should render a custom label if provided', () => { + renderClipboardButton('Foo Bar'); + expect(screen.getByRole('button', { name: 'Foo Bar' })).toBeInTheDocument(); + }); + + function renderClipboardButton(children?: React.ReactNode) { + renderWithContext({children}); + } +}); + +describe('ClipboardIconButton', () => { + it('should display correctly', () => { + renderWithContext(); + + const copyButton = screen.getByRole('button', { name: 'copy_to_clipboard' }); + expect(copyButton).toBeInTheDocument(); + }); +}); diff --git a/server/sonar-web/design-system/src/components/buttons.tsx b/server/sonar-web/design-system/src/components/buttons.tsx new file mode 100644 index 00000000000..442026354ea --- /dev/null +++ b/server/sonar-web/design-system/src/components/buttons.tsx @@ -0,0 +1,219 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import React from 'react'; +import tw from 'twin.macro'; +import { themeBorder, themeColor, themeContrast } from '../helpers/theme'; +import { ThemedProps } from '../types/theme'; +import { BaseLink, LinkProps } from './Link'; + +type AllowedButtonAttributes = Pick< + React.ButtonHTMLAttributes, + 'aria-label' | 'autoFocus' | 'id' | 'name' | 'style' | 'title' | 'type' +>; + +export interface ButtonProps extends AllowedButtonAttributes { + children?: React.ReactNode; + className?: string; + disabled?: boolean; + icon?: React.ReactNode; + innerRef?: React.Ref; + onClick?: VoidFunction; + + preventDefault?: boolean; + reloadDocument?: LinkProps['reloadDocument']; + stopPropagation?: boolean; + target?: LinkProps['target']; + to?: LinkProps['to']; +} + +class Button extends React.PureComponent { + handleClick = (event: React.MouseEvent) => { + const { disabled, onClick, stopPropagation = false, type } = this.props; + const { preventDefault = type !== 'submit' } = this.props; + + event.currentTarget.blur(); + + if (preventDefault || disabled) { + event.preventDefault(); + } + + if (stopPropagation) { + event.stopPropagation(); + } + + if (onClick && !disabled) { + onClick(); + } + }; + + render() { + const { + children, + disabled, + icon, + innerRef, + onClick, + preventDefault, + stopPropagation, + to, + type = 'button', + ...htmlProps + } = this.props; + + const props = { + ...htmlProps, + 'aria-disabled': disabled, + disabled, + type, + }; + + if (to) { + return ( + + {icon} + {children} + + ); + } + + return ( + + {icon} + {children} + + ); + } +} + +const buttonStyle = (props: ThemedProps) => css` + box-sizing: border-box; + text-decoration: none; + outline: none; + border: var(--border); + color: var(--color); + background-color: var(--background); + transition: background-color 0.2s ease, outline 0.2s ease; + + ${tw`sw-inline-flex sw-items-center`} + ${tw`sw-h-control`} + ${tw`sw-body-sm-highlight`} + ${tw`sw-py-2 sw-px-4`} + ${tw`sw-rounded-2`} + ${tw`sw-cursor-pointer`} + + &:hover { + color: var(--color); + background-color: var(--backgroundHover); + } + + &:focus, + &:active { + color: var(--color); + outline: ${themeBorder('focus', 'var(--focus)')(props)}; + } + + &:disabled, + &:disabled:hover { + color: ${themeContrast('buttonDisabled')(props)}; + background-color: ${themeColor('buttonDisabled')(props)}; + border: ${themeBorder('default', 'buttonDisabledBorder')(props)}; + + ${tw`sw-cursor-not-allowed`} + } + + & > svg { + ${tw`sw-mr-1`} + } +`; + +const BaseButtonLink = styled(BaseLink)` + ${buttonStyle} +`; + +const BaseButton = styled.button` + ${buttonStyle} + + /* Workaround for tooltips issue with onMouseLeave in disabled buttons: https://github.com/facebook/react/issues/4251 */ + & [disabled] { + ${tw`sw-pointer-events-none`}; + } +`; + +export const ButtonPrimary: React.FC = styled(Button)` + --background: ${themeColor('button')}; + --backgroundHover: ${themeColor('buttonHover')}; + --color: ${themeContrast('primary')}; + --focus: ${themeColor('button', 0.2)}; + --border: ${themeBorder('default', 'transparent')}; +`; + +export const ButtonSecondary: React.FC = styled(Button)` + --background: ${themeColor('buttonSecondary')}; + --backgroundHover: ${themeColor('buttonSecondaryHover')}; + --color: ${themeContrast('buttonSecondary')}; + --focus: ${themeColor('buttonSecondaryBorder', 0.2)}; + --border: ${themeBorder('default', 'buttonSecondaryBorder')}; +`; + +export const DangerButtonPrimary: React.FC = styled(Button)` + --background: ${themeColor('dangerButton')}; + --backgroundHover: ${themeColor('dangerButtonHover')}; + --color: ${themeContrast('dangerButton')}; + --focus: ${themeColor('dangerButtonFocus', 0.2)}; + --border: ${themeBorder('default', 'transparent')}; +`; + +export const DangerButtonSecondary: React.FC = styled(Button)` + --background: ${themeColor('dangerButtonSecondary')}; + --backgroundHover: ${themeColor('dangerButtonSecondaryHover')}; + --color: ${themeContrast('dangerButtonSecondary')}; + --focus: ${themeColor('dangerButtonSecondaryFocus', 0.2)}; + --border: ${themeBorder('default', 'dangerButtonSecondaryBorder')}; +`; + +interface ThirdPartyProps extends Omit { + iconPath: string; + name: string; +} + +export function ThirdPartyButton({ children, iconPath, name, ...buttonProps }: ThirdPartyProps) { + const size = 16; + return ( + + {name} + {children} + + ); +} + +const ThirdPartyButtonStyled: React.FC = styled(Button)` + --background: ${themeColor('thirdPartyButton')}; + --backgroundHover: ${themeColor('thirdPartyButtonHover')}; + --color: ${themeContrast('thirdPartyButton')}; + --focus: ${themeColor('thirdPartyButtonBorder', 0.2)}; + --border: ${themeBorder('default', 'thirdPartyButtonBorder')}; +`; + +export const BareButton = styled.button` + all: unset; + cursor: pointer; +`; diff --git a/server/sonar-web/design-system/src/components/clipboard.tsx b/server/sonar-web/design-system/src/components/clipboard.tsx new file mode 100644 index 00000000000..ea05963f772 --- /dev/null +++ b/server/sonar-web/design-system/src/components/clipboard.tsx @@ -0,0 +1,170 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 classNames from 'classnames'; +import Clipboard from 'clipboard'; +import React from 'react'; +import { INTERACTIVE_TOOLTIP_DELAY } from '../helpers/constants'; +import { translate } from '../helpers/l10n'; +import { ButtonSecondary } from './buttons'; +import CopyIcon from './icons/CopyIcon'; +import { IconProps } from './icons/Icon'; +import { DiscreetInteractiveIcon, InteractiveIcon, InteractiveIconSize } from './InteractiveIcon'; +import Tooltip from './Tooltip'; + +const COPY_SUCCESS_NOTIFICATION_LIFESPAN = 1000; + +export interface State { + copySuccess: boolean; +} + +interface RenderProps { + copySuccess: boolean; + setCopyButton: (node: HTMLElement | null) => void; +} + +interface BaseProps { + children: (props: RenderProps) => React.ReactNode; +} + +export class ClipboardBase extends React.PureComponent { + private clipboard?: Clipboard; + private copyButton?: HTMLElement | null; + mounted = false; + state: State = { copySuccess: false }; + + componentDidMount() { + this.mounted = true; + if (this.copyButton) { + this.clipboard = new Clipboard(this.copyButton); + this.clipboard.on('success', this.handleSuccessCopy); + } + } + + componentDidUpdate() { + if (this.clipboard) { + this.clipboard.destroy(); + } + if (this.copyButton) { + this.clipboard = new Clipboard(this.copyButton); + this.clipboard.on('success', this.handleSuccessCopy); + } + } + + componentWillUnmount() { + this.mounted = false; + if (this.clipboard) { + this.clipboard.destroy(); + } + } + + setCopyButton = (node: HTMLElement | null) => { + this.copyButton = node; + }; + + handleSuccessCopy = () => { + if (this.mounted) { + this.setState({ copySuccess: true }); + setTimeout(() => { + if (this.mounted) { + this.setState({ copySuccess: false }); + } + }, COPY_SUCCESS_NOTIFICATION_LIFESPAN); + } + }; + + render() { + return this.props.children({ + setCopyButton: this.setCopyButton, + copySuccess: this.state.copySuccess, + }); + } +} + +interface ButtonProps { + children?: React.ReactNode; + className?: string; + copyValue: string; + icon?: React.ReactNode; +} + +export function ClipboardButton({ + icon = , + className, + children, + copyValue, +}: ButtonProps) { + return ( + + {({ setCopyButton, copySuccess }) => ( + + + {children || translate('copy')} + + + )} + + ); +} + +interface IconButtonProps { + Icon?: React.ComponentType; + 'aria-label'?: string; + className?: string; + copyValue: string; + discreet?: boolean; + size?: InteractiveIconSize; +} + +export function ClipboardIconButton(props: IconButtonProps) { + const { className, copyValue, discreet, size = 'small', Icon = CopyIcon } = props; + const InteractiveIconComponent = discreet ? DiscreetInteractiveIcon : InteractiveIcon; + + return ( + + {({ setCopyButton, copySuccess }) => { + return ( + + {translate(copySuccess ? 'copied_action' : 'copy_to_clipboard')} +
    + } + {...(copySuccess ? { visible: copySuccess } : undefined)} + > + + + ); + }} + + ); +} diff --git a/server/sonar-web/design-system/src/components/icons/CheckIcon.tsx b/server/sonar-web/design-system/src/components/icons/CheckIcon.tsx new file mode 100644 index 00000000000..dff5e8b4455 --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/CheckIcon.tsx @@ -0,0 +1,36 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { useTheme } from '@emotion/react'; +import { themeColor } from '../../helpers/theme'; +import { CustomIcon, IconProps } from './Icon'; + +export default function CheckIcon({ fill = 'iconCheck', ...iconProps }: IconProps) { + const theme = useTheme(); + return ( + + + + ); +} diff --git a/server/sonar-web/design-system/src/components/icons/ClockIcon.tsx b/server/sonar-web/design-system/src/components/icons/ClockIcon.tsx new file mode 100644 index 00000000000..15f81c7a302 --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/ClockIcon.tsx @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { ClockIcon } from '@primer/octicons-react'; +import { OcticonHoc } from './Icon'; + +export default OcticonHoc(ClockIcon); diff --git a/server/sonar-web/design-system/src/components/icons/CloseIcon.tsx b/server/sonar-web/design-system/src/components/icons/CloseIcon.tsx new file mode 100644 index 00000000000..79fb0888398 --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/CloseIcon.tsx @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { XIcon } from '@primer/octicons-react'; +import { OcticonHoc } from './Icon'; + +export default OcticonHoc(XIcon, 'CloseIcon'); diff --git a/server/sonar-web/design-system/src/components/icons/CopyIcon.tsx b/server/sonar-web/design-system/src/components/icons/CopyIcon.tsx new file mode 100644 index 00000000000..e9f12579961 --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/CopyIcon.tsx @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { CopyIcon } from '@primer/octicons-react'; +import { OcticonHoc } from './Icon'; + +export default OcticonHoc(CopyIcon); diff --git a/server/sonar-web/design-system/src/components/icons/Icon.tsx b/server/sonar-web/design-system/src/components/icons/Icon.tsx new file mode 100644 index 00000000000..0603fe83cfe --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/Icon.tsx @@ -0,0 +1,86 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { useTheme } from '@emotion/react'; +import { OcticonProps } from '@primer/octicons-react'; +import React from 'react'; +import { theme } from 'twin.macro'; +import { themeColor } from '../../helpers/theme'; +import { CSSColor, ThemeColors } from '../../types/theme'; + +interface Props { + 'aria-label'?: string; + children: React.ReactNode; + className?: string; +} + +export interface IconProps extends Omit { + fill?: ThemeColors | CSSColor; +} + +export function CustomIcon(props: Props) { + const { 'aria-label': ariaLabel, children, className, ...iconProps } = props; + return ( + + {children} + + ); +} + +export function OcticonHoc( + WrappedOcticon: React.ComponentType, + displayName?: string +): React.ComponentType { + function IconWrapper({ fill, ...props }: IconProps) { + const theme = useTheme(); + return ( + + ); + } + + IconWrapper.displayName = displayName || WrappedOcticon.displayName || WrappedOcticon.name; + return IconWrapper; +} diff --git a/server/sonar-web/design-system/src/components/icons/MenuHelpIcon.tsx b/server/sonar-web/design-system/src/components/icons/MenuHelpIcon.tsx new file mode 100644 index 00000000000..5fcebecdf93 --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/MenuHelpIcon.tsx @@ -0,0 +1,36 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { useTheme } from '@emotion/react'; +import { themeColor } from '../../helpers/theme'; +import { CustomIcon, IconProps } from './Icon'; + +export default function MenuHelpIcon({ fill = 'currentColor', ...iconProps }: IconProps) { + const theme = useTheme(); + return ( + + + + ); +} diff --git a/server/sonar-web/design-system/src/components/icons/MenuIcon.tsx b/server/sonar-web/design-system/src/components/icons/MenuIcon.tsx new file mode 100644 index 00000000000..ea30d7ddf9a --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/MenuIcon.tsx @@ -0,0 +1,29 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { KebabHorizontalIcon } from '@primer/octicons-react'; +import { OcticonHoc } from './Icon'; + +const MenuIcon = styled(OcticonHoc(KebabHorizontalIcon))` + transform: rotate(90deg); +`; + +MenuIcon.displayName = 'MenuIcon'; +export default MenuIcon; diff --git a/server/sonar-web/design-system/src/components/icons/MenuSearchIcon.tsx b/server/sonar-web/design-system/src/components/icons/MenuSearchIcon.tsx new file mode 100644 index 00000000000..a09077285e8 --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/MenuSearchIcon.tsx @@ -0,0 +1,37 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { useTheme } from '@emotion/react'; +import { themeColor } from '../../helpers/theme'; +import { CustomIcon, IconProps } from './Icon'; + +export default function MenuSearchIcon({ fill = 'currentColor', ...iconProps }: IconProps) { + const theme = useTheme(); + + return ( + + + + ); +} diff --git a/server/sonar-web/design-system/src/components/icons/OpenNewTabIcon.tsx b/server/sonar-web/design-system/src/components/icons/OpenNewTabIcon.tsx new file mode 100644 index 00000000000..f856c0ce7ee --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/OpenNewTabIcon.tsx @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { LinkExternalIcon } from '@primer/octicons-react'; +import { OcticonHoc } from './Icon'; + +export default OcticonHoc(LinkExternalIcon, 'OpenNewTabIcon'); diff --git a/server/sonar-web/design-system/src/components/icons/SearchIcon.tsx b/server/sonar-web/design-system/src/components/icons/SearchIcon.tsx new file mode 100644 index 00000000000..674ac699a6e --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/SearchIcon.tsx @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { SearchIcon } from '@primer/octicons-react'; +import { OcticonHoc } from './Icon'; + +export default OcticonHoc(SearchIcon); diff --git a/server/sonar-web/design-system/src/components/icons/StarIcon.tsx b/server/sonar-web/design-system/src/components/icons/StarIcon.tsx new file mode 100644 index 00000000000..f83c9a340a5 --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/StarIcon.tsx @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { StarIcon } from '@primer/octicons-react'; +import { OcticonHoc } from './Icon'; + +export default OcticonHoc(StarIcon); diff --git a/server/sonar-web/design-system/src/components/icons/__tests__/Icon-test.tsx b/server/sonar-web/design-system/src/components/icons/__tests__/Icon-test.tsx new file mode 100644 index 00000000000..4d25af63048 --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/__tests__/Icon-test.tsx @@ -0,0 +1,54 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { CheckIcon } from '@primer/octicons-react'; +import { screen } from '@testing-library/react'; +import { render } from '../../../helpers/testUtils'; +import { CustomIcon, OcticonHoc } from '../Icon'; + +it('should render custom icon correctly', () => { + render( + + + + ); + + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(screen.getByRole('img', { hidden: true })).toContainHTML(''); +}); + +it('should not be hidden when aria-label is set', () => { + render( + + + + ); + + expect(screen.getByRole('img')).toBeVisible(); +}); + +describe('Octicon HOC', () => { + it('should render correctly', () => { + const Wrapped = OcticonHoc(CheckIcon, 'TestIcon'); + + render(); + + expect(screen.getByRole('img')).toBeVisible(); + }); +}); diff --git a/server/sonar-web/design-system/src/components/icons/index.ts b/server/sonar-web/design-system/src/components/icons/index.ts new file mode 100644 index 00000000000..8b30b791711 --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/index.ts @@ -0,0 +1,24 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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. + */ +export { default as ClockIcon } from './ClockIcon'; +export { default as MenuHelpIcon } from './MenuHelpIcon'; +export { default as MenuSearchIcon } from './MenuSearchIcon'; +export { default as OpenNewTabIcon } from './OpenNewTabIcon'; +export { default as StarIcon } from './StarIcon'; diff --git a/server/sonar-web/design-system/src/components/index.ts b/server/sonar-web/design-system/src/components/index.ts index a96434d2ea2..e7bdcf4ca80 100644 --- a/server/sonar-web/design-system/src/components/index.ts +++ b/server/sonar-web/design-system/src/components/index.ts @@ -18,4 +18,21 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -export * from './DummyComponent'; +export * from './Avatar'; +export * from './buttons'; +export { default as DeferredSpinner } from './DeferredSpinner'; +export { default as Dropdown } from './Dropdown'; +export * from './DropdownMenu'; +export { default as DropdownToggler } from './DropdownToggler'; +export * from './GenericAvatar'; +export * from './icons'; +export { default as InputSearch } from './InputSearch'; +export * from './InteractiveIcon'; +export { default as Link } from './Link'; +export * from './MainAppBar'; +export * from './MainMenu'; +export { MainMenuItem } from './MainMenuItem'; +export * from './popups'; +export * from './SonarQubeLogo'; +export * from './Text'; +export { default as Tooltip } from './Tooltip'; diff --git a/server/sonar-web/design-system/src/components/popups.tsx b/server/sonar-web/design-system/src/components/popups.tsx new file mode 100644 index 00000000000..e517ceb7f7d --- /dev/null +++ b/server/sonar-web/design-system/src/components/popups.tsx @@ -0,0 +1,256 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 classNames from 'classnames'; +import { throttle } from 'lodash'; +import React, { AriaRole } from 'react'; +import { createPortal, findDOMNode } from 'react-dom'; +import tw from 'twin.macro'; +import { THROTTLE_SCROLL_DELAY } from '../helpers/constants'; +import { PopupPlacement, popupPositioning, PopupZLevel } from '../helpers/positioning'; +import { themeBorder, themeColor, themeContrast, themeShadow } from '../helpers/theme'; +import ClickEventBoundary from './ClickEventBoundary'; + +interface PopupProps { + 'aria-labelledby'?: string; + children?: React.ReactNode; + className?: string; + id?: string; + placement?: PopupPlacement; + role?: AriaRole; + style?: React.CSSProperties; + zLevel?: PopupZLevel; +} + +function PopupBase(props: PopupProps, ref: React.Ref) { + const { + children, + className, + placement = PopupPlacement.Bottom, + style, + zLevel = PopupZLevel.Default, + ...ariaProps + } = props; + return ( + + + {children} + + + ); +} + +const PopupWithRef = React.forwardRef(PopupBase); +PopupWithRef.displayName = 'Popup'; + +export const Popup = PopupWithRef; + +interface PortalPopupProps extends Omit { + allowResizing?: boolean; + children: React.ReactNode; + overlay: React.ReactNode; +} + +interface Measurements { + height: number; + left: number; + top: number; + width: number; +} + +type State = Partial; + +function isMeasured(state: State): state is Measurements { + return state.height !== undefined; +} + +export class PortalPopup extends React.PureComponent { + mounted = false; + popupNode = React.createRef(); + throttledPositionTooltip: () => void; + + constructor(props: PortalPopupProps) { + super(props); + this.state = {}; + this.throttledPositionTooltip = throttle(this.positionPopup, THROTTLE_SCROLL_DELAY); + } + + componentDidMount() { + this.positionPopup(); + this.addEventListeners(); + this.mounted = true; + } + + componentDidUpdate(prevProps: PortalPopupProps) { + if (this.props.placement !== prevProps.placement || this.props.overlay !== prevProps.overlay) { + this.positionPopup(); + } + } + + componentWillUnmount() { + this.removeEventListeners(); + this.mounted = false; + } + + addEventListeners = () => { + window.addEventListener('resize', this.throttledPositionTooltip); + if (this.props.zLevel !== PopupZLevel.Global) { + window.addEventListener('scroll', this.throttledPositionTooltip); + } + }; + + removeEventListeners = () => { + window.removeEventListener('resize', this.throttledPositionTooltip); + if (this.props.zLevel !== PopupZLevel.Global) { + window.removeEventListener('scroll', this.throttledPositionTooltip); + } + }; + + positionPopup = () => { + if (this.mounted) { + // `findDOMNode(this)` will search for the DOM node for the current component + // first it will find a React.Fragment (see `render`), + // so it will get the DOM node of the first child, i.e. DOM node of `this.props.children` + // docs: https://reactjs.org/docs/refs-and-the-dom.html#exposing-dom-refs-to-parent-components + + // eslint-disable-next-line react/no-find-dom-node + const toggleNode = findDOMNode(this); + if (toggleNode && toggleNode instanceof Element && this.popupNode.current) { + const { placement, zLevel } = this.props; + const isGlobal = zLevel === PopupZLevel.Global; + const { height, left, top, width } = popupPositioning( + toggleNode, + this.popupNode.current, + placement + ); + + // save width and height (and later set in `render`) to avoid resizing the popup element, + // when it's placed close to the window edge + this.setState({ + left: left + (isGlobal ? 0 : window.scrollX), + top: top + (isGlobal ? 0 : window.scrollY), + width, + height, + }); + } + } + }; + + render() { + const { + allowResizing, + children, + overlay, + placement = PopupPlacement.Bottom, + ...popupProps + } = this.props; + + let style: React.CSSProperties | undefined; + if (isMeasured(this.state)) { + style = { left: this.state.left, top: this.state.top }; + if (!allowResizing) { + style.width = this.state.width; + style.height = this.state.height; + } + } + return ( + <> + {this.props.children} + {this.props.overlay && ( + + + {overlay} + + + )} + + ); + } +} + +const PopupWrapper = styled.div<{ zLevel: PopupZLevel }>` + position: ${({ zLevel }) => (zLevel === PopupZLevel.Global ? 'fixed' : 'absolute')}; + background-color: ${themeColor('popup')}; + color: ${themeContrast('popup')}; + border: ${themeBorder('default', 'popupBorder')}; + box-shadow: ${themeShadow('md')}; + + ${tw`sw-box-border`}; + ${tw`sw-rounded-2`}; + ${tw`sw-cursor-default`}; + ${tw`sw-overflow-hidden`}; + ${({ zLevel }) => + ({ + [PopupZLevel.Default]: tw`sw-z-popup`, + [PopupZLevel.Global]: tw`sw-z-global-popup`, + [PopupZLevel.Content]: tw`sw-z-content-popup`, + }[zLevel])}; + + &.is-bottom, + &.is-bottom-left, + &.is-bottom-right { + ${tw`sw-mt-2`}; + } + + &.is-top, + &.is-top-left, + &.is-top-right { + ${tw`sw--mt-2`}; + } + + &.is-left, + &.is-left-top, + &.is-left-bottom { + ${tw`sw--ml-2`}; + } + + &.is-right, + &.is-right-top, + &.is-right-bottom { + ${tw`sw-ml-2`}; + } +`; + +class PortalWrapper extends React.Component { + el: HTMLElement; + + constructor(props: {}) { + super(props); + this.el = document.createElement('div'); + } + + componentDidMount() { + document.body.appendChild(this.el); + } + + componentWillUnmount() { + document.body.removeChild(this.el); + } + + render() { + return createPortal(this.props.children, this.el); + } +} diff --git a/server/sonar-web/design-system/src/helpers/__tests__/colors-test.ts b/server/sonar-web/design-system/src/helpers/__tests__/colors-test.ts new file mode 100644 index 00000000000..eead6e02c5c --- /dev/null +++ b/server/sonar-web/design-system/src/helpers/__tests__/colors-test.ts @@ -0,0 +1,61 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 * as colors from '../colors'; + +describe('#stringToColor', () => { + it('should return a color for a text', () => { + expect(colors.stringToColor('skywalker')).toBe('#97f047'); + }); +}); + +describe('#isDarkColor', () => { + it('should be dark', () => { + expect(colors.isDarkColor('#000000')).toBe(true); + expect(colors.isDarkColor('#222222')).toBe(true); + expect(colors.isDarkColor('#000')).toBe(true); + }); + it('should be light', () => { + expect(colors.isDarkColor('#FFFFFF')).toBe(false); + expect(colors.isDarkColor('#CDCDCD')).toBe(false); + expect(colors.isDarkColor('#FFF')).toBe(false); + }); +}); + +describe('#getTextColor', () => { + it('should return dark color', () => { + expect(colors.getTextColor('#FFF', 'dark', 'light')).toBe('dark'); + expect(colors.getTextColor('#FFF')).toBe('#222'); + }); + it('should return light color', () => { + expect(colors.getTextColor('#000', 'dark', 'light')).toBe('light'); + expect(colors.getTextColor('#000')).toBe('#fff'); + }); +}); + +describe('rgb array to color', () => { + it('should return rgb color without opacity', () => { + expect(colors.getRGBAString([0, 0, 0])).toBe('rgb(0,0,0)'); + expect(colors.getRGBAString([255, 255, 255])).toBe('rgb(255,255,255)'); + }); + it('should return rgba color with opacity', () => { + expect(colors.getRGBAString([5, 6, 100], 0.05)).toBe('rgba(5,6,100,0.05)'); + expect(colors.getRGBAString([255, 255, 255], 0)).toBe('rgba(255,255,255,0)'); + }); +}); diff --git a/server/sonar-web/design-system/src/helpers/__tests__/positioning-test.ts b/server/sonar-web/design-system/src/helpers/__tests__/positioning-test.ts new file mode 100644 index 00000000000..7953d3531c9 --- /dev/null +++ b/server/sonar-web/design-system/src/helpers/__tests__/positioning-test.ts @@ -0,0 +1,167 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { PopupPlacement, popupPositioning } from '../positioning'; + +const toggleRect = { + getBoundingClientRect: jest.fn().mockReturnValue({ + left: 400, + top: 200, + width: 50, + height: 20, + }), +} as any; + +const popupRect = { + getBoundingClientRect: jest.fn().mockReturnValue({ + width: 200, + height: 100, + }), +} as any; + +beforeAll(() => { + Object.defineProperties(document.documentElement, { + clientWidth: { + configurable: true, + value: 1000, + }, + clientHeight: { + configurable: true, + value: 1000, + }, + }); +}); + +it('should calculate positioning based on placement', () => { + const fixes = { leftFix: 0, topFix: 0 }; + expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Bottom)).toMatchObject({ + left: 325, + top: 220, + ...fixes, + }); + expect(popupPositioning(toggleRect, popupRect, PopupPlacement.BottomLeft)).toMatchObject({ + left: 400, + top: 220, + ...fixes, + }); + expect(popupPositioning(toggleRect, popupRect, PopupPlacement.BottomRight)).toMatchObject({ + left: 250, + top: 220, + ...fixes, + }); + expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Top)).toMatchObject({ + left: 325, + top: 100, + ...fixes, + }); + expect(popupPositioning(toggleRect, popupRect, PopupPlacement.TopLeft)).toMatchObject({ + left: 400, + top: 100, + ...fixes, + }); + expect(popupPositioning(toggleRect, popupRect, PopupPlacement.TopRight)).toMatchObject({ + left: 250, + top: 100, + ...fixes, + }); + expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Left)).toMatchObject({ + left: 200, + top: 160, + ...fixes, + }); + expect(popupPositioning(toggleRect, popupRect, PopupPlacement.LeftBottom)).toMatchObject({ + left: 200, + top: 120, + ...fixes, + }); + expect(popupPositioning(toggleRect, popupRect, PopupPlacement.LeftTop)).toMatchObject({ + left: 200, + top: 200, + ...fixes, + }); + expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Right)).toMatchObject({ + left: 450, + top: 160, + ...fixes, + }); + expect(popupPositioning(toggleRect, popupRect, PopupPlacement.RightBottom)).toMatchObject({ + left: 450, + top: 120, + ...fixes, + }); + expect(popupPositioning(toggleRect, popupRect, PopupPlacement.RightTop)).toMatchObject({ + left: 450, + top: 200, + ...fixes, + }); +}); + +it('should position the element in the boundaries of the screen', () => { + toggleRect.getBoundingClientRect.mockReturnValueOnce({ + left: 0, + top: 850, + width: 50, + height: 50, + }); + expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Bottom)).toMatchObject({ + left: 4, + leftFix: 79, + top: 896, + topFix: -4, + }); + toggleRect.getBoundingClientRect.mockReturnValueOnce({ + left: 900, + top: 0, + width: 50, + height: 50, + }); + expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Top)).toMatchObject({ + left: 796, + leftFix: -29, + top: 4, + topFix: 104, + }); +}); + +it('should position the element outside the boundaries of the screen when the toggle is outside', () => { + toggleRect.getBoundingClientRect.mockReturnValueOnce({ + left: -100, + top: 1100, + width: 50, + height: 50, + }); + expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Bottom)).toMatchObject({ + left: -75, + leftFix: 100, + top: 1025, + topFix: -125, + }); + toggleRect.getBoundingClientRect.mockReturnValueOnce({ + left: 1500, + top: -200, + width: 50, + height: 50, + }); + expect(popupPositioning(toggleRect, popupRect, PopupPlacement.Top)).toMatchObject({ + left: 1325, + leftFix: -100, + top: -175, + topFix: 125, + }); +}); diff --git a/server/sonar-web/design-system/src/helpers/__tests__/theme-test.ts b/server/sonar-web/design-system/src/helpers/__tests__/theme-test.ts new file mode 100644 index 00000000000..66f1b97ce05 --- /dev/null +++ b/server/sonar-web/design-system/src/helpers/__tests__/theme-test.ts @@ -0,0 +1,148 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 * as ThemeHelper from '../../helpers/theme'; +import { lightTheme } from '../../theme'; + +const props = { + color: 'rgb(0,0,0)', +}; + +describe('getProp', () => { + it('should work', () => { + expect(ThemeHelper.getProp('color')(props)).toEqual('rgb(0,0,0)'); + }); +}); + +describe('themeColor', () => { + it('should work for light theme', () => { + expect(ThemeHelper.themeColor('backgroundPrimary')({ theme: lightTheme })).toEqual( + 'rgb(252,252,253)' + ); + }); + + it('should work with a theme-defined opacity', () => { + expect(ThemeHelper.themeColor('bannerIconHover')({ theme: lightTheme })).toEqual( + 'rgba(217,45,32,0.2)' + ); + }); + + it('should work for all kind of color parameters', () => { + expect(ThemeHelper.themeColor('transparent')({ theme: lightTheme })).toEqual('transparent'); + expect(ThemeHelper.themeColor('currentColor')({ theme: lightTheme })).toEqual('currentColor'); + expect(ThemeHelper.themeColor('var(--test)')({ theme: lightTheme })).toEqual('var(--test)'); + expect(ThemeHelper.themeColor('rgb(0,0,0)')({ theme: lightTheme })).toEqual('rgb(0,0,0)'); + expect(ThemeHelper.themeColor('rgba(0,0,0,1)')({ theme: lightTheme })).toEqual('rgba(0,0,0,1)'); + expect( + ThemeHelper.themeColor(ThemeHelper.themeContrast('backgroundPrimary')({ theme: lightTheme }))( + { + theme: lightTheme, + } + ) + ).toEqual('rgb(8,9,12)'); + expect( + ThemeHelper.themeColor(ThemeHelper.themeAvatarColor('luke')({ theme: lightTheme }))({ + theme: lightTheme, + }) + ).toEqual('rgb(209,215,254)'); + }); +}); + +describe('themeContrast', () => { + it('should work for light theme', () => { + expect(ThemeHelper.themeContrast('backgroundPrimary')({ theme: lightTheme })).toEqual( + 'rgb(8,9,12)' + ); + }); + + it('should work for all kind of color parameters', () => { + expect(ThemeHelper.themeContrast('var(--test)')({ theme: lightTheme })).toEqual('var(--test)'); + expect(ThemeHelper.themeContrast('rgb(0,0,0)')({ theme: lightTheme })).toEqual('rgb(0,0,0)'); + expect(ThemeHelper.themeContrast('rgba(0,0,0,1)')({ theme: lightTheme })).toEqual( + 'rgba(0,0,0,1)' + ); + expect( + ThemeHelper.themeContrast(ThemeHelper.themeColor('backgroundPrimary')({ theme: lightTheme }))( + { + theme: lightTheme, + } + ) + ).toEqual('rgb(252,252,253)'); + expect( + ThemeHelper.themeContrast(ThemeHelper.themeAvatarColor('luke')({ theme: lightTheme }))({ + theme: lightTheme, + }) + ).toEqual('rgb(209,215,254)'); + expect( + ThemeHelper.themeContrast('backgroundPrimary')({ + theme: { + ...lightTheme, + contrasts: { ...lightTheme.contrasts, backgroundPrimary: 'inherit' }, + }, + }) + ).toEqual('inherit'); + }); +}); + +describe('themeBorder', () => { + it('should work for light theme', () => { + expect(ThemeHelper.themeBorder()({ theme: lightTheme })).toEqual('1px solid rgb(235,235,235)'); + }); + it('should allow to override the color of the border', () => { + expect(ThemeHelper.themeBorder('focus', 'primaryLight')({ theme: lightTheme })).toEqual( + '4px solid rgba(123,135,217,0.2)' + ); + }); + it('should allow to override the opacity of the border', () => { + expect(ThemeHelper.themeBorder('focus', undefined, 0.5)({ theme: lightTheme })).toEqual( + '4px solid rgba(197,205,223,0.5)' + ); + }); + it('should allow to pass a CSS prop as color name', () => { + expect( + ThemeHelper.themeBorder('focus', 'var(--outlineColor)', 0.5)({ theme: lightTheme }) + ).toEqual('4px solid var(--outlineColor)'); + }); +}); + +describe('themeShadow', () => { + it('should work for light theme', () => { + expect(ThemeHelper.themeShadow('xs')({ theme: lightTheme })).toEqual( + '0px 1px 2px 0px rgba(29,33,47,0.05)' + ); + }); + it('should allow to override the color of the shadow', () => { + expect(ThemeHelper.themeShadow('xs', 'backgroundPrimary')({ theme: lightTheme })).toEqual( + '0px 1px 2px 0px rgba(252,252,253,0.05)' + ); + expect(ThemeHelper.themeShadow('xs', 'transparent')({ theme: lightTheme })).toEqual( + '0px 1px 2px 0px transparent' + ); + }); + it('should allow to override the opacity of the shadow', () => { + expect(ThemeHelper.themeShadow('xs', 'backgroundPrimary', 0.8)({ theme: lightTheme })).toEqual( + '0px 1px 2px 0px rgba(252,252,253,0.8)' + ); + }); + it('should allow to pass a CSS prop as color name', () => { + expect(ThemeHelper.themeShadow('xs', 'var(--shadowColor)')({ theme: lightTheme })).toEqual( + '0px 1px 2px 0px var(--shadowColor)' + ); + }); +}); diff --git a/server/sonar-web/design-system/src/helpers/colors.ts b/server/sonar-web/design-system/src/helpers/colors.ts new file mode 100644 index 00000000000..d0cb5e215ca --- /dev/null +++ b/server/sonar-web/design-system/src/helpers/colors.ts @@ -0,0 +1,56 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { CSSColor } from '../types/theme'; + +/* eslint-disable no-bitwise, no-mixed-operators */ +export function stringToColor(str: string) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + let color = '#'; + for (let i = 0; i < 3; i++) { + const value = (hash >> (i * 8)) & 0xff; + color += ('00' + value.toString(16)).substr(-2); + } + return color; +} + +export function isDarkColor(color: string) { + color = color.substr(1); + if (color.length === 3) { + // shortcut notation: #f90 + color = color[0] + color[0] + color[1] + color[1] + color[2] + color[2]; + } + const rgb = parseInt(color.substr(1), 16); + const r = (rgb >> 16) & 0xff; + const g = (rgb >> 8) & 0xff; + const b = (rgb >> 0) & 0xff; + const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return luma < 140; +} + +export function getTextColor(background: string, dark = '#222', light = '#fff') { + return isDarkColor(background) ? light : dark; +} + +export function getRGBAString([r, g, b]: Array, a?: number | string) { + return (a !== undefined ? `rgba(${r},${g},${b},${a})` : `rgb(${r},${g},${b})`) as CSSColor; +} diff --git a/server/sonar-web/design-system/src/helpers/constants.ts b/server/sonar-web/design-system/src/helpers/constants.ts new file mode 100644 index 00000000000..68a385c3c1c --- /dev/null +++ b/server/sonar-web/design-system/src/helpers/constants.ts @@ -0,0 +1,68 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { theme } from 'twin.macro'; + +export const DEFAULT_LOCALE = 'en'; +export const IS_SSR = typeof window === 'undefined'; +export const REACT_DOM_CONTAINER = '#___gatsby'; + +export const RULE_STATUSES = ['READY', 'BETA', 'DEPRECATED']; + +export const THROTTLE_SCROLL_DELAY = 10; +export const THROTTLE_KEYPRESS_DELAY = 100; + +export const DEBOUNCE_DELAY = 250; + +export const DEBOUNCE_LONG_DELAY = 1000; + +export const DEBOUNCE_SUCCESS_DELAY = 1000; + +export const INTERACTIVE_TOOLTIP_DELAY = 0.5; + +export const LEAK_PERIOD = 'sonar.leak.period'; + +export const LEAK_PERIOD_TYPE = 'sonar.leak.period.type'; + +export const INPUT_SIZES = { + small: theme('width.input-small'), + medium: theme('width.input-medium'), + large: theme('width.input-large'), + full: theme('width.full'), + auto: theme('width.auto'), +}; + +export const LAYOUT_VIEWPORT_MIN_WIDTH = 1280; +export const LAYOUT_MAIN_CONTENT_GUTTER = 60; +export const LAYOUT_SIDEBAR_WIDTH = 240; +export const LAYOUT_SIDEBAR_COLLAPSED_WIDTH = 60; +export const LAYOUT_SIDEBAR_BREAKPOINT = 1320; +export const LAYOUT_BANNER_HEIGHT = 44; +export const LAYOUT_BRANDING_ICON_WIDTH = 198; +export const LAYOUT_FILTERBAR_HEADER = 56; +export const LAYOUT_GLOBAL_NAV_HEIGHT = 52; +export const LAYOUT_LOGO_MARGIN_RIGHT = 45; +export const LAYOUT_LOGO_MAX_HEIGHT = 40; +export const LAYOUT_LOGO_MAX_WIDTH = 150; +export const LAYOUT_FOOTER_HEIGHT = 52; +export const LAYOUT_NOTIFICATIONSBAR_WIDTH = 350; + +export const CORE_CONCEPTS_WIDTH = 350; + +export const DARK_THEME_ID = 'dark-theme'; diff --git a/server/sonar-web/design-system/src/helpers/index.ts b/server/sonar-web/design-system/src/helpers/index.ts new file mode 100644 index 00000000000..764e245473d --- /dev/null +++ b/server/sonar-web/design-system/src/helpers/index.ts @@ -0,0 +1,21 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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. + */ +export * from './constants'; +export * from './positioning'; diff --git a/server/sonar-web/design-system/src/helpers/keyboard.ts b/server/sonar-web/design-system/src/helpers/keyboard.ts new file mode 100644 index 00000000000..42bc6bdf52e --- /dev/null +++ b/server/sonar-web/design-system/src/helpers/keyboard.ts @@ -0,0 +1,52 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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. + */ +export enum Key { + ArrowLeft = 'ArrowLeft', + ArrowUp = 'ArrowUp', + ArrowRight = 'ArrowRight', + ArrowDown = 'ArrowDown', + + Alt = 'Alt', + Backspace = 'Backspace', + CapsLock = 'CapsLock', + Meta = 'Meta', + Control = 'Control', + Delete = 'Delete', + End = 'End', + Enter = 'Enter', + Escape = 'Escape', + Home = 'Home', + PageDown = 'PageDown', + PageUp = 'PageUp', + Shift = 'Shift', + Space = ' ', + Tab = 'Tab', +} + +export function isShortcut(event: KeyboardEvent): boolean { + return event.ctrlKey || event.metaKey; +} + +const INPUT_TAGS = ['INPUT', 'SELECT', 'TEXTAREA', 'UBCOMMENT']; + +export function isInput(event: KeyboardEvent): boolean { + const { tagName } = event.target as HTMLElement; + return INPUT_TAGS.includes(tagName); +} diff --git a/server/sonar-web/design-system/src/helpers/l10n.ts b/server/sonar-web/design-system/src/helpers/l10n.ts new file mode 100644 index 00000000000..96cf9467685 --- /dev/null +++ b/server/sonar-web/design-system/src/helpers/l10n.ts @@ -0,0 +1,30 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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. + */ + +export function translate(keys: string): string { + return keys; +} + +export function translateWithParameters( + messageKey: string, + ...parameters: Array +): string { + return `${messageKey}.${parameters.join('.')}`; +} diff --git a/server/sonar-web/design-system/src/helpers/positioning.ts b/server/sonar-web/design-system/src/helpers/positioning.ts new file mode 100644 index 00000000000..09384e2299b --- /dev/null +++ b/server/sonar-web/design-system/src/helpers/positioning.ts @@ -0,0 +1,185 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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. + */ +/** + * Positioning rules: + * - Bottom = below the block, horizontally centered + * - BottomLeft = below the block, horizontally left-aligned + * - BottomRight = below the block, horizontally right-aligned + * - Left = Left of the block, vertically centered + * - LeftTop = on the left-side of the block, vertically top-aligned + * - LeftBottom = on the left-side of the block, vertically bottom-aligned + * - Right = Right of the block, vertically centered + * - RightTop = on the right-side of the block, vertically top-aligned + * - RightBottom = on the right-side of the block, vetically bottom-aligned + * - Top = above the block, horizontally centered + * - TopLeft = above the block, horizontally left-aligned + * - TopRight = above the block, horizontally right-aligned + */ +export enum PopupPlacement { + Bottom = 'bottom', + BottomLeft = 'bottom-left', + BottomRight = 'bottom-right', + Left = 'left', + LeftTop = 'left-top', + LeftBottom = 'left-bottom', + Right = 'right', + RightTop = 'right-top', + RightBottom = 'right-bottom', + Top = 'top', + TopLeft = 'top-left', + TopRight = 'top-right', +} + +export enum PopupZLevel { + Content = 'content', + Default = 'popup', + Global = 'global', +} + +export type BasePlacement = Extract< + PopupPlacement, + PopupPlacement.Bottom | PopupPlacement.Top | PopupPlacement.Left | PopupPlacement.Right +>; + +export const PLACEMENT_FLIP_MAP: { [key in PopupPlacement]: PopupPlacement } = { + [PopupPlacement.Left]: PopupPlacement.Right, + [PopupPlacement.LeftBottom]: PopupPlacement.RightBottom, + [PopupPlacement.LeftTop]: PopupPlacement.RightTop, + [PopupPlacement.Right]: PopupPlacement.Left, + [PopupPlacement.RightBottom]: PopupPlacement.LeftBottom, + [PopupPlacement.RightTop]: PopupPlacement.LeftTop, + [PopupPlacement.Top]: PopupPlacement.Bottom, + [PopupPlacement.TopLeft]: PopupPlacement.BottomLeft, + [PopupPlacement.TopRight]: PopupPlacement.BottomRight, + [PopupPlacement.Bottom]: PopupPlacement.Top, + [PopupPlacement.BottomLeft]: PopupPlacement.TopLeft, + [PopupPlacement.BottomRight]: PopupPlacement.TopRight, +}; + +const MARGIN_TO_EDGE = 4; + +export function popupPositioning( + toggleNode: Element, + popupNode: Element, + placement: PopupPlacement = PopupPlacement.Bottom +) { + const toggleRect = toggleNode.getBoundingClientRect(); + const popupRect = popupNode.getBoundingClientRect(); + + let left = 0; + let top = 0; + + switch (placement) { + case PopupPlacement.Bottom: + left = toggleRect.left + toggleRect.width / 2 - popupRect.width / 2; + top = toggleRect.top + toggleRect.height; + break; + case PopupPlacement.BottomLeft: + left = toggleRect.left; + top = toggleRect.top + toggleRect.height; + break; + case PopupPlacement.BottomRight: + left = toggleRect.left + toggleRect.width - popupRect.width; + top = toggleRect.top + toggleRect.height; + break; + case PopupPlacement.Left: + left = toggleRect.left - popupRect.width; + top = toggleRect.top + toggleRect.height / 2 - popupRect.height / 2; + break; + case PopupPlacement.LeftTop: + left = toggleRect.left - popupRect.width; + top = toggleRect.top; + break; + case PopupPlacement.LeftBottom: + left = toggleRect.left - popupRect.width; + top = toggleRect.top + toggleRect.height - popupRect.height; + break; + case PopupPlacement.Right: + left = toggleRect.left + toggleRect.width; + top = toggleRect.top + toggleRect.height / 2 - popupRect.height / 2; + break; + case PopupPlacement.RightTop: + left = toggleRect.left + toggleRect.width; + top = toggleRect.top; + break; + case PopupPlacement.RightBottom: + left = toggleRect.left + toggleRect.width; + top = toggleRect.top + toggleRect.height - popupRect.height; + break; + case PopupPlacement.Top: + left = toggleRect.left + toggleRect.width / 2 - popupRect.width / 2; + top = toggleRect.top - popupRect.height; + break; + case PopupPlacement.TopLeft: + left = toggleRect.left; + top = toggleRect.top - popupRect.height; + break; + case PopupPlacement.TopRight: + left = toggleRect.left + toggleRect.width - popupRect.width; + top = toggleRect.top - popupRect.height; + break; + } + + const inBoundariesLeft = Math.min( + Math.max(left, getMinLeftPlacement(toggleRect)), + getMaxLeftPlacement(toggleRect, popupRect) + ); + const inBoundariesTop = Math.min( + Math.max(top, getMinTopPlacement(toggleRect)), + getMaxTopPlacement(toggleRect, popupRect) + ); + + return { + height: popupRect.height, + left: inBoundariesLeft, + leftFix: inBoundariesLeft - left, + top: inBoundariesTop, + topFix: inBoundariesTop - top, + width: popupRect.width, + }; +} + +function getMinLeftPlacement(toggleRect: DOMRect) { + return Math.min( + MARGIN_TO_EDGE, // Left edge of the sceen + toggleRect.left + toggleRect.width / 2 // Left edge of the screen when scrolled + ); +} + +function getMaxLeftPlacement(toggleRect: DOMRect, popupRect: DOMRect) { + return Math.max( + document.documentElement.clientWidth - popupRect.width - MARGIN_TO_EDGE, // Right edge of the screen + toggleRect.left + toggleRect.width / 2 - popupRect.width // Right edge of the screen when scrolled + ); +} + +function getMinTopPlacement(toggleRect: DOMRect) { + return Math.min( + MARGIN_TO_EDGE, // Top edge of the sceen + toggleRect.top + toggleRect.height / 2 // Top edge of the screen when scrolled + ); +} + +function getMaxTopPlacement(toggleRect: DOMRect, popupRect: DOMRect) { + return Math.max( + document.documentElement.clientHeight - popupRect.height - MARGIN_TO_EDGE, // Bottom edge of the screen + toggleRect.top + toggleRect.height / 2 - popupRect.height // Bottom edge of the screen when scrolled + ); +} diff --git a/server/sonar-web/design-system/src/helpers/testUtils.tsx b/server/sonar-web/design-system/src/helpers/testUtils.tsx new file mode 100644 index 00000000000..558906fe0dc --- /dev/null +++ b/server/sonar-web/design-system/src/helpers/testUtils.tsx @@ -0,0 +1,117 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { render as rtlRender, RenderOptions } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Options as UserEventsOptions } from '@testing-library/user-event/dist/types/options'; +import { InitialEntry } from 'history'; +import { identity, kebabCase } from 'lodash'; +import React, { PropsWithChildren, ReactNode } from 'react'; +import { HelmetProvider } from 'react-helmet-async'; +import { IntlProvider } from 'react-intl'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +export function render( + ui: React.ReactElement, + options?: RenderOptions, + userEventOptions?: UserEventsOptions +) { + return { ...rtlRender(ui, options), user: userEvent.setup(userEventOptions) }; +} + +type RenderContextOptions = Omit & { + initialEntries?: InitialEntry[]; + userEventOptions?: UserEventsOptions; +}; + +export function renderWithContext( + ui: React.ReactElement, + { userEventOptions, ...options }: RenderContextOptions = {} +) { + return render(ui, { ...options, wrapper: getContextWrapper() }, userEventOptions); +} + +type RenderRouterOptions = { additionalRoutes?: ReactNode }; + +export function renderWithRouter( + ui: React.ReactElement, + options: RenderContextOptions & RenderRouterOptions = {} +) { + const { additionalRoutes, userEventOptions, ...renderOptions } = options; + + function RouterWrapper({ children }: React.PropsWithChildren<{}>) { + return ( + + + + + {additionalRoutes} + + + + ); + } + + return render(ui, { ...renderOptions, wrapper: RouterWrapper }, userEventOptions); +} + +function getContextWrapper() { + return function ContextWrapper({ children }: React.PropsWithChildren<{}>) { + return ( + + + {children} + + + ); + }; +} + +export function mockComponent(name: string, transformProps: (props: any) => any = identity) { + function MockedComponent({ ...props }: PropsWithChildren) { + return React.createElement('mocked-' + kebabCase(name), transformProps(props)); + } + + MockedComponent.displayName = `mocked(${name})`; + return MockedComponent; +} + +export const debounceTimer = jest.fn().mockImplementation((callback, timeout) => { + let timeoutId: number; + const debounced = jest.fn((...args) => { + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => callback(...args), timeout); + }); + (debounced as any).cancel = jest.fn(() => { + window.clearTimeout(timeoutId); + }); + return debounced; +}); + +export function flushPromises(usingFakeTime = false): Promise { + return new Promise((resolve) => { + if (usingFakeTime) { + jest.useRealTimers(); + } + setTimeout(resolve, 0); + if (usingFakeTime) { + jest.useFakeTimers(); + } + }); +} diff --git a/server/sonar-web/design-system/src/helpers/theme.ts b/server/sonar-web/design-system/src/helpers/theme.ts new file mode 100644 index 00000000000..6dab879cf4a --- /dev/null +++ b/server/sonar-web/design-system/src/helpers/theme.ts @@ -0,0 +1,130 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { CSSColor, Theme, ThemeColors, ThemeContrasts, ThemedProps } from '../types/theme'; +import { getRGBAString } from './colors'; + +export function getProp(name: keyof Omit) { + return (props: T) => props[name]; +} + +export function themeColor(name: ThemeColors | CSSColor, opacity?: number) { + return function ({ theme }: ThemedProps) { + return getColor(theme, [], name, opacity); + }; +} + +export function themeContrast(name: ThemeColors | CSSColor) { + return function ({ theme }: ThemedProps) { + return getContrast(theme, name); + }; +} + +export function themeBorder( + name: keyof Theme['borders'] = 'default', + color?: ThemeColors | CSSColor, + opacity?: number +) { + return function ({ theme }: ThemedProps) { + const [width, style, ...rgba] = theme.borders[name]; + return `${width} ${style} ${getColor(theme, rgba as number[], color, opacity)}`; + }; +} + +export function themeShadow( + name: keyof Theme['shadows'], + color?: ThemeColors | CSSColor, + opacity?: number +) { + return function ({ theme }: ThemedProps) { + const shadows = theme.shadows[name]; + return shadows + .map((item) => { + const [x, y, blur, spread, ...rgba] = item; + return `${x}px ${y}px ${blur}px ${spread}px ${getColor(theme, rgba, color, opacity)}`; + }) + .join(','); + }; +} + +export function themeAvatarColor(name: string, contrast = false) { + return function ({ theme }: ThemedProps) { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + + // Reduces number length to avoid modulo's limit. + hash = parseInt(hash.toString().slice(-5), 10); + if (contrast) { + return getColor(theme, theme.avatar.contrast[hash % theme.avatar.contrast.length]); + } + return getColor(theme, theme.avatar.color[hash % theme.avatar.color.length]); + }; +} + +export function themeImage(imageKey: keyof Theme['images']) { + return function ({ theme }: ThemedProps) { + return theme.images[imageKey]; + }; +} + +function getColor( + theme: Theme, + [r, g, b, a]: number[], + colorOverride?: ThemeColors | CSSColor, + opacityOverride?: number +) { + // Custom CSS property or rgb(a) color, return it directly + if ( + colorOverride?.startsWith('var(--') || + colorOverride?.startsWith('rgb(') || + colorOverride?.startsWith('rgba(') + ) { + return colorOverride as CSSColor; + } + // Is theme color overridden by a color name ? + const color = colorOverride ? theme.colors[colorOverride as ThemeColors] : [r, g, b]; + if (typeof color === 'string') { + return color as CSSColor; + } + + return getRGBAString(color, opacityOverride ?? color[3] ?? a); +} + +// Simplified version of getColor for contrast colors, fallback to colors if contrast isn't found +function getContrast(theme: Theme, colorOverride: ThemeContrasts | ThemeColors | CSSColor) { + // Custom CSS property or rgb(a) color, return it directly + if ( + colorOverride?.startsWith('var(--') || + colorOverride?.startsWith('rgb(') || + colorOverride?.startsWith('rgba(') + ) { + return colorOverride as CSSColor; + } + + // For contrast we always require a color override (it's the principle of a contrast) + const color = + theme.contrasts[colorOverride as ThemeContrasts] || theme.colors[colorOverride as ThemeColors]; + if (typeof color === 'string') { + return color as CSSColor; + } + + return getRGBAString(color, color[3]); +} diff --git a/server/sonar-web/design-system/src/helpers/types.ts b/server/sonar-web/design-system/src/helpers/types.ts new file mode 100644 index 00000000000..05b2043827b --- /dev/null +++ b/server/sonar-web/design-system/src/helpers/types.ts @@ -0,0 +1,22 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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. + */ +export function isDefined(x: T | undefined | null): x is T { + return x !== undefined && x !== null; +} diff --git a/server/sonar-web/design-system/src/index.ts b/server/sonar-web/design-system/src/index.ts new file mode 100644 index 00000000000..cd4bd05a51b --- /dev/null +++ b/server/sonar-web/design-system/src/index.ts @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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. + */ + +export * from './components'; +export * from './helpers'; +export * from './theme'; diff --git a/server/sonar-web/design-system/src/theme/colors.ts b/server/sonar-web/design-system/src/theme/colors.ts new file mode 100644 index 00000000000..785f6f07c8b --- /dev/null +++ b/server/sonar-web/design-system/src/theme/colors.ts @@ -0,0 +1,136 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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. + */ +export default { + white: [255, 255, 255], + black: [0, 0, 0], + sonarcloud: [243, 112, 42], + grey: { 50: [235, 235, 235], 100: [221, 221, 221] }, + blueGrey: { + 25: [252, 252, 253], + 50: [239, 242, 249], + 100: [225, 230, 243], + 200: [197, 205, 223], + 300: [166, 173, 194], + 400: [106, 117, 144], + 500: [62, 67, 87], + 600: [42, 47, 64], + 700: [29, 33, 47], + 800: [18, 20, 29], + 900: [8, 9, 12], + }, + indigo: { + 25: [244, 246, 255], + 50: [232, 235, 255], + 100: [209, 215, 254], + 200: [189, 198, 255], + 300: [159, 169, 237], + 400: [123, 135, 217], + 500: [93, 108, 208], + 600: [75, 86, 187], + 700: [71, 81, 143], + 800: [43, 51, 104], + 900: [27, 34, 80], + }, + tangerine: { + 25: [255, 248, 244], + 50: [250, 230, 220], + 100: [246, 206, 187], + 200: [243, 185, 157], + 300: [240, 166, 130], + 400: [237, 148, 106], + 500: [235, 131, 82], + 600: [233, 116, 63], + 700: [231, 102, 49], + 800: [181, 68, 25], + 900: [130, 43, 10], + }, + green: { + 50: [246, 254, 249], + 100: [236, 253, 243], + 200: [209, 250, 223], + 300: [166, 244, 197], + 400: [50, 213, 131], + 500: [18, 183, 106], + 600: [3, 152, 85], + 700: [2, 122, 72], + 800: [5, 96, 58], + 900: [5, 79, 49], + }, + yellowGreen: { + 50: [247, 251, 230], + 100: [241, 250, 210], + 200: [225, 245, 168], + 300: [197, 230, 124], + 400: [166, 208, 91], + 500: [110, 183, 18], + 600: [104, 154, 48], + 700: [83, 128, 39], + 800: [63, 104, 29], + 900: [49, 85, 22], + }, + yellow: { + 50: [252, 245, 228], + 100: [254, 245, 208], + 200: [252, 233, 163], + 300: [250, 220, 121], + 400: [248, 205, 92], + 500: [245, 184, 64], + 600: [209, 152, 52], + 700: [174, 122, 41], + 800: [140, 94, 30], + 900: [102, 64, 15], + }, + orange: { + 50: [255, 240, 235], + 100: [254, 219, 199], + 200: [255, 214, 175], + 300: [254, 150, 75], + 400: [253, 113, 34], + 500: [247, 95, 9], + 600: [220, 94, 3], + 700: [181, 71, 8], + 800: [147, 55, 13], + 900: [122, 46, 14], + }, + red: { + 50: [254, 243, 242], + 100: [254, 228, 226], + 200: [254, 205, 202], + 300: [253, 162, 155], + 400: [249, 112, 102], + 500: [240, 68, 56], + 600: [217, 45, 32], + 700: [180, 35, 24], + 800: [128, 27, 20], + 900: [93, 29, 19], + }, + blue: { + 50: [245, 251, 255], + 100: [233, 244, 251], + 200: [184, 222, 241], + 300: [143, 202, 234], + 400: [110, 185, 228], + 500: [85, 170, 223], + 600: [69, 149, 203], + 700: [58, 127, 173], + 800: [49, 108, 146], + 900: [23, 67, 97], + }, +}; diff --git a/server/sonar-web/design-system/src/theme/index.ts b/server/sonar-web/design-system/src/theme/index.ts new file mode 100644 index 00000000000..6b8c84a5721 --- /dev/null +++ b/server/sonar-web/design-system/src/theme/index.ts @@ -0,0 +1,20 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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. + */ +export { default as lightTheme } from './light'; diff --git a/server/sonar-web/design-system/src/theme/light.ts b/server/sonar-web/design-system/src/theme/light.ts new file mode 100644 index 00000000000..8b10b339326 --- /dev/null +++ b/server/sonar-web/design-system/src/theme/light.ts @@ -0,0 +1,743 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 COLORS from './colors'; + +const primary = { + light: COLORS.indigo[400], + default: COLORS.indigo[500], + dark: COLORS.indigo[600], +}; + +const secondary = { + light: COLORS.blueGrey[50], + default: COLORS.blueGrey[200], + dark: COLORS.blueGrey[400], + darker: COLORS.blueGrey[500], +}; + +const danger = { + lightest: COLORS.red[50], + lighter: COLORS.red[300], + light: COLORS.red[400], + default: COLORS.red[600], + dark: COLORS.red[700], + darker: COLORS.red[800], +}; + +const lightTheme = { + id: 'light-theme', + highlightTheme: 'atom-one-light.css', + logo: 'sonarcloud-logo-black.svg', + + colors: { + transparent: 'transparent', + currentColor: 'currentColor', + + backgroundPrimary: COLORS.blueGrey[25], + backgroundSecondary: COLORS.white, + border: COLORS.grey[50], + sonarcloud: COLORS.sonarcloud, + + // primary + primaryLight: primary.light, + primary: primary.default, + primaryDark: primary.dark, + + // danger + danger: danger.dark, + + // buttons + button: primary.default, + buttonHover: primary.dark, + buttonSecondary: COLORS.white, + buttonSecondaryBorder: secondary.default, + buttonSecondaryHover: secondary.light, + buttonDisabled: secondary.light, + buttonDisabledBorder: secondary.default, + + // danger buttons + dangerButton: danger.default, + dangerButtonHover: danger.dark, + dangerButtonFocus: danger.default, + dangerButtonSecondary: COLORS.white, + dangerButtonSecondaryBorder: danger.lighter, + dangerButtonSecondaryHover: danger.lightest, + dangerButtonSecondaryFocus: danger.light, + + // third party button + thirdPartyButton: COLORS.white, + thirdPartyButtonBorder: secondary.default, + thirdPartyButtonHover: secondary.light, + + // popup + popup: COLORS.white, + popupBorder: secondary.default, + + // dropdown menu + dropdownMenu: COLORS.white, + dropdownMenuHover: secondary.light, + dropdownMenuFocus: COLORS.indigo[50], + dropdownMenuFocusBorder: primary.light, + dropdownMenuDisabled: COLORS.white, + dropdownMenuHeader: COLORS.white, + dropdownMenuDanger: danger.default, + dropdownMenuSubTitle: secondary.dark, + + // radio + radio: primary.default, + radioBorder: primary.default, + radioHover: COLORS.indigo[50], + radioFocus: COLORS.indigo[50], + radioFocusBorder: COLORS.indigo[300], + radioFocusOutline: [...COLORS.indigo[300], 0.2], + radioChecked: COLORS.indigo[50], + radioDisabled: secondary.default, + radioDisabledBackground: secondary.light, + radioDisabledBorder: secondary.default, + + // switch + switch: secondary.default, + switchDisabled: COLORS.blueGrey[100], + switchActive: primary.default, + switchHover: COLORS.blueGrey[300], + switchHoverActive: primary.light, + switchButton: COLORS.white, + switchButtonDisabled: secondary.light, + + // sidebar + // NOTE: these aren't used because the sidebar is exclusively dark. but for type purposes are listed here + sidebarBackground: COLORS.blueGrey[700], + sidebarItemActive: COLORS.blueGrey[800], + sidebarBorder: COLORS.blueGrey[500], + sidebarTextDisabled: COLORS.blueGrey[400], + sidebarIcon: COLORS.blueGrey[400], + sidebarActiveIcon: COLORS.blueGrey[200], + + //separator-circle + separatorCircle: COLORS.blueGrey[200], + separatorSlash: COLORS.blueGrey[300], + + // flag message + flagMessageBackground: COLORS.white, + + errorBorder: danger.light, + errorBackground: danger.lightest, + errorText: danger.dark, + + warningBorder: COLORS.yellow[400], + warningBackground: COLORS.yellow[50], + + successBorder: COLORS.green[400], + successBackground: COLORS.green[50], + + infoBorder: COLORS.blue[400], + infoBackground: COLORS.blue[50], + + // banner message + bannerMessage: danger.lightest, + bannerMessageIcon: danger.darker, + + // toggle buttons + toggle: COLORS.white, + toggleBorder: secondary.default, + toggleHover: secondary.light, + toggleFocus: [...secondary.default, 0.2], + + // code snippet + codeSnippetBackground: COLORS.blueGrey[25], + codeSnippetBorder: COLORS.blueGrey[100], + codeSnippetHighlight: secondary.default, + + // code viewer + codeLineIssueIndicator: COLORS.blueGrey[400], // Should be blueGrey[300], to be changed once code viewer is reworked + + // checkbox + checkboxHover: COLORS.indigo[50], + checkboxCheckedHover: primary.light, + checkboxDisabled: secondary.light, + checkboxDisabledChecked: secondary.default, + checkboxLabel: COLORS.blueGrey[500], + + // input search + searchHighlight: COLORS.tangerine[50], + + // input field + inputBackground: COLORS.white, + inputBorder: secondary.default, + inputFocus: primary.light, + inputDanger: danger.default, + inputDangerFocus: danger.light, + inputSuccess: COLORS.yellowGreen[500], + inputSuccessFocus: COLORS.yellowGreen[400], + inputDisabled: secondary.light, + inputDisabledBorder: secondary.default, + inputPlaceholder: secondary.dark, + + // required input + inputRequired: danger.dark, + + // tooltip + tooltipBackground: COLORS.blueGrey[600], + tooltipSeparator: secondary.dark, + + // avatar + avatarBackground: COLORS.white, + avatarBorder: COLORS.blueGrey[100], + + // badges + badgeNew: COLORS.indigo[100], + badgeDefault: COLORS.blueGrey[100], + badgeDeleted: COLORS.red[100], + badgeCounter: COLORS.blueGrey[100], + + // input select + selectOptionSelected: secondary.light, + + // breadcrumbs + breadcrumb: 'transparent', + + // tab + tabBorder: primary.light, + + //table + tableRowHover: COLORS.indigo[25], + tableRowSelected: COLORS.indigo[300], + + // links + linkDefault: primary.default, + linkActive: COLORS.indigo[600], + linkDiscreet: 'currentColor', + linkTooltipDefault: COLORS.indigo[200], + linkTooltipActive: COLORS.indigo[100], + + // discreet select + discreetBorder: secondary.default, + discreetBackground: COLORS.white, + discreetHover: secondary.light, + discreetButtonHover: COLORS.indigo[500], + discreetFocus: COLORS.indigo[50], + discreetFocusBorder: primary.light, + + // interactive icon + interactiveIcon: 'transparent', + interactiveIconHover: COLORS.indigo[50], + interactiveIconFocus: primary.default, + bannerIcon: 'transparent', + bannerIconHover: [...COLORS.red[600], 0.2], + bannerIconFocus: danger.default, + discreetInteractiveIcon: secondary.dark, + destructiveIcon: 'transparent', + destructiveIconHover: danger.lightest, + destructiveIconFocus: danger.default, + + // icons + iconSeverityMajor: danger.light, + iconSeverityMinor: COLORS.yellowGreen[400], + iconSeverityInfo: COLORS.blue[400], + iconDirectory: COLORS.orange[300], + iconFile: COLORS.blueGrey[300], + iconProject: COLORS.blueGrey[300], + iconUnitTest: COLORS.blueGrey[300], + iconFavorite: COLORS.tangerine[400], + iconCheck: COLORS.green[500], + iconPositiveUpdate: COLORS.green[300], + iconNegativeUpdate: COLORS.red[300], + iconTrendPositive: COLORS.green[400], + iconTrendNegative: COLORS.red[400], + iconTrendNeutral: COLORS.blue[400], + iconTrendDisabled: COLORS.blueGrey[400], + iconError: danger.default, + iconWarning: COLORS.yellow[600], + iconSuccess: COLORS.green[600], + iconInfo: COLORS.blue[600], + iconStatus: COLORS.blueGrey[200], + iconStatusResolved: secondary.dark, + iconNotificationsOn: COLORS.indigo[300], + iconHelperHint: COLORS.blueGrey[100], + iconRuleInheritanceOverride: danger.light, + + // numbered list + numberedList: COLORS.indigo[50], + + // unordered list + listMarker: COLORS.blueGrey[300], + + // product news + productNews: COLORS.indigo[50], + productNewsHover: COLORS.indigo[100], + + // scrollbar + scrollbar: COLORS.blueGrey[25], + + // resizer + resizer: secondary.default, + + // coverage indicators + coverageGreen: COLORS.green[500], + coverageRed: danger.dark, + + // duplications indicators + 'duplicationsRating.A': COLORS.green[500], + 'duplicationsRating.B': COLORS.yellowGreen[500], + 'duplicationsRating.C': COLORS.yellow[500], + 'duplicationsRating.D': COLORS.orange[500], + 'duplicationsRating.E': COLORS.red[500], + duplicationsRatingSecondary: secondary.light, + + // size indicators + sizeIndicator: COLORS.blue[500], + + // rating colors + 'rating.A': COLORS.green[200], + 'rating.B': COLORS.yellowGreen[200], + 'rating.C': COLORS.yellow[200], + 'rating.D': COLORS.orange[200], + 'rating.E': COLORS.red[200], + + // date picker + datePicker: COLORS.white, + datePickerIcon: secondary.default, + datePickerDisabled: COLORS.white, + datePickerDefault: COLORS.white, + datePickerHover: COLORS.blueGrey[100], + datePickerSelected: primary.default, + datePickerRange: COLORS.indigo[100], + + // tags + tag: secondary.light, + + // quality gate indicator + qgIndicatorPassed: COLORS.green[200], + qgIndicatorFailed: COLORS.red[200], + qgIndicatorNotComputed: COLORS.blueGrey[200], + + // main bar + mainBar: COLORS.white, + mainBarHover: COLORS.blueGrey[600], + mainBarLogo: COLORS.white, + mainBarDarkLogo: COLORS.blueGrey[800], + mainBarNews: COLORS.indigo[50], + menuBorder: primary.light, + + // navbar + navbar: COLORS.white, + navbarTextMeta: secondary.darker, + + // filterbar + filterbar: COLORS.white, + filterbarBorder: COLORS.blueGrey[100], + + // facets + facetHeader: COLORS.blueGrey[600], + facetItemSelected: COLORS.indigo[50], + facetItemSelectedHover: COLORS.indigo[100], + facetItemSelectedBorder: primary.light, + facetItemDisabled: COLORS.blueGrey[300], + facetItemLight: secondary.dark, + facetItemGraph: secondary.default, + facetKeyboardHint: COLORS.blueGrey[50], + facetToggleActive: COLORS.green[500], + facetToggleInactive: COLORS.red[500], + facetToggleHover: COLORS.blueGrey[600], + + // subnavigation sidebar + subnavigation: COLORS.white, + subnavigationHover: COLORS.indigo[50], + subnavigationBorder: COLORS.grey[100], + subnavigationSeparator: COLORS.grey[50], + subnavigationSubheading: COLORS.blueGrey[25], + + // footer + footer: COLORS.white, + footerBorder: COLORS.grey[100], + + // project + projectCardBackground: COLORS.white, + projectCardBorder: COLORS.blueGrey[100], + + // overview + iconOverviewIssue: COLORS.blueGrey[400], + + // graph - chart + graphPointCircleColor: COLORS.white, + 'graphLineColor.0': COLORS.blue[500], + 'graphLineColor.1': COLORS.blue[700], + 'graphLineColor.2': COLORS.blue[300], + 'graphLineColor.3': COLORS.blue[900], + graphGridColor: COLORS.grey[50], + graphCursorLineColor: COLORS.blueGrey[400], + newCodeHighlight: COLORS.indigo[300], + graphZoomBackgroundColor: COLORS.blueGrey[25], + graphZoomBorderColor: COLORS.blueGrey[100], + graphZoomHandleColor: COLORS.blueGrey[400], + + // page + pageTitle: COLORS.blueGrey[700], + pageContentLight: secondary.dark, + pageContent: secondary.darker, + pageContentDark: COLORS.blueGrey[600], + pageBlock: COLORS.white, + pageBlockBorder: COLORS.blueGrey[100], + + // core concepts + coreConceptsCloseIcon: COLORS.blueGrey[300], + coreConceptsTitle: secondary.darker, + coreConceptsBody: secondary.darker, + coreConceptsHomeBorder: COLORS.blueGrey[100], + coreConceptsCompleted: COLORS.green[500], + coreConceptsPulse: COLORS.indigo[500], + coreConceptsPulseFallback: COLORS.white, + + // progress bar + coreConceptsProgressBar: secondary.light, + + // issue box + issueBoxBorder: danger.lighter, + issueBoxBorderDepracated: secondary.default, + issueTypeIcon: COLORS.red[200], + + // separator + pipeSeparator: COLORS.blueGrey[100], + + // drilldown link + drilldown: secondary.darker, + drilldownBorder: secondary.default, + + // selection card + selectionCardHeader: secondary.darker, + selectionCardDisabled: secondary.light, + selectionCardBorder: COLORS.blueGrey[100], + selectionCardBorderHover: COLORS.indigo[200], + selectionCardBorderSelected: primary.light, + selectionCardBorderDisabled: secondary.default, + + // bubble charts + bubbleChartLine: COLORS.grey[50], + bubbleDefault: [...COLORS.blue[500], 0.3], + 'bubble.1': [...COLORS.green[500], 0.3], + 'bubble.2': [...COLORS.yellowGreen[500], 0.3], + 'bubble.3': [...COLORS.yellow[500], 0.3], + 'bubble.4': [...COLORS.orange[500], 0.3], + 'bubble.5': [...COLORS.red[500], 0.3], + + // leak legend + leakLegend: [...COLORS.indigo[300], 0.15], + leakLegendBorder: COLORS.indigo[100], + + // hotspot + hotspotStatus: COLORS.blueGrey[25], + + // activity comments + activityCommentPipe: COLORS.tangerine[200], + + // illustrations + illustrationOutline: COLORS.blueGrey[400], + illustrationInlineBorder: COLORS.blueGrey[100], + illustrationPrimary: COLORS.indigo[400], + illustrationSecondary: COLORS.indigo[200], + illustrationShade: COLORS.indigo[25], + + // news bar + newsBar: COLORS.white, + newsBorder: COLORS.grey[100], + newsContent: COLORS.white, + newsTag: COLORS.blueGrey[50], + roadmap: COLORS.indigo[25], + roadmapContent: 'transparent', + + // project analyse page + almCardBorder: COLORS.grey[100], + }, + + // contrast colors to be used for text when using a color background with the same name + // must match the color name + contrasts: { + backgroundPrimary: COLORS.blueGrey[900], + backgroundSecondary: COLORS.blueGrey[900], + primaryLight: secondary.darker, + primary: COLORS.white, + + // switch + switchHover: primary.light, + switchButton: primary.default, + switchButtonDisabled: COLORS.blueGrey[300], + + // sidebar + sidebarBackground: COLORS.blueGrey[200], + sidebarItemActive: COLORS.blueGrey[25], + + // flag message + flagMessageBackground: secondary.darker, + + // banner message + bannerMessage: COLORS.red[900], + + // buttons + buttonDisabled: COLORS.blueGrey[300], + buttonSecondary: secondary.darker, + + // danger buttons + dangerButton: COLORS.white, + dangerButtonSecondary: danger.dark, + + // third party button + thirdPartyButton: secondary.darker, + + // popup + popup: secondary.darker, + + // dropdown menu + dropdownMenu: secondary.darker, + dropdownMenuDisabled: COLORS.blueGrey[300], + dropdownMenuHeader: secondary.dark, + + // toggle buttons + toggle: secondary.darker, + toggleHover: secondary.darker, + + // code snippet + codeSnippetHighlight: danger.default, + + // checkbox + checkboxDisabled: secondary.default, + + // input search + searchHighlight: secondary.darker, + + // input field + inputBackground: secondary.darker, + inputDisabled: COLORS.blueGrey[300], + + // tooltip + tooltipBackground: secondary.light, + + // badges + badgeNew: COLORS.indigo[900], + badgeDefault: COLORS.blueGrey[700], + badgeDeleted: COLORS.red[900], + badgeCounter: secondary.darker, + + // breadcrumbs + breadcrumb: secondary.dark, + + // discreet select + discreetBackground: secondary.darker, + discreetHover: secondary.darker, + + // interactive icons + interactiveIcon: primary.dark, + interactiveIconHover: COLORS.indigo[800], + bannerIcon: danger.darker, + bannerIconHover: danger.darker, + destructiveIcon: danger.default, + destructiveIconHover: danger.darker, + + // icons + iconSeverityMajor: COLORS.white, + iconSeverityMinor: COLORS.white, + iconSeverityInfo: COLORS.white, + iconStatusResolved: COLORS.white, + iconHelperHint: secondary.darker, + + // numbered list + numberedList: COLORS.indigo[800], + + // product news + productNews: secondary.darker, + productNewsHover: secondary.darker, + + // scrollbar + scrollbar: COLORS.grey[100], + + // size indicators + sizeIndicator: COLORS.white, + + // rating colors + 'rating.A': COLORS.green[900], + 'rating.B': COLORS.yellowGreen[900], + 'rating.C': COLORS.yellow[900], + 'rating.D': COLORS.orange[900], + 'rating.E': COLORS.red[900], + + // date picker + datePicker: COLORS.blueGrey[300], + datePickerDisabled: COLORS.blueGrey[300], + datePickerDefault: COLORS.blueGrey[600], + datePickerHover: COLORS.blueGrey[600], + datePickerSelected: COLORS.white, + datePickerRange: COLORS.blueGrey[600], + + // tags + tag: secondary.darker, + + // quality gate indicator + qgIndicatorPassed: COLORS.green[800], + qgIndicatorFailed: danger.darker, + qgIndicatorNotComputed: COLORS.blueGrey[800], + + // main bar + mainBar: secondary.darker, + mainBarLogo: COLORS.black, + mainBarDarkLogo: COLORS.white, + mainBarNews: secondary.darker, + + // navbar + navbar: secondary.darker, + + // filterbar + filterbar: secondary.darker, + + // facet + facetKeyboardHint: secondary.darker, + facetToggleActive: COLORS.white, + facetToggleInactive: COLORS.white, + + // subnavigation sidebar + subnavigation: secondary.darker, + subnavigationHover: COLORS.blueGrey[700], + subnavigationSubheading: secondary.dark, + + // footer + footer: secondary.dark, + + // page + pageBlock: secondary.darker, + + // graph - chart + graphZoomHandleColor: COLORS.white, + + // progress bar + coreConceptsProgressBar: primary.light, + + // issue box + issueTypeIcon: COLORS.red[900], + + // selection card + selectionCardDisabled: secondary.dark, + + // bubble charts + bubbleDefault: COLORS.blue[500], + 'bubble.1': COLORS.green[500], + 'bubble.2': COLORS.yellowGreen[500], + 'bubble.3': COLORS.yellow[500], + 'bubble.4': COLORS.orange[500], + 'bubble.5': COLORS.red[500], + + // news bar + newsBar: COLORS.blueGrey[600], + newsContent: COLORS.blueGrey[500], + newsTag: COLORS.blueGrey[500], + roadmap: COLORS.blueGrey[600], + roadmapContent: COLORS.blueGrey[500], + }, + + // predefined shadows + shadows: { + xs: [[0, 1, 2, 0, ...COLORS.blueGrey[700], 0.05]], + sm: [ + [0, 1, 3, 0, ...COLORS.blueGrey[700], 0.05], + [0, 1, 25, 0, ...COLORS.blueGrey[700], 0.05], + ], + md: [ + [0, 4, 8, -2, ...COLORS.blueGrey[700], 0.1], + [0, 2, 15, -2, ...COLORS.blueGrey[700], 0.06], + ], + lg: [ + [0, 12, 16, -4, ...COLORS.blueGrey[700], 0.1], + [0, 4, 6, -2, ...COLORS.blueGrey[700], 0.05], + ], + xl: [ + [15, 20, 24, -4, ...COLORS.blueGrey[700], 0.1], + [0, 8, 8, -4, ...COLORS.blueGrey[700], 0.06], + ], + }, + + // predefined borders + borders: { + default: ['1px', 'solid', ...COLORS.grey[50]], + active: ['3px', 'solid', ...primary.light], + focus: ['4px', 'solid', ...secondary.default, 0.2], + }, + + avatar: { + color: [ + COLORS.blueGrey[100], + COLORS.indigo[100], + COLORS.tangerine[100], + COLORS.green[100], + COLORS.yellowGreen[100], + COLORS.yellow[100], + COLORS.orange[100], + COLORS.red[100], + COLORS.blue[100], + ], + contrast: [ + COLORS.blueGrey[900], + COLORS.indigo[900], + COLORS.tangerine[900], + COLORS.green[900], + COLORS.yellowGreen[900], + COLORS.yellow[900], + COLORS.orange[900], + COLORS.red[900], + COLORS.blue[900], + ], + }, + + // Theme specific icons and images + images: { + azure: 'azure.svg', + bitbucket: 'bitbucket.svg', + github: 'github.svg', + gitlab: 'gitlab.svg', + microsoft: 'microsoft.svg', + 'cayc-1': 'cayc-1-light.gif', + 'cayc-2': 'cayc-2-light.gif', + 'cayc-3': 'cayc-3-light.svg', + 'cayc-4': 'cayc-4-light.svg', + 'new-code-1': 'new-code-1.svg', + 'new-code-2': 'new-code-2-light.svg', + 'new-code-3': 'new-code-3.gif', + 'new-code-4': 'new-code-4.gif', + 'new-code-5': 'new-code-5.png', + 'pull-requests-1': 'pull-requests-1-light.gif', + 'pull-requests-2': 'pull-requests-2-light.svg', + 'pull-requests-3': 'pull-requests-3.svg', + 'quality-gate-1': 'quality-gate-1.png', + 'quality-gate-2a': 'quality-gate-2a.svg', + 'quality-gate-2b': 'quality-gate-2b.png', + 'quality-gate-2c': 'quality-gate-2c.png', + 'quality-gate-3': 'quality-gate-3-light.svg', + 'quality-gate-4': 'quality-gate-4.png', + 'quality-gate-5': 'quality-gate-5.svg', + + // project configure page + AzurePipe: '/images/alms/azure.svg', + BitbucketPipe: '/images/alms/bitbucket.svg', + BitbucketAzure: '/images/alms/azure.svg', + BitbucketCircleCI: '/images/tutorials/circleci.svg', + GitHubActions: '/images/alms/github.svg', + GitHubCircleCI: '/images/tutorials/circleci.svg', + GitHubTravis: '/images/tutorials/TravisCI-Mascot.png', + GitLabPipeline: '/images/alms/gitlab.svg', + }, +}; + +export default lightTheme; diff --git a/server/sonar-web/design-system/src/types/misc.ts b/server/sonar-web/design-system/src/types/misc.ts new file mode 100644 index 00000000000..ea95b30ffc6 --- /dev/null +++ b/server/sonar-web/design-system/src/types/misc.ts @@ -0,0 +1,21 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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. + */ + +export type FCProps> = Parameters[0]; diff --git a/server/sonar-web/design-system/src/types/theme.ts b/server/sonar-web/design-system/src/types/theme.ts new file mode 100644 index 00000000000..7ced6c14012 --- /dev/null +++ b/server/sonar-web/design-system/src/types/theme.ts @@ -0,0 +1,45 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { lightTheme } from '../theme'; + +export type InputSizeKeys = 'small' | 'medium' | 'large' | 'full' | 'auto'; + +type LightTheme = typeof lightTheme; +type ThemeColor = string | number[]; +export interface Theme extends Omit { + colors: { + [key in keyof LightTheme['colors']]: ThemeColor; + }; + contrasts: { + [key in keyof LightTheme['colors'] & keyof LightTheme['contrasts']]: ThemeColor; + }; +} + +export type ThemeColors = keyof Theme['colors']; +export type ThemeContrasts = keyof Theme['contrasts']; + +type RGBColor = `rgb(${number},${number},${number})`; +type RGBAColor = `rgba(${number},${number},${number},${number})`; +type CSSCustomProp = `var(--${string})`; +export type CSSColor = CSSCustomProp | RGBColor | RGBAColor; + +export interface ThemedProps { + theme: Theme; +} diff --git a/server/sonar-web/design-system/tsconfig.json b/server/sonar-web/design-system/tsconfig.json index a7abe85ba4f..f270502ed24 100644 --- a/server/sonar-web/design-system/tsconfig.json +++ b/server/sonar-web/design-system/tsconfig.json @@ -6,6 +6,7 @@ "forceConsistentCasingInFileNames": true, "isolatedModules": true, "lib": ["dom", "dom.iterable", "es2022"], + "jsx": "react-jsx", "module": "commonjs", "noEmit": true, "paths": { @@ -13,9 +14,10 @@ "~helpers/*": ["src/helpers/*"], "~icons/*": ["src/icons/*"], "~types/*": ["src/types/*"], - "~utils/*": ["src/utils/*"], + "~utils/*": ["src/utils/*"] }, "resolveJsonModule": true, - "skipLibCheck": true, - } + "skipLibCheck": true + }, + "include": ["./src/**/*"] } diff --git a/server/sonar-web/design-system/vite.config.js b/server/sonar-web/design-system/vite.config.js index a1b283bbe0e..558a8879fdc 100644 --- a/server/sonar-web/design-system/vite.config.js +++ b/server/sonar-web/design-system/vite.config.js @@ -36,7 +36,7 @@ const customProperties = getCustomProperties(); export default defineConfig({ build: { lib: { - entry: resolve('src', 'components/index.ts'), + entry: resolve('src', 'index.ts'), name: 'MIUI', formats: ['es'], fileName: (_format) => `index.js`, @@ -73,7 +73,7 @@ export default defineConfig({ babel: babelConfig, }), dts({ - include: ['src/components/'], + entryRoot: 'src', }), ], }); diff --git a/server/sonar-web/jest.config.js b/server/sonar-web/jest.config.js index 38e2b543ac4..c64ecfa1642 100644 --- a/server/sonar-web/jest.config.js +++ b/server/sonar-web/jest.config.js @@ -17,11 +17,17 @@ module.exports = { '/config/polyfills.ts', '/config/jest/SetupEnzyme.ts', '/config/jest/SetupTestEnvironment.ts', + '/config/jest/SetupTheme.js', ], setupFilesAfterEnv: ['/config/jest/SetupReactTestingLibrary.ts'], snapshotSerializers: ['enzyme-to-json/serializer', '@emotion/jest/serializer'], testEnvironment: 'jsdom', - testPathIgnorePatterns: ['/config', '/node_modules', '/scripts'], + testPathIgnorePatterns: [ + '/config', + '/design-system', + '/node_modules', + '/scripts', + ], testRegex: '(/__tests__/.*|\\-test)\\.(ts|tsx|js)$', transform: { '^.+\\.(t|j)sx?$': [ diff --git a/server/sonar-web/package.json b/server/sonar-web/package.json index 9bfcb674b00..21675f8eea1 100644 --- a/server/sonar-web/package.json +++ b/server/sonar-web/package.json @@ -10,6 +10,7 @@ "dependencies": { "@emotion/react": "11.10.5", "@emotion/styled": "11.10.5", + "@primer/octicons-react": "17.11.1", "classnames": "2.3.2", "clipboard": "2.0.11", "core-js": "3.27.2", @@ -97,7 +98,7 @@ "postcss-custom-properties": "12.1.11", "prettier": "2.8.3", "react-select-event": "5.5.1", - "tailwindcss": "3.2.6", + "tailwindcss": "2.2.19", "testing-library-selector": "0.2.1", "turbo": "1.7.4", "typescript": "4.9.4", diff --git a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx index 704d8e84e47..ce3f0c22d95 100644 --- a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx +++ b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx @@ -17,6 +17,8 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { ThemeProvider } from '@emotion/react'; +import { lightTheme } from 'design-system'; import * as React from 'react'; import { Outlet, useLocation } from 'react-router-dom'; import A11yProvider from '../../components/a11y/A11yProvider'; @@ -40,35 +42,37 @@ export default function GlobalContainer() { const location = useLocation(); return ( - - - - -
    -
    -
    - - - - - - - - - - - - - - - + + + + + +
    +
    +
    + + + + + + + + + + + + + + + +
    +
    - +
    - -
    - - - + + + + ); } diff --git a/server/sonar-web/src/main/js/app/components/SimpleContainer.tsx b/server/sonar-web/src/main/js/app/components/SimpleContainer.tsx index c6cf4a771ac..44298201c9c 100644 --- a/server/sonar-web/src/main/js/app/components/SimpleContainer.tsx +++ b/server/sonar-web/src/main/js/app/components/SimpleContainer.tsx @@ -19,9 +19,8 @@ */ import * as React from 'react'; import { Outlet } from 'react-router-dom'; -import NavBar from '../../components/ui/NavBar'; -import { rawSizes } from '../theme'; import GlobalFooter from './GlobalFooter'; +import MainSonarQubeBar from './nav/global/MainSonarQubeBar'; /* * We need to render either children or the Outlet, @@ -31,7 +30,7 @@ export default function SimpleContainer({ children }: { children?: React.ReactNo return (
    - + {children !== undefined ? children : }
    diff --git a/server/sonar-web/src/main/js/app/components/global-search/GlobalSearch.tsx b/server/sonar-web/src/main/js/app/components/global-search/GlobalSearch.tsx new file mode 100644 index 00000000000..24b96e70a5d --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/global-search/GlobalSearch.tsx @@ -0,0 +1,430 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { + DropdownMenu, + InputSearch, + InteractiveIcon, + INTERACTIVE_TOOLTIP_DELAY, + MenuSearchIcon, + PopupZLevel, + PortalPopup, + TextMuted, + Tooltip, +} from 'design-system'; +import { debounce, uniqBy } from 'lodash'; +import * as React from 'react'; +import { getSuggestions } from '../../../api/components'; +import OutsideClickHandler from '../../../components/controls/OutsideClickHandler'; +import { Router, withRouter } from '../../../components/hoc/withRouter'; +import { PopupPlacement } from '../../../components/ui/popups'; +import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers'; +import { KeyboardKeys } from '../../../helpers/keycodes'; +import { translate, translateWithParameters } from '../../../helpers/l10n'; +import { getKeyboardShortcutEnabled } from '../../../helpers/preferences'; +import { scrollToElement } from '../../../helpers/scrolling'; +import { getComponentOverviewUrl } from '../../../helpers/urls'; +import { ComponentQualifier } from '../../../types/component'; +import { Dict } from '../../../types/types'; +import RecentHistory from '../RecentHistory'; +import GlobalSearchResult from './GlobalSearchResult'; +import GlobalSearchResults from './GlobalSearchResults'; +import { ComponentResult, More, Results, sortQualifiers } from './utils'; + +interface Props { + router: Router; +} +interface State { + loading: boolean; + loadingMore?: string; + more: More; + open: boolean; + query: string; + results: Results; + selected?: string; +} +const MIN_SEARCH_QUERY_LENGTH = 2; + +export class GlobalSearch extends React.PureComponent { + input?: HTMLInputElement | null; + node?: HTMLElement | null; + nodes: Dict; + mounted = false; + + constructor(props: Props) { + super(props); + this.nodes = {}; + this.search = debounce(this.search, 250); + this.state = { + loading: false, + more: {}, + open: false, + query: '', + results: {}, + }; + } + + componentDidMount() { + this.mounted = true; + document.addEventListener('keydown', this.handleSKeyDown); + } + + componentDidUpdate(_prevProps: Props, prevState: State) { + if (prevState.selected !== this.state.selected) { + this.scrollToSelected(); + } + } + + componentWillUnmount() { + this.mounted = false; + document.removeEventListener('keydown', this.handleSKeyDown); + } + + focusInput = () => { + if (this.input) { + this.input.focus(); + } + }; + + handleClickOutside = () => { + this.closeSearch(false); + }; + + handleFocus = () => { + if (!this.state.open) { + // simulate click to close any other dropdowns + const body = document.documentElement; + if (body) { + body.click(); + } + } + this.openSearch(); + }; + + openSearch = () => { + if (!this.state.open && !this.state.query) { + this.search(''); + } + this.setState({ open: true }); + }; + + closeSearch = (clear = true) => { + if (this.input) { + this.input.blur(); + } + if (clear) { + this.setState({ + more: {}, + open: false, + query: '', + results: {}, + selected: undefined, + }); + } else { + this.setState({ open: false }); + } + }; + + getPlainComponentsList = (results: Results, more: More) => + sortQualifiers(Object.keys(results)).reduce((components, qualifier) => { + const next = [...components, ...results[qualifier].map((component) => component.key)]; + if (more[qualifier]) { + next.push('qualifier###' + qualifier); + } + return next; + }, []); + + stopLoading = () => { + if (this.mounted) { + this.setState({ loading: false }); + } + }; + + search = (query: string) => { + if (query.length === 0 || query.length >= MIN_SEARCH_QUERY_LENGTH) { + this.setState({ loading: true }); + const recentlyBrowsed = RecentHistory.get().map((component) => component.key); + getSuggestions(query, recentlyBrowsed).then((response) => { + // compare `this.state.query` and `query` to handle two request done almost at the same time + // in this case only the request that matches the current query should be taken + if (this.mounted && this.state.query === query) { + const results: Results = {}; + const more: More = {}; + this.nodes = {}; + response.results.forEach((group) => { + results[group.q] = group.items.map((item) => ({ ...item, qualifier: group.q })); + more[group.q] = group.more; + }); + const list = this.getPlainComponentsList(results, more); + this.setState({ + loading: false, + more, + results, + selected: list.length > 0 ? list[0] : undefined, + }); + } + }, this.stopLoading); + } else { + this.setState({ loading: false }); + } + }; + + searchMore = (qualifier: string) => { + const { query } = this.state; + if (query.length === 1) { + return; + } + + this.setState({ loading: true, loadingMore: qualifier }); + const recentlyBrowsed = RecentHistory.get().map((component) => component.key); + getSuggestions(query, recentlyBrowsed, qualifier).then((response) => { + if (this.mounted) { + const group = response.results.find((group) => group.q === qualifier); + const moreResults = (group ? group.items : []).map((item) => ({ ...item, qualifier })); + this.setState((state) => ({ + loading: false, + loadingMore: undefined, + more: { ...state.more, [qualifier]: 0 }, + results: { + ...state.results, + [qualifier]: uniqBy([...state.results[qualifier], ...moreResults], 'key'), + }, + selected: moreResults.length > 0 ? moreResults[0].key : state.selected, + })); + this.focusInput(); + } + }, this.stopLoading); + }; + + handleQueryChange = (query: string) => { + this.setState({ query }); + this.search(query); + }; + + selectPrevious = () => { + this.setState(({ more, results, selected }) => { + if (selected) { + const list = this.getPlainComponentsList(results, more); + const index = list.indexOf(selected); + return index > 0 ? { selected: list[index - 1] } : null; + } + return null; + }); + }; + + selectNext = () => { + this.setState(({ more, results, selected }) => { + if (selected) { + const list = this.getPlainComponentsList(results, more); + const index = list.indexOf(selected); + return index >= 0 && index < list.length - 1 ? { selected: list[index + 1] } : null; + } + return null; + }); + }; + + openSelected = () => { + const { results, selected } = this.state; + + if (!selected) { + return; + } + + if (selected.startsWith('qualifier###')) { + this.searchMore(selected.substr(12)); + } else { + let qualifier = ComponentQualifier.Project; + + if ((results[ComponentQualifier.Portfolio] ?? []).find((r) => r.key === selected)) { + qualifier = ComponentQualifier.Portfolio; + } else if ((results[ComponentQualifier.SubPortfolio] ?? []).find((r) => r.key === selected)) { + qualifier = ComponentQualifier.SubPortfolio; + } + + this.props.router.push(getComponentOverviewUrl(selected, qualifier)); + + this.closeSearch(); + } + }; + + scrollToSelected = () => { + if (this.state.selected) { + const node = this.nodes[this.state.selected]; + if (node && this.node) { + scrollToElement(node, { + topOffset: 30, + bottomOffset: 60, + parent: this.node, + }); + } + } + }; + + handleSKeyDown = (event: KeyboardEvent) => { + if (!getKeyboardShortcutEnabled() || isInput(event) || isShortcut(event)) { + return true; + } + if (event.key === KeyboardKeys.KeyS) { + event.preventDefault(); + this.focusInput(); + this.openSearch(); + } + }; + + handleKeyDown = (event: React.KeyboardEvent) => { + if (!this.state.open) { + return; + } + + switch (event.key) { + case KeyboardKeys.Enter: + event.preventDefault(); + event.stopPropagation(); + this.openSelected(); + break; + case KeyboardKeys.UpArrow: + event.preventDefault(); + event.stopPropagation(); + this.selectPrevious(); + break; + case KeyboardKeys.Escape: + event.preventDefault(); + event.stopPropagation(); + this.closeSearch(); + break; + case KeyboardKeys.DownArrow: + event.preventDefault(); + event.stopPropagation(); + this.selectNext(); + break; + } + }; + + handleSelect = (selected: string) => { + this.setState({ selected }); + }; + + innerRef = (component: string, node: HTMLElement | null) => { + if (node) { + this.nodes[component] = node; + } + }; + + searchInputRef = (node: HTMLInputElement | null) => { + this.input = node; + }; + + renderResult = (component: ComponentResult) => ( + + ); + + renderNoResults = () => ( +
    + {translateWithParameters('no_results_for_x', this.state.query)} +
    + ); + + render() { + const { open, query, results, more, loadingMore, selected, loading } = this.state; + if (!open && !query) { + return ( + + + + ); + } + + const list = this.getPlainComponentsList(results, more); + const search = ( +
    + (this.node = node)} + size="auto" + > + + {list.length > 0 && ( +
  • + +
  • + )} + + ) + } + placement={PopupPlacement.BottomLeft} + zLevel={PopupZLevel.Global} + > + +
    +
    + ); + + return open ? ( + {search} + ) : ( + search + ); + } +} + +export default withRouter(GlobalSearch); diff --git a/server/sonar-web/src/main/js/app/components/global-search/GlobalSearchResult.tsx b/server/sonar-web/src/main/js/app/components/global-search/GlobalSearchResult.tsx new file mode 100644 index 00000000000..8e112b048e7 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/global-search/GlobalSearchResult.tsx @@ -0,0 +1,65 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 classNames from 'classnames'; +import { ClockIcon, ItemLink, SearchText, TextMuted } from 'design-system'; +import * as React from 'react'; +import FavoriteIcon from '../../../components/icons/FavoriteIcon'; +import { translate } from '../../../helpers/l10n'; +import { getComponentOverviewUrl } from '../../../helpers/urls'; +import { ComponentResult } from './utils'; + +interface Props { + component: ComponentResult; + innerRef: (componentKey: string, node: HTMLElement | null) => void; + onClose: () => void; + onSelect: (componentKey: string) => void; + selected: boolean; +} +export default class GlobalSearchResult extends React.PureComponent { + doSelect = () => { + this.props.onSelect(this.props.component.key); + }; + + render() { + const { component, selected } = this.props; + const to = getComponentOverviewUrl(component.key, component.qualifier); + return ( + this.props.innerRef(component.key, node)} + key={component.key} + onClick={this.props.onClose} + onPointerEnter={this.doSelect} + to={to} + > +
    + + {component.isFavorite && } + {!component.isFavorite && component.isRecentlyBrowsed && ( + + )} +
    + +
    + ); + } +} diff --git a/server/sonar-web/src/main/js/app/components/global-search/GlobalSearchResults.tsx b/server/sonar-web/src/main/js/app/components/global-search/GlobalSearchResults.tsx new file mode 100644 index 00000000000..5ee4ca6f24c --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/global-search/GlobalSearchResults.tsx @@ -0,0 +1,73 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { ItemDivider, ItemHeader } from 'design-system'; +import * as React from 'react'; +import { translate } from '../../../helpers/l10n'; +import GlobalSearchShowMore from './GlobalSearchShowMore'; +import { ComponentResult, More, Results, sortQualifiers } from './utils'; + +export interface Props { + query: string; + loadingMore?: string; + more: More; + onMoreClick: (qualifier: string) => void; + onSelect: (componentKey: string) => void; + renderNoResults: () => React.ReactElement; + renderResult: (component: ComponentResult) => React.ReactNode; + results: Results; + selected?: string; +} + +export default function GlobalSearchResults(props: Props): React.ReactElement { + const qualifiers = Object.keys(props.results); + const renderedComponents: React.ReactNode[] = []; + const allowMore = props.query.length !== 1; + + sortQualifiers(qualifiers).forEach((qualifier) => { + const components = props.results[qualifier]; + if (components.length > 0) { + const more = props.more[qualifier]; + renderedComponents.push( +
  • +
      + +

      {translate('qualifiers', qualifier)}

      +
      + {components.map((component) => props.renderResult(component))} + {more !== undefined && more > 0 && ( + + )} + +
    +
  • + ); + } + }); + + return renderedComponents.length > 0 ? <>{renderedComponents} : props.renderNoResults(); +} diff --git a/server/sonar-web/src/main/js/app/components/global-search/GlobalSearchShowMore.tsx b/server/sonar-web/src/main/js/app/components/global-search/GlobalSearchShowMore.tsx new file mode 100644 index 00000000000..f16d54b2f57 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/global-search/GlobalSearchShowMore.tsx @@ -0,0 +1,68 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 classNames from 'classnames'; +import { DeferredSpinner, ItemButton } from 'design-system'; +import * as React from 'react'; +import { translate } from '../../../helpers/l10n'; + +interface Props { + allowMore: boolean; + loadingMore?: string; + onMoreClick: (qualifier: string) => void; + onSelect: (qualifier: string) => void; + qualifier: string; + selected: boolean; +} + +export default class GlobalSearchShowMore extends React.PureComponent { + handleMoreClick = (event: React.MouseEvent, qualifier: string) => { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.blur(); + if (qualifier) { + this.props.onMoreClick(qualifier); + } + }; + + handleMouseEnter = (qualifier: string) => { + if (qualifier) { + this.props.onSelect(`qualifier###${qualifier}`); + } + }; + + render() { + const { loadingMore, qualifier, selected, allowMore } = this.props; + + return ( + ) => this.handleMoreClick(e, qualifier)} + onPointerEnter={() => { + this.handleMouseEnter(qualifier); + }} + > + + {translate('show_more')} + + + ); + } +} diff --git a/server/sonar-web/src/main/js/app/components/global-search/__tests__/GlobalSearch-it.tsx b/server/sonar-web/src/main/js/app/components/global-search/__tests__/GlobalSearch-it.tsx new file mode 100644 index 00000000000..5a71453adab --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/global-search/__tests__/GlobalSearch-it.tsx @@ -0,0 +1,214 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 userEvent from '@testing-library/user-event'; +import React from 'react'; +import { byRole, byText } from 'testing-library-selector'; +import { getSuggestions } from '../../../../api/components'; +import { mockRouter } from '../../../../helpers/testMocks'; +import { renderComponent } from '../../../../helpers/testReactTestingUtils'; +import GlobalSearch, { GlobalSearch as GlobalSearchWithoutRouter } from '../GlobalSearch'; + +jest.mock('../../../../api/components', () => ({ + getSuggestions: jest.fn().mockResolvedValue({ + results: [ + { + q: 'TRK', + more: 1, + items: [ + { + isFavorite: true, + isRecentlyBrowsed: true, + key: 'sonarqube', + match: 'SonarQube', + name: 'SonarQube', + project: '', + }, + { + isFavorite: false, + isRecentlyBrowsed: false, + key: 'sonarcloud', + match: 'Sonarcloud', + name: 'Sonarcloud', + project: '', + }, + ], + }, + ], + }), +})); + +const ui = { + searchButton: byRole('button', { name: 'search_verb' }), + searchInput: byRole('searchbox'), + searchItemListWrapper: byRole('menu'), + searchItem: byRole('menuitem'), + showMoreButton: byRole('menuitem', { name: 'show_more' }), + tooShortWarning: byText('select2.tooShort.2'), + noResultTextABCD: byText('no_results_for_x.abcd'), +}; + +it('should show the input when user click on the search icon', async () => { + const user = userEvent.setup(); + renderGlobalSearch(); + + expect(ui.searchButton.get()).toBeInTheDocument(); + await user.click(ui.searchButton.get()); + expect(ui.searchInput.get()).toBeVisible(); + expect(ui.searchItemListWrapper.get()).toBeVisible(); + + await user.click(document.body); + expect(ui.searchInput.query()).not.toBeInTheDocument(); + expect(ui.searchItemListWrapper.query()).not.toBeInTheDocument(); +}); + +it('selects the results', async () => { + const user = userEvent.setup(); + renderGlobalSearch(); + await user.click(ui.searchButton.get()); + + await user.click(ui.searchInput.get()); + await user.keyboard('son'); + expect(ui.searchItem.getAll()[1]).toHaveClass('active'); + expect(ui.searchItem.getAll()[1]).toHaveTextContent('SonarQubesonarqube'); + + await user.keyboard('{arrowdown}'); + expect(ui.searchItem.getAll()[2]).toHaveClass('active'); + expect(ui.searchItem.getAll()[2]).toHaveTextContent('Sonarcloudsonarcloud'); + + await user.keyboard('{arrowdown}'); + expect(ui.searchItem.getAll()[3]).toHaveClass('active'); + expect(ui.searchItem.getAll()[3]).toHaveTextContent('show_more'); + + await user.keyboard('{arrowup}'); + expect(ui.searchItem.getAll()[2]).toHaveClass('active'); + expect(ui.searchItem.getAll()[2]).toHaveTextContent('Sonarcloudsonarcloud'); + + await user.hover(ui.searchItem.getAll()[1]); + expect(ui.searchItem.getAll()[1]).toHaveClass('active'); + + await user.keyboard('{Escape}'); + expect(ui.searchInput.query()).not.toBeInTheDocument(); +}); + +it('load more results', async () => { + const user = userEvent.setup(); + renderGlobalSearch(); + await user.click(ui.searchButton.get()); + expect(getSuggestions).toHaveBeenCalledWith('', []); + + await user.click(ui.searchInput.get()); + await user.keyboard('foo'); + expect(getSuggestions).toHaveBeenLastCalledWith('foo', []); + + (getSuggestions as jest.Mock).mockResolvedValueOnce({ + results: [ + { + items: [ + { + isFavorite: false, + isRecentlyBrowsed: false, + key: 'bar', + match: 'Bar', + name: 'Bar', + organization: 'org', + project: 'bar', + }, + ], + more: 0, + q: 'TRK', + }, + ], + }); + + await user.click(ui.showMoreButton.get()); + expect(getSuggestions).toHaveBeenLastCalledWith('foo', [], 'TRK'); + expect(ui.searchItem.getAll()[3]).toHaveTextContent('Barbar'); +}); + +it('shows warning about short input', async () => { + const user = userEvent.setup(); + renderGlobalSearch(); + await user.click(ui.searchButton.get()); + + await user.click(ui.searchInput.get()); + await user.keyboard('s'); + expect(ui.tooShortWarning.get()).toBeVisible(); + + await user.keyboard('abc'); + expect(ui.tooShortWarning.query()).not.toBeInTheDocument(); +}); + +it('should display no results message', async () => { + const user = userEvent.setup(); + renderGlobalSearch(); + (getSuggestions as jest.Mock).mockResolvedValue({ + results: [ + { + items: [], + more: 0, + q: 'TRK', + }, + ], + }); + + await user.click(ui.searchButton.get()); + + await user.click(ui.searchInput.get()); + await user.keyboard('abcd'); + + expect(ui.noResultTextABCD.get()).toBeVisible(); +}); + +it('should open selected', async () => { + (getSuggestions as jest.Mock).mockResolvedValueOnce({ + results: [ + { + items: [ + { + isFavorite: true, + isRecentlyBrowsed: true, + key: 'sonarqube', + match: 'SonarQube', + name: 'SonarQube', + project: '', + }, + ], + more: 0, + q: 'TRK', + }, + ], + }); + const user = userEvent.setup(); + const router = mockRouter(); + renderComponent(); + await user.click(ui.searchButton.get()); + + await user.click(ui.searchInput.get()); + await user.keyboard('{arrowdown}'); + await user.keyboard('{enter}'); + expect(router.push).toHaveBeenCalledWith({ + pathname: '/dashboard', + search: '?id=sonarqube', + }); +}); + +function renderGlobalSearch() { + return renderComponent(); +} diff --git a/server/sonar-web/src/main/js/app/components/global-search/utils.ts b/server/sonar-web/src/main/js/app/components/global-search/utils.ts new file mode 100644 index 00000000000..51f9fc636e5 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/global-search/utils.ts @@ -0,0 +1,50 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { sortBy } from 'lodash'; +import { ComponentQualifier } from '../../../../js/types/component'; + +const ORDER = [ + ComponentQualifier.Developper, + ComponentQualifier.Portfolio, + ComponentQualifier.SubPortfolio, + ComponentQualifier.Application, + ComponentQualifier.Project, +]; + +export function sortQualifiers(qualifiers: string[]) { + return sortBy(qualifiers, (qualifier) => ORDER.indexOf(qualifier as ComponentQualifier)); +} + +export interface ComponentResult { + isFavorite?: boolean; + isRecentlyBrowsed?: boolean; + key: string; + match?: string; + name: string; + qualifier: string; +} + +export interface Results { + [qualifier: string]: ComponentResult[]; +} + +export interface More { + [qualifier: string]: number; +} diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css deleted file mode 100644 index 5013161cc0e..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css +++ /dev/null @@ -1,125 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2023 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. - */ -.global-navbar, -.global-navbar .global-navbar-inner { - background-color: var(--globalNavBarBg); - z-index: 421; -} - -.global-navbar .navbar-limited { - display: flex; -} - -.global-navbar { - position: fixed; - width: 100%; -} - -.global-navbar .global-navbar-inner { - position: static; - display: flex; - max-width: var(--maxPageWidth); - min-width: var(--minPageWidth); - padding-left: var(--pagePadding); - padding-right: var(--pagePadding); - margin-left: auto; - margin-right: auto; -} - -.navbar-brand { - display: flex; - justify-content: center; - align-items: center; - height: var(--globalNavHeight); - margin-left: calc(-1 * (var(--globalNavHeight) - var(--globalNavContentHeight)) / 2); - padding-top: 4px; - padding-left: calc((var(--globalNavHeight) - var(--globalNavContentHeight)) / 2); - padding-right: calc((var(--globalNavHeight) - var(--globalNavContentHeight)) / 2); - border-bottom: 4px solid transparent; -} - -.navbar-login { - margin-right: -10px; -} - -.navbar-avatar { - margin-right: calc(-1 * (var(--globalNavHeight) - var(--globalNavContentHeight)) / 2); - padding: calc((var(--globalNavHeight) - var(--globalNavContentHeight)) / 2) !important; - border: none !important; -} - -.navbar-icon { - display: inline-block; - height: var(--globalNavHeight); - padding: calc(var(--globalNavHeight) - var(--globalNavContentHeight)) 12px !important; - border-bottom: none !important; - color: #fff !important; -} - -.navbar-plus { - margin-right: calc(-1 * var(--gridSize)); - position: relative; - z-index: var(--aboveNormalZIndex); -} - -.global-navbar-menu { - display: flex; - align-items: center; - margin-left: auto; - height: var(--globalNavHeight); -} - -.global-navbar-menu > li > a, -.global-navbar-menu .navbar-login { - display: block; - height: var(--globalNavHeight); - padding: calc((var(--globalNavHeight) - var(--globalNavContentHeight)) / 2) 10px; - line-height: var(--globalNavContentHeight); - border-bottom: 4px solid transparent; - box-sizing: border-box; - color: #ccc; - font-size: var(--baseFontSize); - letter-spacing: 0.05em; - white-space: nowrap; -} - -.navbar-brand:hover, -.navbar-brand:focus, -.global-navbar-menu > li > a.active, -.global-navbar-menu > li > a:hover, -.global-navbar-menu > li > a:focus, -.navbar-login.active, -.navbar-login:hover, -.navbar-login:focus { - background-color: #020202; - border-bottom-color: var(--blue); -} - -.global-navbar-menu-right { - flex: 1; - justify-content: flex-end; - margin-left: calc(5 * var(--gridSize)); -} - -@media print { - .global-navbar { - display: none !important; - } -} diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx index a44493731e6..bad9281cd95 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx @@ -20,13 +20,11 @@ import * as React from 'react'; import EmbedDocsPopupHelper from '../../../../components/embed-docs-modal/EmbedDocsPopupHelper'; import { CurrentUser } from '../../../../types/users'; -import { sizes } from '../../../theme'; import withCurrentUserContext from '../../current-user/withCurrentUserContext'; -import Search from '../../search/Search'; -import './GlobalNav.css'; -import GlobalNavBranding from './GlobalNavBranding'; +import GlobalSearch from '../../global-search/GlobalSearch'; import GlobalNavMenu from './GlobalNavMenu'; -import GlobalNavUser from './GlobalNavUser'; +import { GlobalNavUser } from './GlobalNavUser'; +import MainSonarQubeBar from './MainSonarQubeBar'; export interface GlobalNavProps { currentUser: CurrentUser; @@ -36,21 +34,23 @@ export interface GlobalNavProps { export function GlobalNav(props: GlobalNavProps) { const { currentUser, location } = props; return ( -
    -