Browse Source

SONAR-18524 New Main App bar

tags/10.0.0.68432
Jeremy Davis 1 year ago
parent
commit
b33a7cd219
100 changed files with 7964 additions and 453 deletions
  1. 2
    0
      .cirrus.yml
  2. 1
    1
      server/sonar-web/build.gradle
  3. 26
    0
      server/sonar-web/config/jest/SetupTheme.js
  4. 2
    1
      server/sonar-web/design-system/babel.config.js
  5. 40
    0
      server/sonar-web/design-system/build.gradle
  6. 25
    0
      server/sonar-web/design-system/config/jest/SetupReactTestingLibrary.ts
  7. 50
    0
      server/sonar-web/design-system/config/jest/SetupTestEnvironment.js
  8. 26
    0
      server/sonar-web/design-system/config/jest/SetupTheme.js
  9. 61
    0
      server/sonar-web/design-system/jest.config.js
  10. 40
    9
      server/sonar-web/design-system/package.json
  11. 27
    0
      server/sonar-web/design-system/src/@types/css.d.ts
  12. 25
    0
      server/sonar-web/design-system/src/@types/emotion.d.ts
  13. 117
    0
      server/sonar-web/design-system/src/components/Avatar.tsx
  14. 174
    0
      server/sonar-web/design-system/src/components/Checkbox.tsx
  15. 35
    0
      server/sonar-web/design-system/src/components/ClickEventBoundary.tsx
  16. 138
    0
      server/sonar-web/design-system/src/components/DeferredSpinner.tsx
  17. 140
    0
      server/sonar-web/design-system/src/components/Dropdown.tsx
  18. 370
    0
      server/sonar-web/design-system/src/components/DropdownMenu.tsx
  19. 48
    0
      server/sonar-web/design-system/src/components/DropdownToggler.tsx
  20. 48
    0
      server/sonar-web/design-system/src/components/EscKeydownHandler.tsx
  21. 60
    0
      server/sonar-web/design-system/src/components/GenericAvatar.tsx
  22. 243
    0
      server/sonar-web/design-system/src/components/InputSearch.tsx
  23. 182
    0
      server/sonar-web/design-system/src/components/InteractiveIcon.tsx
  24. 173
    0
      server/sonar-web/design-system/src/components/Link.tsx
  25. 89
    0
      server/sonar-web/design-system/src/components/MainAppBar.tsx
  26. 30
    0
      server/sonar-web/design-system/src/components/MainMenu.tsx
  27. 59
    0
      server/sonar-web/design-system/src/components/MainMenuItem.tsx
  28. 74
    0
      server/sonar-web/design-system/src/components/NavLink.tsx
  29. 68
    0
      server/sonar-web/design-system/src/components/OutsideClickHandler.tsx
  30. 125
    0
      server/sonar-web/design-system/src/components/RadioButton.tsx
  31. 50
    0
      server/sonar-web/design-system/src/components/SonarQubeLogo.tsx
  32. 62
    0
      server/sonar-web/design-system/src/components/Text.tsx
  33. 504
    0
      server/sonar-web/design-system/src/components/Tooltip.tsx
  34. 69
    0
      server/sonar-web/design-system/src/components/__tests__/Avatar-test.tsx
  35. 73
    0
      server/sonar-web/design-system/src/components/__tests__/DeferredSpinner-test.tsx
  36. 65
    0
      server/sonar-web/design-system/src/components/__tests__/Dropdown-test.tsx
  37. 100
    0
      server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx
  38. 51
    0
      server/sonar-web/design-system/src/components/__tests__/GenericAvatar-test.tsx
  39. 90
    0
      server/sonar-web/design-system/src/components/__tests__/InputSearch-test.tsx
  40. 129
    0
      server/sonar-web/design-system/src/components/__tests__/Link-test.tsx
  41. 54
    0
      server/sonar-web/design-system/src/components/__tests__/MainAppBar-test.tsx
  42. 64
    0
      server/sonar-web/design-system/src/components/__tests__/MainMenuItem-test.tsx
  43. 112
    0
      server/sonar-web/design-system/src/components/__tests__/NavLink-test.tsx
  44. 41
    0
      server/sonar-web/design-system/src/components/__tests__/Text-test.tsx
  45. 126
    0
      server/sonar-web/design-system/src/components/__tests__/Tooltip-test.tsx
  46. 69
    0
      server/sonar-web/design-system/src/components/__tests__/clipboard-test.tsx
  47. 219
    0
      server/sonar-web/design-system/src/components/buttons.tsx
  48. 170
    0
      server/sonar-web/design-system/src/components/clipboard.tsx
  49. 36
    0
      server/sonar-web/design-system/src/components/icons/CheckIcon.tsx
  50. 23
    0
      server/sonar-web/design-system/src/components/icons/ClockIcon.tsx
  51. 23
    0
      server/sonar-web/design-system/src/components/icons/CloseIcon.tsx
  52. 23
    0
      server/sonar-web/design-system/src/components/icons/CopyIcon.tsx
  53. 86
    0
      server/sonar-web/design-system/src/components/icons/Icon.tsx
  54. 36
    0
      server/sonar-web/design-system/src/components/icons/MenuHelpIcon.tsx
  55. 29
    0
      server/sonar-web/design-system/src/components/icons/MenuIcon.tsx
  56. 37
    0
      server/sonar-web/design-system/src/components/icons/MenuSearchIcon.tsx
  57. 23
    0
      server/sonar-web/design-system/src/components/icons/OpenNewTabIcon.tsx
  58. 23
    0
      server/sonar-web/design-system/src/components/icons/SearchIcon.tsx
  59. 23
    0
      server/sonar-web/design-system/src/components/icons/StarIcon.tsx
  60. 54
    0
      server/sonar-web/design-system/src/components/icons/__tests__/Icon-test.tsx
  61. 24
    0
      server/sonar-web/design-system/src/components/icons/index.ts
  62. 18
    1
      server/sonar-web/design-system/src/components/index.ts
  63. 256
    0
      server/sonar-web/design-system/src/components/popups.tsx
  64. 61
    0
      server/sonar-web/design-system/src/helpers/__tests__/colors-test.ts
  65. 167
    0
      server/sonar-web/design-system/src/helpers/__tests__/positioning-test.ts
  66. 148
    0
      server/sonar-web/design-system/src/helpers/__tests__/theme-test.ts
  67. 56
    0
      server/sonar-web/design-system/src/helpers/colors.ts
  68. 68
    0
      server/sonar-web/design-system/src/helpers/constants.ts
  69. 2
    4
      server/sonar-web/design-system/src/helpers/index.ts
  70. 52
    0
      server/sonar-web/design-system/src/helpers/keyboard.ts
  71. 30
    0
      server/sonar-web/design-system/src/helpers/l10n.ts
  72. 185
    0
      server/sonar-web/design-system/src/helpers/positioning.ts
  73. 117
    0
      server/sonar-web/design-system/src/helpers/testUtils.tsx
  74. 130
    0
      server/sonar-web/design-system/src/helpers/theme.ts
  75. 22
    0
      server/sonar-web/design-system/src/helpers/types.ts
  76. 23
    0
      server/sonar-web/design-system/src/index.ts
  77. 136
    0
      server/sonar-web/design-system/src/theme/colors.ts
  78. 20
    0
      server/sonar-web/design-system/src/theme/index.ts
  79. 743
    0
      server/sonar-web/design-system/src/theme/light.ts
  80. 21
    0
      server/sonar-web/design-system/src/types/misc.ts
  81. 45
    0
      server/sonar-web/design-system/src/types/theme.ts
  82. 5
    3
      server/sonar-web/design-system/tsconfig.json
  83. 2
    2
      server/sonar-web/design-system/vite.config.js
  84. 7
    1
      server/sonar-web/jest.config.js
  85. 2
    1
      server/sonar-web/package.json
  86. 32
    28
      server/sonar-web/src/main/js/app/components/GlobalContainer.tsx
  87. 2
    3
      server/sonar-web/src/main/js/app/components/SimpleContainer.tsx
  88. 93
    76
      server/sonar-web/src/main/js/app/components/global-search/GlobalSearch.tsx
  89. 65
    0
      server/sonar-web/src/main/js/app/components/global-search/GlobalSearchResult.tsx
  90. 15
    18
      server/sonar-web/src/main/js/app/components/global-search/GlobalSearchResults.tsx
  91. 16
    29
      server/sonar-web/src/main/js/app/components/global-search/GlobalSearchShowMore.tsx
  92. 214
    0
      server/sonar-web/src/main/js/app/components/global-search/__tests__/GlobalSearch-it.tsx
  93. 0
    0
      server/sonar-web/src/main/js/app/components/global-search/utils.ts
  94. 0
    125
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css
  95. 15
    15
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx
  96. 21
    58
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.tsx
  97. 69
    0
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMore.tsx
  98. 54
    78
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.tsx
  99. 66
    0
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUserMenu.tsx
  100. 0
    0
      server/sonar-web/src/main/js/app/components/nav/global/MainSonarQubeBar.tsx

+ 2
- 0
.cirrus.yml View File

@@ -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/

+ 1
- 1
server/sonar-web/build.gradle View File

@@ -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)

+ 26
- 0
server/sonar-web/config/jest/SetupTheme.js View File

@@ -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;

+ 2
- 1
server/sonar-web/design-system/babel.config.js View File

@@ -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',
],
};

+ 40
- 0
server/sonar-web/design-system/build.gradle View File

@@ -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'])
}

+ 25
- 0
server/sonar-web/design-system/config/jest/SetupReactTestingLibrary.ts View File

@@ -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,
});

+ 50
- 0
server/sonar-web/design-system/config/jest/SetupTestEnvironment.js View File

@@ -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;
});

+ 26
- 0
server/sonar-web/design-system/config/jest/SetupTheme.js View File

@@ -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;

+ 61
- 0
server/sonar-web/design-system/jest.config.js View File

@@ -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: '<rootDir>/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)$':
'<rootDir>/config/jest/FileStub.js',
// '^.+\\.css$': '<rootDir>/config/jest/CSSStub.js',
},
setupFiles: [
'<rootDir>/config/jest/SetupTestEnvironment.js',
'<rootDir>/config/jest/SetupTheme.js',
],
setupFilesAfterEnv: ['<rootDir>/config/jest/SetupReactTestingLibrary.ts'],
snapshotSerializers: ['@emotion/jest/serializer'],
testEnvironment: 'jsdom',
testPathIgnorePatterns: ['<rootDir>/config/jest', '<rootDir>/node_modules', '<rootDir>/scripts'],
testRegex: '(/__tests__/.*|\\-test)\\.(ts|tsx|js)$',
transform: {
'^.+\\.(t|j)sx?$': ['babel-jest', babelConfig],
},
transformIgnorePatterns: ['/node_modules/(?!(d3-.+))/'],
testTimeout: 30000,
};

+ 40
- 9
server/sonar-web/design-system/package.json View File

@@ -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"
}
}
}

+ 27
- 0
server/sonar-web/design-system/src/@types/css.d.ts View File

@@ -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;
}
}

+ 25
- 0
server/sonar-web/design-system/src/@types/emotion.d.ts View File

@@ -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 {}
}

+ 117
- 0
server/sonar-web/design-system/src/components/Avatar.tsx View File

@@ -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<Size, number> = {
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<HTMLImageElement> = () => {
setImgError(true);
};

if (!imgError) {
if (enableGravatar && gravatarServerUrl && hash) {
const url = gravatarServerUrl
.replace('{EMAIL_MD5}', hash)
.replace('{SIZE}', String(numberSize * 2));

return (
<StyledAvatar
alt={resolvedName}
border={border}
className={className}
height={numberSize}
onError={handleImgError}
role="img"
src={url}
width={numberSize}
/>
);
}

if (resolvedName && organizationAvatar) {
return (
<StyledAvatar
alt={resolvedName}
border={border}
className={className}
height={numberSize}
onError={handleImgError}
role="img"
src={organizationAvatar}
width={numberSize}
/>
);
}
}

if (!resolvedName) {
return <input className="sw-appearance-none" />;
}

return <GenericAvatar className={className} name={resolvedName} size={numberSize} />;
}

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')};
`;

+ 174
- 0
server/sonar-web/design-system/src/components/Checkbox.tsx View File

@@ -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<HTMLInputElement>) => 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 (
<CheckboxContainer className={className} disabled={disabled}>
{right && children}
<AccessibleCheckbox
aria-label={title}
checked={checked}
disabled={disabled || loading}
id={id}
onChange={handleChange}
onClick={onClick}
onFocus={onFocus}
type="checkbox"
/>
<DeferredSpinner loading={loading}>
<StyledCheckbox aria-hidden={true} data-clickable="true" title={title}>
<CheckboxIcon checked={checked} thirdState={thirdState} />
</StyledCheckbox>
</DeferredSpinner>
{!right && children}
</CheckboxContainer>
);
}

interface CheckIconProps {
checked?: boolean;
thirdState?: boolean;
}

function CheckboxIcon({ checked, thirdState }: CheckIconProps) {
if (checked && thirdState) {
return (
<CustomIcon>
<rect fill="currentColor" height="2" rx="1" width="50%" x="4" y="7" />
</CustomIcon>
);
} else if (checked) {
return <CheckIcon fill="currentColor" />;
}
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')};
}
`;

+ 35
- 0
server/sonar-web/design-system/src/components/ClickEventBoundary.tsx View File

@@ -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<MouseEvent>) => {
e.stopPropagation();
if (typeof children.props.onClick === 'function') {
children.props.onClick(e);
}
},
});
}

+ 138
- 0
server/sonar-web/design-system/src/components/DeferredSpinner.tsx View File

@@ -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<Props, State> {
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 <Spinner className={className} role="status" />;
}
if (children) {
return children;
}
if (placeholder) {
return <Placeholder className={className} />;
}
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`};
`;

+ 140
- 0
server/sonar-web/design-system/src/components/Dropdown.tsx View File

@@ -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<HTMLElement>) => void;
type A11yAttrs = Pick<React.AriaAttributes, 'aria-controls' | 'aria-expanded' | 'aria-haspopup'> & {
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<Props, State> {
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 (
<DropdownToggler
allowResizing={allowResizing}
aria-labelledby={`${id}-trigger`}
className={className}
id={`${id}-dropdown`}
onRequestClose={this.handleClose}
open={open}
overlay={
<DropdownMenu onClick={closeOnClick ? this.handleClose : undefined} size={size}>
{this.props.overlay}
</DropdownMenu>
}
placement={this.props.placement}
zLevel={zLevel}
>
{children}
</DropdownToggler>
);
}
}

interface ActionsDropdownProps extends Omit<Props, 'children' | 'overlay'> {
buttonSize?: 'small' | 'medium';
children: React.ReactNode;
}

export function ActionsDropdown(props: ActionsDropdownProps) {
const { children, buttonSize, ...dropdownProps } = props;
return (
<Dropdown overlay={children} {...dropdownProps}>
<InteractiveIcon
Icon={MenuIcon}
aria-label={translate('menu')}
size={buttonSize}
stopPropagation={false}
/>
</Dropdown>
);
}

+ 370
- 0
server/sonar-web/design-system/src/components/DropdownMenu.tsx View File

@@ -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<HTMLMenuElement> {
children?: React.ReactNode;
className?: string;
innerRef?: React.Ref<HTMLUListElement>;
maxHeight?: string;
size?: InputSizeKeys;
}

export function DropdownMenu({
children,
className,
innerRef,
maxHeight = 'inherit',
size = 'small',
...menuProps
}: Props) {
return (
<DropdownMenuWrapper
className={classNames('dropdown-menu', className)}
ref={innerRef}
role="menu"
style={{ '--inputSize': INPUT_SIZES[size], maxHeight }}
{...menuProps}
>
{children}
</DropdownMenuWrapper>
);
}

interface ListItemProps {
children?: React.ReactNode;
className?: string;
innerRef?: React.Ref<HTMLLIElement>;
onFocus?: VoidFunction;
onPointerEnter?: VoidFunction;
onPointerLeave?: VoidFunction;
}

type ItemLinkProps = Omit<ListItemProps, 'innerRef'> &
Pick<LinkProps, 'disabled' | 'icon' | 'onClick' | 'to'> & {
innerRef?: React.Ref<HTMLAnchorElement>;
};

export function ItemLink(props: ItemLinkProps) {
const { children, className, disabled, icon, onClick, innerRef, to, ...liProps } = props;
return (
<li {...liProps}>
<ItemLinkStyled
className={classNames(className, { disabled })}
disabled={disabled}
icon={icon}
onClick={onClick}
ref={innerRef}
role="menuitem"
showExternalIcon={false}
to={to}
>
{children}
</ItemLinkStyled>
</li>
);
}

interface ItemNavLinkProps extends ItemLinkProps {
end?: boolean;
}

export function ItemNavLink(props: ItemNavLinkProps) {
const { children, className, disabled, end, icon, onClick, innerRef, to, ...liProps } = props;
return (
<li {...liProps}>
<ItemNavLinkStyled
className={classNames(className, { disabled })}
disabled={disabled}
end={end}
onClick={onClick}
ref={innerRef}
role="menuitem"
to={to}
>
{icon}
{children}
</ItemNavLinkStyled>
</li>
);
}

interface ItemButtonProps extends ListItemProps {
disabled?: boolean;
icon?: React.ReactNode;
onClick: React.MouseEventHandler<HTMLButtonElement>;
}

export function ItemButton(props: ItemButtonProps) {
const { children, className, disabled, icon, innerRef, onClick, ...liProps } = props;
return (
<li ref={innerRef} role="none" {...liProps}>
<ItemButtonStyled className={className} disabled={disabled} onClick={onClick} role="menuitem">
{icon}
{children}
</ItemButtonStyled>
</li>
);
}

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 (
<li ref={innerRef} role="none" {...liProps}>
<ItemCheckboxStyled
checked={checked}
className={classNames(className, { disabled })}
disabled={disabled}
id={id}
onCheck={onCheck}
onFocus={onFocus}
>
{children}
</ItemCheckboxStyled>
</li>
);
}

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 (
<li ref={innerRef} role="none" {...liProps}>
<ItemRadioButtonStyled
checked={checked}
className={classNames(className, { disabled })}
disabled={disabled}
onCheck={onCheck}
value={value}
>
{children}
</ItemRadioButtonStyled>
</li>
);
}

interface ItemCopyProps {
children?: React.ReactNode;
className?: string;
copyValue: string;
}

export function ItemCopy(props: ItemCopyProps) {
const { children, className, copyValue } = props;
return (
<ClipboardBase>
{({ setCopyButton, copySuccess }) => (
<Tooltip overlay={translate('copied_action')} visible={copySuccess}>
<li role="none">
<ItemButtonStyled
className={className}
data-clipboard-text={copyValue}
ref={setCopyButton}
role="menuitem"
>
{children}
</ItemButtonStyled>
</li>
</Tooltip>
)}
</ClipboardBase>
);
}

interface ItemDownloadProps extends ListItemProps {
download: string;
href: string;
}

export function ItemDownload(props: ItemDownloadProps) {
const { children, className, download, href, innerRef, ...liProps } = props;
return (
<li ref={innerRef} role="none" {...liProps}>
<ItemDownloadStyled
className={className}
download={download}
href={href}
rel="noopener noreferrer"
role="menuitem"
target="_blank"
>
{children}
</ItemDownloadStyled>
</li>
);
}

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}
`;

+ 48
- 0
server/sonar-web/design-system/src/components/DropdownToggler.tsx View File

@@ -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 (
<PortalPopup
overlay={
open ? (
<OutsideClickHandler onClickOutside={onRequestClose}>
<EscKeydownHandler onKeydown={onRequestClose}>{overlay}</EscKeydownHandler>
</OutsideClickHandler>
) : undefined
}
{...popupProps}
>
{children}
</PortalPopup>
);
}

+ 48
- 0
server/sonar-web/design-system/src/components/EscKeydownHandler.tsx View File

@@ -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<Props> {
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;
}
}

+ 60
- 0
server/sonar-web/design-system/src/components/GenericAvatar.tsx View File

@@ -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<IconProps>;
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 (
<StyledGenericAvatar aria-label={name} className={className} name={name} role="img" size={size}>
{Icon ? <Icon fill={themeAvatarColor(name, true)({ theme })} /> : text}
</StyledGenericAvatar>
);
}

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;
`;

+ 243
- 0
server/sonar-web/design-system/src/components/InputSearch.tsx View File

@@ -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<HTMLInputElement>;
loading?: boolean;
maxLength?: number;
minLength?: number;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
onChange: (value: string) => void;
onFocus?: React.FocusEventHandler<HTMLInputElement>;
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
onMouseDown?: React.MouseEventHandler<HTMLInputElement>;
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 | HTMLElement>(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<HTMLInputElement>) => {
const eventValue = event.currentTarget.value;
setValue(eventValue);
changeValue(eventValue);
};

const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
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 (
<InputSearchWrapper
className={className}
id={id}
onMouseDown={onMouseDown}
style={{ '--inputSize': INPUT_SIZES[size] }}
title={tooShort && isDefined(minLength) ? tooShortText : ''}
>
<StyledInputWrapper className="sw-flex sw-items-center">
<input
aria-label={searchInputAriaLabel}
autoComplete="off"
autoFocus={autoFocus}
className={inputClassName}
maxLength={maxLength}
onBlur={onBlur}
onChange={handleInputChange}
onFocus={onFocus}
onKeyDown={handleInputKeyDown}
placeholder={placeholder}
ref={ref}
role="searchbox"
type="search"
value={value}
/>
<DeferredSpinner loading={loading !== undefined ? loading : false}>
<StyledSearchIcon />
</DeferredSpinner>
{value && (
<StyledInteractiveIcon
Icon={CloseIcon}
aria-label={clearIconAriaLabel}
className="js-input-search-clear"
onClick={handleClearClick}
size="small"
/>
)}

{tooShort && isDefined(minLength) && (
<StyledNote className="sw-ml-1" role="note">
{tooShortText}
</StyledNote>
)}
</StyledInputWrapper>
</InputSearchWrapper>
);
}

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`}
`;

+ 182
- 0
server/sonar-web/design-system/src/components/InteractiveIcon.tsx View File

@@ -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<IconProps>;
'aria-label': string;
children?: React.ReactNode;
className?: string;
currentColor?: boolean;
disabled?: boolean;
id?: string;
innerRef?: React.Ref<HTMLButtonElement>;
onClick?: VoidFunction;
size?: InteractiveIconSize;
stopPropagation?: boolean;
to?: LinkProps['to'];
}

export class InteractiveIconBase extends React.PureComponent<InteractiveIconProps> {
handleClick = (event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
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 (
<IconLink
{...props}
onClick={onClick}
showExternalIcon={false}
stopPropagation={true}
to={to}
>
<Icon className={classNames({ 'sw-mr-1': isDefined(children) })} />
{children}
</IconLink>
);
}

return (
<IconButton {...props} onClick={this.handleClick} ref={innerRef}>
<Icon className={classNames({ 'sw-mr-1': isDefined(children) })} />
{children}
</IconButton>
);
}
}

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<InteractiveIconProps> = 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<InteractiveIconProps> = styled(InteractiveIcon)`
--color: ${themeColor('discreetInteractiveIcon')};
`;

export const DestructiveIcon: React.FC<InteractiveIconProps> = styled(InteractiveIconBase)`
--background: ${themeColor('destructiveIcon')};
--backgroundHover: ${themeColor('destructiveIconHover')};
--color: ${themeContrast('destructiveIcon')};
--colorHover: ${themeContrast('destructiveIconHover')};
--focus: ${themeColor('destructiveIconFocus', 0.2)};
`;

export const DismissProductNewsIcon: React.FC<InteractiveIconProps> = styled(InteractiveIcon)`
--background: ${themeColor('productNews')};
--backgroundHover: ${themeColor('productNewsHover')};
--color: ${themeContrast('productNews')};
--colorHover: ${themeContrast('productNewsHover')};
--focus: ${themeColor('interactiveIconFocus', 0.2)};

height: 28px;
`;

+ 173
- 0
server/sonar-web/design-system/src/components/Link.tsx View File

@@ -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<HTMLAnchorElement>) => void;
preventDefault?: boolean;
showExternalIcon?: boolean;
stopPropagation?: boolean;
target?: HTMLAttributeAnchorTarget;
}

function BaseLinkWithRef(props: LinkProps, ref: React.ForwardedRef<HTMLAnchorElement>) {
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<HTMLAnchorElement>) => {
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 ? (
<a
{...rest}
href={to}
onClick={handleClick}
ref={ref}
rel="noopener noreferrer"
target={target}
>
{icon}
{children}
{showExternalIcon && <OpenNewTabIcon className="sw-ml-1" />}
</a>
) : (
<RouterLink ref={ref} {...rest} onClick={handleClick} to={to}>
{icon}
{children}
</RouterLink>
);
}

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;

+ 89
- 0
server/sonar-web/design-system/src/components/MainAppBar.tsx View File

@@ -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 (
<MainAppBarContainerDiv>
<MainAppBarDiv>
<MainAppBarNavLogoDiv>
<MainAppBarNavLogoLink href="/">
<Logo />
</MainAppBarNavLogoLink>
</MainAppBarNavLogoDiv>
<MainAppBarNavRightDiv>{children}</MainAppBarNavRightDiv>
</MainAppBarDiv>
</MainAppBarContainerDiv>
);
}

+ 30
- 0
server/sonar-web/design-system/src/components/MainMenu.tsx View File

@@ -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 <MainMenuUl>{children}</MainMenuUl>;
}

+ 59
- 0
server/sonar-web/design-system/src/components/MainMenuItem.tsx View File

@@ -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')};
}
}
`;

+ 74
- 0
server/sonar-web/design-system/src/components/NavLink.tsx View File

@@ -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<HTMLAnchorElement>) => 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<HTMLAnchorElement>) {
const {
blurAfterClick,
children,
disabled,
onClick,
preventDefault,
stopPropagation,
...otherProps
} = props;

const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
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 (
<RouterNavLink onClick={handleClick} ref={ref} {...otherProps}>
{children}
</RouterNavLink>
);
}

const NavLink = React.forwardRef(NavLinkWithRef);
export default NavLink;

+ 68
- 0
server/sonar-web/design-system/src/components/OutsideClickHandler.tsx View File

@@ -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<Props> {
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;
}
}

+ 125
- 0
server/sonar-web/design-system/src/components/RadioButton.tsx View File

@@ -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<HTMLInputElement>,
'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 (
<label className={classNames('sw-flex sw-items-center', className)}>
<RadioButtonStyled
aria-disabled={disabled}
checked={checked}
disabled={disabled}
onChange={handleChange}
type="radio"
value={value}
{...htmlProps}
/>
{children}
</label>
);
}

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')};
}
}
`;

+ 50
- 0
server/sonar-web/design-system/src/components/SonarQubeLogo.tsx View File

@@ -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 (
<SonarQubeLogoSvg viewBox="0 0 540.33 156.33" xmlns="http://www.w3.org/2000/svg">
<path
d="M11.89 101.92a29.92 29.92 0 0 0 13.23 3.74c4.65 0 6.57-1.62 6.57-4.14s-1.51-3.74-7.27-5.66c-10.21-3.44-14.15-9-14-14.85 0-9.2 7.89-16.17 20.11-16.17a33.07 33.07 0 0 1 13.95 2.83l-2.78 10.6A24.24 24.24 0 0 0 31 75.44c-3.74 0-5.87 1.51-5.87 4 0 2.33 1.93 3.54 8 5.66 9.4 3.23 13.34 8 13.44 15.26 0 9.19-7.27 16-21.42 16-6.47 0-12.22-1.42-16-3.44zM100.63 90.09c0 18.09-12.83 26.38-26.08 26.38C60.11 116.48 49 107 49 91s10.5-26.17 26.37-26.17c15.16 0 25.26 10.41 25.26 25.26zm-35.78.51c0 8.49 3.54 14.85 10.11 14.85 6 0 9.8-6 9.8-14.85 0-7.38-2.83-14.87-9.8-14.87-7.37.01-10.11 7.59-10.11 14.87zM106.11 81.71c0-6.16-.2-11.42-.41-15.76H119l.7 6.76h.31a18.08 18.08 0 0 1 15.25-7.88c10.11 0 17.69 6.66 17.69 21.22v29.31h-15.31V88c0-6.37-2.22-10.71-7.78-10.71a8.18 8.18 0 0 0-7.78 5.71 10.41 10.41 0 0 0-.61 3.84v28.51h-15.36zM189.39 115.36l-.91-5h-.3c-3.23 3.95-8.3 6.07-14.15 6.07-10 0-16-7.29-16-15.16 0-12.83 11.52-19 29-18.91v-.7c0-2.63-1.42-6.37-9-6.37a27.8 27.8 0 0 0-13.64 3.73l-2.84-9.9c3.44-1.93 10.21-4.35 19.2-4.35 16.48 0 21.73 9.7 21.73 21.32v17.18a75.92 75.92 0 0 0 .71 12zM187.58 92c-8.08-.1-14.35 1.83-14.35 7.78 0 3.95 2.63 5.87 6.07 5.87a8.39 8.39 0 0 0 8-5.66 10.87 10.87 0 0 0 .31-2.63zM210.63 82.21c0-7.27-.2-12-.41-16.26h13.24L224 75h.4c2.53-7.17 8.59-10.2 13.34-10.2a16.56 16.56 0 0 1 3.26.2v14.48a21.82 21.82 0 0 0-4.14-.41c-5.66 0-9.5 3-10.52 7.78a18.94 18.94 0 0 0-.3 3.44v25.07h-15.41zM342.35 102c0 5 .1 9.5.41 13.34h-7.89l-.51-8h-.19a18.43 18.43 0 0 1-16.17 9.1c-7.68 0-16.89-4.24-16.89-21.42V66.44H310v27.09c0 9.29 2.83 15.57 10.92 15.57a12.88 12.88 0 0 0 11.72-8.1 13.15 13.15 0 0 0 .81-4.55v-30h8.9zM352.67 115.36c.2-3.34.4-8.3.4-12.64V43.6h8.79v30.73h.2c3.13-5.46 8.79-9 16.68-9 12.12 0 20.71 10.11 20.61 25 0 17.49-11 26.18-21.92 26.18-7.08 0-12.73-2.73-16.37-9.2h-.31l-.4 8.09zm9.19-19.61a16.48 16.48 0 0 0 .41 3.23 13.71 13.71 0 0 0 13.33 10.41c9.31 0 14.85-7.58 14.85-18.79 0-9.8-5-18.19-14.55-18.19a14.17 14.17 0 0 0-13.54 10.91 17.47 17.47 0 0 0-.51 3.64zM411.5 92.52c.19 12 7.88 17 16.77 17a32.24 32.24 0 0 0 13.54-2.52l1.52 6.37c-3.13 1.41-8.49 3-16.27 3-15.06 0-24.06-9.9-24.06-24.65s8.69-26.38 22.94-26.38c16 0 20.21 14 20.21 23a33.67 33.67 0 0 1-.3 4.14zm26.07-6.37c.1-5.66-2.31-14.46-12.32-14.46-9 0-12.94 8.3-13.65 14.46z"
fill="#1b171b"
/>
<path
d="M290.55 75.25a26.41 26.41 0 1 0-11.31 39.07l10.22 16.6 8.11-5.51-10.22-16.6a26.42 26.42 0 0 0 3.2-33.56M279.1 105.4a18.5 18.5 0 1 1 4.9-25.7 18.52 18.52 0 0 1-4.9 25.7"
fill="#1b171b"
fillRule="evenodd"
/>
<path
d="M506.94 115.57h-6.27c0-50.44-41.62-91.48-92.78-91.48v-6.26c54.62 0 99.05 43.84 99.05 97.74z"
fill="#4e9bcd"
/>
<path
d="M511.27 81.93c-7.52-31.65-33.16-58.06-65.27-67.29l1.44-5c33.93 9.74 61 37.65 68.95 71.1zM516.09 52.23a96 96 0 0 0-37.17-41.49l2.17-3.57a100.24 100.24 0 0 1 38.8 43.31z"
fill="#4e9bcd"
/>
</SonarQubeLogoSvg>
);
}

+ 62
- 0
server/sonar-web/design-system/src/components/Text.tsx View File

@@ -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 ? (
<StyledText
// Safe: comes from the search engine, that injects bold tags into component names
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: match }}
/>
) : (
<StyledText title={name}>{name}</StyledText>
);
}

export function TextMuted({ text }: { text: string }) {
return <StyledMutedText title={text}>{text}</StyledMutedText>;
}

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')};
`;

+ 504
- 0
server/sonar-web/design-system/src/components/Tooltip.tsx View File

@@ -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<Measurements>;

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 ? <TooltipInner {...props}>{props.children}</TooltipInner> : props.children;
}

export class TooltipInner extends React.Component<TooltipProps, State> {
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() && (
<TooltipPortal>
<TooltipWrapper
className={classNames(placement)}
onPointerEnter={this.handleOverlayPointerEnter}
onPointerLeave={this.handleOverlayPointerLeave}
ref={this.tooltipNodeRef}
role="tooltip"
style={style}
>
<TooltipWrapperInner>{this.props.overlay}</TooltipWrapperInner>
<TooltipWrapperArrow
style={
isMeasured(this.state)
? this.adjustArrowPosition(placement, this.state)
: undefined
}
/>
</TooltipWrapper>
</TooltipPortal>
)}
</>
);
}
}

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`};
}
`;

+ 69
- 0
server/sonar-web/design-system/src/components/__tests__/Avatar-test.tsx View File

@@ -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<FCProps<typeof Avatar>> = {}) {
return render(
<Avatar enableGravatar={true} gravatarServerUrl={gravatarServerUrl} name="foo" {...props} />
);
}

+ 73
- 0
server/sonar-web/design-system/src/components/__tests__/DeferredSpinner-test.tsx View File

@@ -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: <a href="#">foo</a> });
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<DeferredSpinner['props']> = {}) {
// 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<DeferredSpinner['props']> = {}) {
return <DeferredSpinner {...props} />;
}

+ 65
- 0
server/sonar-web/design-system/src/components/__tests__/Dropdown-test.tsx View File

@@ -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 }) => (
<ButtonSecondary onClick={onToggleClick} />
));

await user.click(screen.getByRole('button'));
expect(screen.getByRole('menu')).toBeVisible();
});

function setupWithChildren(children?: Dropdown['props']['children']) {
return renderWithRouter(
<Dropdown id="test-menu" overlay={<div id="overlay" />}>
{children ?? <ButtonSecondary />}
</Dropdown>
);
}
});

describe('ActionsDropdown', () => {
it('renders', () => {
setup();
expect(screen.getByRole('button')).toHaveAccessibleName('menu');
});

function setup() {
return renderWithRouter(
<ActionsDropdown id="test-menu">
<div id="overlay" />
</ActionsDropdown>
);
}
});

+ 100
- 0
server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx View File

@@ -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(
<Tooltip overlay="test tooltip">
<ItemButton onClick={jest.fn()}>button</ItemButton>
</Tooltip>,
{},
{ 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(
<DropdownMenu>
<ItemHeader>My header</ItemHeader>
<ItemNavLink to="/test">Test menu item</ItemNavLink>
<ItemDivider />
<ItemLink disabled={true} to="/test-disabled">
Test disabled item
</ItemLink>
<ItemButton icon={<MenuIcon />} onClick={noop}>
Button
</ItemButton>
<ItemDangerButton onClick={noop}>DangerButton</ItemDangerButton>
<ItemCopy copyValue="copy">Copy</ItemCopy>
<ItemCheckbox checked={true} onCheck={noop}>
Checkbox item
</ItemCheckbox>
<ItemRadioButton checked={false} onCheck={noop} value="radios">
Radio item
</ItemRadioButton>
</DropdownMenu>
);
}

+ 51
- 0
server/sonar-web/design-system/src/components/__tests__/GenericAvatar-test.tsx View File

@@ -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 (
<CustomIcon {...props}>
<path d="l10 10" />
</CustomIcon>
);
}

it('should render single word and size', () => {
render(<GenericAvatar name="foo" size={15} />);
const image = screen.getByRole('img');
expect(image).toHaveAttribute('size', '15');
expect(screen.getByText('F')).toBeInTheDocument();
});

it('should render multiple word with default size', () => {
render(<GenericAvatar name="foo bar" />);
const image = screen.getByRole('img');
expect(image).toHaveAttribute('size', '24');
expect(screen.getByText('F')).toBeInTheDocument();
});

it('should render without name', () => {
render(<GenericAvatar Icon={TestIcon} name="" size={32} />);
const image = screen.getByRole('img');
expect(image).toHaveAttribute('size', '32');
});

+ 90
- 0
server/sonar-web/design-system/src/components/__tests__/InputSearch-test.tsx View File

@@ -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<FCProps<typeof InputSearch>> = {}) {
return render(
<InputSearch
clearIconAriaLabel=""
maxLength={150}
minLength={2}
onChange={jest.fn()}
placeholder="placeholder"
searchInputAriaLabel=""
tooShortText=""
value="foo"
{...props}
/>
);
}

+ 129
- 0
server/sonar-web/design-system/src/components/__tests__/Link-test.tsx View File

@@ -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(
<Link blurAfterClick={true} icon={<div>Icon</div>} 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(<Link preventDefault={true} to="/second" />);

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(
<button onClick={buttonOnClick} type="button">
<Link stopPropagation={true} to="/second" />
</button>
);

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(
<Link onClick={onClick} stopPropagation={true} to="/second" />
);

await user.click(screen.getByRole('link'));

expect(onClick).toHaveBeenCalled();
});

it('internal link should be clickable', async () => {
const { user } = setupWithMemoryRouter(<Link to="/second">internal link</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(<Link to="https://google.com">external link</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(<DiscreetLink to="https://google.com">external link</DiscreetLink>);
expect(screen.getByRole('link')).toBeVisible();

expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
});

function ShowPath() {
const { pathname } = useLocation();
return <pre>{pathname}</pre>;
}

const setupWithMemoryRouter = (component: JSX.Element, initialEntries = ['/initial']) => {
return render(
<MemoryRouter initialEntries={initialEntries}>
<Routes>
<Route
element={
<>
{component}
<ShowPath />
</>
}
path="/initial"
/>
<Route element={<ShowPath />} path="/second" />
</Routes>
</MemoryRouter>
);
};

+ 54
- 0
server/sonar-web/design-system/src/components/__tests__/MainAppBar-test.tsx View File

@@ -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<typeof MainAppBar> = {
Logo: () => <img alt="logo" src="http://example.com/logo.png" />,
}
) {
return render(<MainAppBar {...props} />);
}

+ 64
- 0
server/sonar-web/design-system/src/components/__tests__/MainMenuItem-test.tsx View File

@@ -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(
<MainMenuItem>
<a>Hi</a>
</MainMenuItem>
);

expect(screen.getByText('Hi')).toHaveStyle({
color: 'rgb(62, 67, 87)',
'border-bottom': '3px solid transparent',
});
});

it('should render active link', () => {
render(
<MainMenuItem>
<a className="active">Hi</a>
</MainMenuItem>
);

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(
<MainMenuItem>
<a className="hover">Hi</a>
</MainMenuItem>
);

expect(screen.getByText('Hi')).toHaveStyle({
color: 'rgb(42, 47, 64)',
'border-bottom': '3px solid rgba(123,135,217,1)',
});
});

+ 112
- 0
server/sonar-web/design-system/src/components/__tests__/NavLink-test.tsx View File

@@ -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(<NavLink blurAfterClick={true} 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(<NavLink preventDefault={true} to="/second" />);

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(
<button onClick={buttonOnClick} type="button">
<NavLink stopPropagation={true} to="/second" />
</button>
);

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(
<NavLink onClick={onClick} stopPropagation={true} to="/second" />
);

await user.click(screen.getByRole('link'));

expect(onClick).toHaveBeenCalled();
});

it('NavLink should be clickable', async () => {
const { user } = setupWithMemoryRouter(<NavLink to="/second">internal link</NavLink>);
expect(screen.getByRole('link')).toBeVisible();

await user.click(screen.getByRole('link'));

expect(screen.getByText('/second')).toBeVisible();
});

function ShowPath() {
const { pathname } = useLocation();
return <pre>{pathname}</pre>;
}

const setupWithMemoryRouter = (component: JSX.Element, initialEntries = ['/initial']) => {
return render(
<MemoryRouter initialEntries={initialEntries}>
<Routes>
<Route
element={
<>
{component}
<ShowPath />
</>
}
path="/initial"
/>
<Route element={<ShowPath />} path="/second" />
</Routes>
</MemoryRouter>
);
};

+ 41
- 0
server/sonar-web/design-system/src/components/__tests__/Text-test.tsx View File

@@ -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(<SearchText match="hi" name="hiya" />);

expect(screen.getByText('hi')).toHaveStyle({
'font-weight': '600',
});
});

it('should render TextMuted', () => {
render(<TextMuted text="Hi" />);

expect(screen.getByText('Hi')).toHaveStyle({
color: 'rgb(106, 117, 144)',
});
});

+ 126
- 0
server/sonar-web/design-system/src/components/__tests__/Tooltip-test.tsx View File

@@ -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 },
<div onPointerEnter={onPointerEnter} onPointerLeave={onPointerLeave} role="note" />
);

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<TooltipInner['props']> = {},
children = <div role="note" />
) {
return render(
<TooltipInner mouseLeaveDelay={0} overlay={<span id="overlay" />} {...props}>
{children}
</TooltipInner>
);
}
});

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<FCProps<typeof Tooltip>> = {},
children = <div role="note" />
) {
return render(
<Tooltip overlay={<span id="overlay" />} {...props}>
{children}
</Tooltip>
);
}
});

+ 69
- 0
server/sonar-web/design-system/src/components/__tests__/clipboard-test.tsx View File

@@ -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(<ClipboardButton copyValue="foo">{children}</ClipboardButton>);
}
});

describe('ClipboardIconButton', () => {
it('should display correctly', () => {
renderWithContext(<ClipboardIconButton copyValue="foo" />);

const copyButton = screen.getByRole('button', { name: 'copy_to_clipboard' });
expect(copyButton).toBeInTheDocument();
});
});

+ 219
- 0
server/sonar-web/design-system/src/components/buttons.tsx View File

@@ -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<HTMLButtonElement>,
'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<HTMLButtonElement>;
onClick?: VoidFunction;

preventDefault?: boolean;
reloadDocument?: LinkProps['reloadDocument'];
stopPropagation?: boolean;
target?: LinkProps['target'];
to?: LinkProps['to'];
}

class Button extends React.PureComponent<ButtonProps> {
handleClick = (event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
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 (
<BaseButtonLink {...props} onClick={onClick} to={to}>
{icon}
{children}
</BaseButtonLink>
);
}

return (
<BaseButton {...props} onClick={this.handleClick} ref={innerRef}>
{icon}
{children}
</BaseButton>
);
}
}

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<ButtonProps> = 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<ButtonProps> = 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<ButtonProps> = 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<ButtonProps> = styled(Button)`
--background: ${themeColor('dangerButtonSecondary')};
--backgroundHover: ${themeColor('dangerButtonSecondaryHover')};
--color: ${themeContrast('dangerButtonSecondary')};
--focus: ${themeColor('dangerButtonSecondaryFocus', 0.2)};
--border: ${themeBorder('default', 'dangerButtonSecondaryBorder')};
`;

interface ThirdPartyProps extends Omit<ButtonProps, 'Icon'> {
iconPath: string;
name: string;
}

export function ThirdPartyButton({ children, iconPath, name, ...buttonProps }: ThirdPartyProps) {
const size = 16;
return (
<ThirdPartyButtonStyled {...buttonProps}>
<img alt={name} className="sw-mr-1" height={size} src={iconPath} width={size} />
{children}
</ThirdPartyButtonStyled>
);
}

const ThirdPartyButtonStyled: React.FC<ButtonProps> = 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;
`;

+ 170
- 0
server/sonar-web/design-system/src/components/clipboard.tsx View File

@@ -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<BaseProps, State> {
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 = <CopyIcon />,
className,
children,
copyValue,
}: ButtonProps) {
return (
<ClipboardBase>
{({ setCopyButton, copySuccess }) => (
<Tooltip overlay={translate('copied_action')} visible={copySuccess}>
<ButtonSecondary
className={classNames('sw-select-none', className)}
data-clipboard-text={copyValue}
icon={icon}
innerRef={setCopyButton}
>
{children || translate('copy')}
</ButtonSecondary>
</Tooltip>
)}
</ClipboardBase>
);
}

interface IconButtonProps {
Icon?: React.ComponentType<IconProps>;
'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 (
<ClipboardBase>
{({ setCopyButton, copySuccess }) => {
return (
<Tooltip
mouseEnterDelay={INTERACTIVE_TOOLTIP_DELAY}
overlay={
<div className="sw-w-abs-150 sw-text-center">
{translate(copySuccess ? 'copied_action' : 'copy_to_clipboard')}
</div>
}
{...(copySuccess ? { visible: copySuccess } : undefined)}
>
<InteractiveIconComponent
Icon={Icon}
aria-label={props['aria-label'] ?? translate('copy_to_clipboard')}
className={className}
data-clipboard-text={copyValue}
innerRef={setCopyButton}
size={size}
/>
</Tooltip>
);
}}
</ClipboardBase>
);
}

+ 36
- 0
server/sonar-web/design-system/src/components/icons/CheckIcon.tsx View File

@@ -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 (
<CustomIcon {...iconProps}>
<path
clipRule="evenodd"
d="M11.6634 5.47789c.2884.29737.2811.77218-.0163 1.06054L7.52211 10.5384c-.29414.2852-.76273.2816-1.05244-.0081l-2-1.99997c-.29289-.29289-.29289-.76777 0-1.06066s.76777-.29289 1.06066 0L7.0081 8.94744l3.5948-3.48586c.2974-.28836.7722-.28105 1.0605.01631Z"
fill={themeColor(fill)({ theme })}
fillRule="evenodd"
/>
</CustomIcon>
);
}

+ 23
- 0
server/sonar-web/design-system/src/components/icons/ClockIcon.tsx View File

@@ -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);

+ 23
- 0
server/sonar-web/design-system/src/components/icons/CloseIcon.tsx View File

@@ -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');

+ 23
- 0
server/sonar-web/design-system/src/components/icons/CopyIcon.tsx View File

@@ -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);

+ 86
- 0
server/sonar-web/design-system/src/components/icons/Icon.tsx View File

@@ -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<Props, 'children'> {
fill?: ThemeColors | CSSColor;
}

export function CustomIcon(props: Props) {
const { 'aria-label': ariaLabel, children, className, ...iconProps } = props;
return (
<svg
aria-hidden={ariaLabel ? 'false' : 'true'}
aria-label={ariaLabel}
className={className}
fill="none"
height={theme('height.icon')}
role="img"
style={{
clipRule: 'evenodd',
display: 'inline-block',
fillRule: 'evenodd',
userSelect: 'none',
verticalAlign: 'middle',
strokeLinejoin: 'round',
strokeMiterlimit: 1.414,
}}
version="1.1"
viewBox="0 0 16 16"
width={theme('width.icon')}
xmlSpace="preserve"
xmlnsXlink="http://www.w3.org/1999/xlink"
{...iconProps}
>
{children}
</svg>
);
}

export function OcticonHoc(
WrappedOcticon: React.ComponentType<OcticonProps>,
displayName?: string
): React.ComponentType<IconProps> {
function IconWrapper({ fill, ...props }: IconProps) {
const theme = useTheme();
return (
<WrappedOcticon
fill={fill && themeColor(fill)({ theme })}
size="small"
verticalAlign="middle"
{...props}
/>
);
}

IconWrapper.displayName = displayName || WrappedOcticon.displayName || WrappedOcticon.name;
return IconWrapper;
}

+ 36
- 0
server/sonar-web/design-system/src/components/icons/MenuHelpIcon.tsx View File

@@ -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 (
<CustomIcon {...iconProps}>
<path
clipRule="evenodd"
d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16Zm.507-5.451H6.66v-.166c.005-1.704.462-2.226 1.28-2.742.6-.38 1.062-.803 1.062-1.441 0-.677-.53-1.116-1.188-1.116-.638 0-1.227.424-1.257 1.218H4.571c.044-1.948 1.486-2.873 3.254-2.873 1.933 0 3.307.993 3.307 2.698 0 1.144-.595 1.86-1.505 2.4-.77.463-1.11.906-1.12 1.856v.166Zm.282 1.948a1.185 1.185 0 0 1-1.169 1.169 1.164 1.164 0 1 1 0-2.328c.624 0 1.164.52 1.169 1.159Z"
fill={themeColor(fill)({ theme })}
fillRule="evenodd"
/>
</CustomIcon>
);
}

+ 29
- 0
server/sonar-web/design-system/src/components/icons/MenuIcon.tsx View File

@@ -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;

+ 37
- 0
server/sonar-web/design-system/src/components/icons/MenuSearchIcon.tsx View File

@@ -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 (
<CustomIcon {...iconProps}>
<path
clipRule="evenodd"
d="M12 7c0 2.76142-2.23858 5-5 5S2 9.76142 2 7s2.23858-5 5-5 5 2.23858 5 5Zm-.8078 5.6064C10.0236 13.4816 8.57234 14 7 14c-3.86599 0-7-3.134-7-7 0-3.86599 3.13401-7 7-7 3.866 0 7 3.13401 7 7 0 1.57234-.5184 3.0236-1.3936 4.1922l3.0505 3.0504c.3905.3906.3905 1.0237 0 1.4143-.3906.3905-1.0237.3905-1.4143 0l-3.0504-3.0505Z"
fill={themeColor(fill)({ theme })}
fillRule="evenodd"
/>
</CustomIcon>
);
}

+ 23
- 0
server/sonar-web/design-system/src/components/icons/OpenNewTabIcon.tsx View File

@@ -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');

+ 23
- 0
server/sonar-web/design-system/src/components/icons/SearchIcon.tsx View File

@@ -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);

+ 23
- 0
server/sonar-web/design-system/src/components/icons/StarIcon.tsx View File

@@ -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);

+ 54
- 0
server/sonar-web/design-system/src/components/icons/__tests__/Icon-test.tsx View File

@@ -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(
<CustomIcon>
<path d="test" />
</CustomIcon>
);

expect(screen.queryByRole('img')).not.toBeInTheDocument();
expect(screen.getByRole('img', { hidden: true })).toContainHTML('<path d="test"/>');
});

it('should not be hidden when aria-label is set', () => {
render(
<CustomIcon aria-label="test">
<path d="test" />
</CustomIcon>
);

expect(screen.getByRole('img')).toBeVisible();
});

describe('Octicon HOC', () => {
it('should render correctly', () => {
const Wrapped = OcticonHoc(CheckIcon, 'TestIcon');

render(<Wrapped aria-label="visible" />);

expect(screen.getByRole('img')).toBeVisible();
});
});

+ 24
- 0
server/sonar-web/design-system/src/components/icons/index.ts View File

@@ -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';

+ 18
- 1
server/sonar-web/design-system/src/components/index.ts View File

@@ -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';

+ 256
- 0
server/sonar-web/design-system/src/components/popups.tsx View File

@@ -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<HTMLDivElement>) {
const {
children,
className,
placement = PopupPlacement.Bottom,
style,
zLevel = PopupZLevel.Default,
...ariaProps
} = props;
return (
<ClickEventBoundary>
<PopupWrapper
className={classNames(`is-${placement}`, className)}
ref={ref || React.createRef()}
style={style}
zLevel={zLevel}
{...ariaProps}
>
{children}
</PopupWrapper>
</ClickEventBoundary>
);
}

const PopupWithRef = React.forwardRef(PopupBase);
PopupWithRef.displayName = 'Popup';

export const Popup = PopupWithRef;

interface PortalPopupProps extends Omit<PopupProps, 'style'> {
allowResizing?: boolean;
children: React.ReactNode;
overlay: React.ReactNode;
}

interface Measurements {
height: number;
left: number;
top: number;
width: number;
}

type State = Partial<Measurements>;

function isMeasured(state: State): state is Measurements {
return state.height !== undefined;
}

export class PortalPopup extends React.PureComponent<PortalPopupProps, State> {
mounted = false;
popupNode = React.createRef<HTMLDivElement>();
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 && (
<PortalWrapper>
<Popup placement={placement} ref={this.popupNode} style={style} {...popupProps}>
{overlay}
</Popup>
</PortalWrapper>
)}
</>
);
}
}

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);
}
}

+ 61
- 0
server/sonar-web/design-system/src/helpers/__tests__/colors-test.ts View File

@@ -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)');
});
});

+ 167
- 0
server/sonar-web/design-system/src/helpers/__tests__/positioning-test.ts View File

@@ -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,
});
});

+ 148
- 0
server/sonar-web/design-system/src/helpers/__tests__/theme-test.ts View File

@@ -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)'
);
});
});

+ 56
- 0
server/sonar-web/design-system/src/helpers/colors.ts View File

@@ -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<number | string>, a?: number | string) {
return (a !== undefined ? `rgba(${r},${g},${b},${a})` : `rgb(${r},${g},${b})`) as CSSColor;
}

+ 68
- 0
server/sonar-web/design-system/src/helpers/constants.ts View File

@@ -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';

server/sonar-web/design-system/src/components/DummyComponent.tsx → server/sonar-web/design-system/src/helpers/index.ts View File

@@ -17,7 +17,5 @@
* 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 <div>I&apos;m a dummy</div>;
}
export * from './constants';
export * from './positioning';

+ 52
- 0
server/sonar-web/design-system/src/helpers/keyboard.ts View File

@@ -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);
}

+ 30
- 0
server/sonar-web/design-system/src/helpers/l10n.ts View File

@@ -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 | number>
): string {
return `${messageKey}.${parameters.join('.')}`;
}

+ 185
- 0
server/sonar-web/design-system/src/helpers/positioning.ts View File

@@ -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
);
}

+ 117
- 0
server/sonar-web/design-system/src/helpers/testUtils.tsx View File

@@ -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<RenderOptions, 'wrapper'> & {
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 (
<HelmetProvider>
<MemoryRouter>
<Routes>
<Route element={children} path="/" />
{additionalRoutes}
</Routes>
</MemoryRouter>
</HelmetProvider>
);
}

return render(ui, { ...renderOptions, wrapper: RouterWrapper }, userEventOptions);
}

function getContextWrapper() {
return function ContextWrapper({ children }: React.PropsWithChildren<{}>) {
return (
<HelmetProvider>
<IntlProvider defaultLocale="en" locale="en">
{children}
</IntlProvider>
</HelmetProvider>
);
};
}

export function mockComponent(name: string, transformProps: (props: any) => any = identity) {
function MockedComponent({ ...props }: PropsWithChildren<any>) {
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<void> {
return new Promise((resolve) => {
if (usingFakeTime) {
jest.useRealTimers();
}
setTimeout(resolve, 0);
if (usingFakeTime) {
jest.useFakeTimers();
}
});
}

+ 130
- 0
server/sonar-web/design-system/src/helpers/theme.ts View File

@@ -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<T>(name: keyof Omit<T, keyof ThemedProps>) {
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]);
}

+ 22
- 0
server/sonar-web/design-system/src/helpers/types.ts View File

@@ -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<T>(x: T | undefined | null): x is T {
return x !== undefined && x !== null;
}

+ 23
- 0
server/sonar-web/design-system/src/index.ts View File

@@ -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';

+ 136
- 0
server/sonar-web/design-system/src/theme/colors.ts View File

@@ -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],
},
};

+ 20
- 0
server/sonar-web/design-system/src/theme/index.ts View File

@@ -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';

+ 743
- 0
server/sonar-web/design-system/src/theme/light.ts View File

@@ -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;

+ 21
- 0
server/sonar-web/design-system/src/types/misc.ts View File

@@ -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<T extends React.FunctionComponent<any>> = Parameters<T>[0];

+ 45
- 0
server/sonar-web/design-system/src/types/theme.ts View File

@@ -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<LightTheme, 'colors' | 'contrasts'> {
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;
}

+ 5
- 3
server/sonar-web/design-system/tsconfig.json View File

@@ -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/**/*"]
}

+ 2
- 2
server/sonar-web/design-system/vite.config.js View File

@@ -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',
}),
],
});

+ 7
- 1
server/sonar-web/jest.config.js View File

@@ -17,11 +17,17 @@ module.exports = {
'<rootDir>/config/polyfills.ts',
'<rootDir>/config/jest/SetupEnzyme.ts',
'<rootDir>/config/jest/SetupTestEnvironment.ts',
'<rootDir>/config/jest/SetupTheme.js',
],
setupFilesAfterEnv: ['<rootDir>/config/jest/SetupReactTestingLibrary.ts'],
snapshotSerializers: ['enzyme-to-json/serializer', '@emotion/jest/serializer'],
testEnvironment: 'jsdom',
testPathIgnorePatterns: ['<rootDir>/config', '<rootDir>/node_modules', '<rootDir>/scripts'],
testPathIgnorePatterns: [
'<rootDir>/config',
'<rootDir>/design-system',
'<rootDir>/node_modules',
'<rootDir>/scripts',
],
testRegex: '(/__tests__/.*|\\-test)\\.(ts|tsx|js)$',
transform: {
'^.+\\.(t|j)sx?$': [

+ 2
- 1
server/sonar-web/package.json View File

@@ -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",

+ 32
- 28
server/sonar-web/src/main/js/app/components/GlobalContainer.tsx View File

@@ -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 (
<SuggestionsProvider>
<A11yProvider>
<StartupModal>
<A11ySkipLinks />
<div className="global-container">
<div className="page-wrapper" id="container">
<div className="page-container">
<BranchStatusContextProvider>
<Workspace>
<IndexationContextProvider>
<LanguagesContextProvider>
<MetricsContextProvider>
<SystemAnnouncement />
<IndexationNotification />
<UpdateNotification dismissable={true} />
<GlobalNav location={location} />
<Outlet />
</MetricsContextProvider>
</LanguagesContextProvider>
</IndexationContextProvider>
</Workspace>
</BranchStatusContextProvider>
<ThemeProvider theme={lightTheme}>
<SuggestionsProvider>
<A11yProvider>
<StartupModal>
<A11ySkipLinks />
<div className="global-container">
<div className="page-wrapper" id="container">
<div className="page-container">
<BranchStatusContextProvider>
<Workspace>
<IndexationContextProvider>
<LanguagesContextProvider>
<MetricsContextProvider>
<SystemAnnouncement />
<IndexationNotification />
<UpdateNotification dismissable={true} />
<GlobalNav location={location} />
<Outlet />
</MetricsContextProvider>
</LanguagesContextProvider>
</IndexationContextProvider>
</Workspace>
</BranchStatusContextProvider>
</div>
<PromotionNotification />
</div>
<PromotionNotification />
<GlobalFooter />
</div>
<GlobalFooter />
</div>
</StartupModal>
</A11yProvider>
</SuggestionsProvider>
</StartupModal>
</A11yProvider>
</SuggestionsProvider>
</ThemeProvider>
);
}

+ 2
- 3
server/sonar-web/src/main/js/app/components/SimpleContainer.tsx View File

@@ -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 (
<div className="global-container">
<div className="page-wrapper" id="container">
<NavBar className="global-navbar" height={rawSizes.globalNavHeightRaw} />
<MainSonarQubeBar />
{children !== undefined ? children : <Outlet />}
</div>
<GlobalFooter />

server/sonar-web/src/main/js/app/components/search/Search.tsx → server/sonar-web/src/main/js/app/components/global-search/GlobalSearch.tsx View File

@@ -17,17 +17,23 @@
* 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 { FormattedMessage } from 'react-intl';
import { getSuggestions } from '../../../api/components';
import { DropdownOverlay } from '../../../components/controls/Dropdown';
import FocusOutHandler from '../../../components/controls/FocusOutHandler';
import OutsideClickHandler from '../../../components/controls/OutsideClickHandler';
import SearchBox from '../../../components/controls/SearchBox';
import { Router, withRouter } from '../../../components/hoc/withRouter';
import ClockIcon from '../../../components/icons/ClockIcon';
import DeferredSpinner from '../../../components/ui/DeferredSpinner';
import { PopupPlacement } from '../../../components/ui/popups';
import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
import { KeyboardKeys } from '../../../helpers/keycodes';
import { translate, translateWithParameters } from '../../../helpers/l10n';
@@ -37,9 +43,8 @@ import { getComponentOverviewUrl } from '../../../helpers/urls';
import { ComponentQualifier } from '../../../types/component';
import { Dict } from '../../../types/types';
import RecentHistory from '../RecentHistory';
import './Search.css';
import SearchResult from './SearchResult';
import SearchResults from './SearchResults';
import GlobalSearchResult from './GlobalSearchResult';
import GlobalSearchResults from './GlobalSearchResults';
import { ComponentResult, More, Results, sortQualifiers } from './utils';

interface Props {
@@ -53,12 +58,10 @@ interface State {
query: string;
results: Results;
selected?: string;
shortQuery: boolean;
}

const MIN_SEARCH_QUERY_LENGTH = 2;

export class Search extends React.PureComponent<Props, State> {
export class GlobalSearch extends React.PureComponent<Props, State> {
input?: HTMLInputElement | null;
node?: HTMLElement | null;
nodes: Dict<HTMLElement>;
@@ -74,13 +77,11 @@ export class Search extends React.PureComponent<Props, State> {
open: false,
query: '',
results: {},
shortQuery: false,
};
}

componentDidMount() {
this.mounted = true;
document.addEventListener('keydown', this.handleKeyDown);
document.addEventListener('keydown', this.handleSKeyDown);
}

@@ -93,7 +94,6 @@ export class Search extends React.PureComponent<Props, State> {
componentWillUnmount() {
this.mounted = false;
document.removeEventListener('keydown', this.handleSKeyDown);
document.removeEventListener('keydown', this.handleKeyDown);
}

focusInput = () => {
@@ -135,7 +135,6 @@ export class Search extends React.PureComponent<Props, State> {
query: '',
results: {},
selected: undefined,
shortQuery: false,
});
} else {
this.setState({ open: false });
@@ -178,8 +177,6 @@ export class Search extends React.PureComponent<Props, State> {
more,
results,
selected: list.length > 0 ? list[0] : undefined,
shortQuery:
query.length > MIN_SEARCH_QUERY_LENGTH && response.warning === 'short_input',
});
}
}, this.stopLoading);
@@ -216,7 +213,7 @@ export class Search extends React.PureComponent<Props, State> {
};

handleQueryChange = (query: string) => {
this.setState({ query, shortQuery: query.length === 1 });
this.setState({ query });
this.search(query);
};

@@ -270,7 +267,11 @@ export class Search extends React.PureComponent<Props, State> {
if (this.state.selected) {
const node = this.nodes[this.state.selected];
if (node && this.node) {
scrollToElement(node, { topOffset: 30, bottomOffset: 30, parent: this.node });
scrollToElement(node, {
topOffset: 30,
bottomOffset: 60,
parent: this.node,
});
}
}
};
@@ -286,7 +287,7 @@ export class Search extends React.PureComponent<Props, State> {
}
};

handleKeyDown = (event: KeyboardEvent) => {
handleKeyDown = (event: React.KeyboardEvent) => {
if (!this.state.open) {
return;
}
@@ -330,7 +331,7 @@ export class Search extends React.PureComponent<Props, State> {
};

renderResult = (component: ComponentResult) => (
<SearchResult
<GlobalSearchResult
component={component}
innerRef={this.innerRef}
key={component.key}
@@ -341,73 +342,89 @@ export class Search extends React.PureComponent<Props, State> {
);

renderNoResults = () => (
<div className="navbar-search-no-results" aria-live="assertive">
<div className="sw-px-3 sw-py-2" aria-live="assertive">
{translateWithParameters('no_results_for_x', this.state.query)}
</div>
);

render() {
const { open, query, results, more, loadingMore, selected, loading } = this.state;
if (!open && !query) {
return (
<Tooltip mouseEnterDelay={INTERACTIVE_TOOLTIP_DELAY} overlay={translate('search_verb')}>
<InteractiveIcon
className="it__search-icon"
Icon={MenuSearchIcon}
aria-label={translate('search_verb')}
currentColor={true}
onClick={this.handleFocus}
size="medium"
/>
</Tooltip>
);
}

const list = this.getPlainComponentsList(results, more);
const search = (
<div role="search" className="navbar-search dropdown">
<DeferredSpinner className="navbar-search-icon" loading={this.state.loading} />

<SearchBox
autoFocus={this.state.open}
innerRef={this.searchInputRef}
minLength={2}
onChange={this.handleQueryChange}
onFocus={this.handleFocus}
placeholder={translate('search.placeholder')}
value={this.state.query}
/>

{this.state.shortQuery && (
<span className="navbar-search-input-hint" aria-live="assertive">
{translateWithParameters('select2.tooShort', MIN_SEARCH_QUERY_LENGTH)}
</span>
)}

{this.state.open && Object.keys(this.state.results).length > 0 && (
<DropdownOverlay noPadding={true}>
<div className="global-navbar-search-dropdown" ref={(node) => (this.node = node)}>
<SearchResults
allowMore={this.state.query.length !== 1}
loadingMore={this.state.loadingMore}
more={this.state.more}
onMoreClick={this.searchMore}
onSelect={this.handleSelect}
renderNoResults={this.renderNoResults}
renderResult={this.renderResult}
results={this.state.results}
selected={this.state.selected}
/>
<div className="dropdown-bottom-hint">
<div className="pull-right" aria-hidden={true}>
<ClockIcon className="little-spacer-right" size={12} />
{translate('recently_browsed')}
</div>
<FormattedMessage
defaultMessage={translate('search.shortcut_hint')}
id="search.shortcut_hint"
values={{
shortcut: <span className="shortcut-button shortcut-button-small">s</span>,
}}
<div role="search" className="sw-min-w-abs-200 sw-max-w-abs-350 sw-w-full">
<PortalPopup
allowResizing={true}
overlay={
open && (
<DropdownMenu
className="it__global-navbar-search-dropdown sw-overflow-y-auto sw-overflow-x-hidden"
maxHeight="38rem"
innerRef={(node: HTMLUListElement | null) => (this.node = node)}
size="auto"
>
<GlobalSearchResults
query={query}
loadingMore={loadingMore}
more={more}
onMoreClick={this.searchMore}
onSelect={this.handleSelect}
renderNoResults={this.renderNoResults}
renderResult={this.renderResult}
results={results}
selected={selected}
/>
</div>
</div>
</DropdownOverlay>
)}
{list.length > 0 && (
<li className="sw-px-3 sw-pt-1">
<TextMuted text={translate('global_search.shortcut_hint')} />
</li>
)}
</DropdownMenu>
)
}
placement={PopupPlacement.BottomLeft}
zLevel={PopupZLevel.Global}
>
<InputSearch
className="sw-w-full"
autoFocus={open}
innerRef={this.searchInputRef}
loading={loading}
minLength={MIN_SEARCH_QUERY_LENGTH}
onChange={this.handleQueryChange}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDown}
placeholder={translate('search.search_for_projects')}
size="auto"
value={query}
tooShortText={translateWithParameters('select2.tooShort', MIN_SEARCH_QUERY_LENGTH)}
searchInputAriaLabel={translate('search_verb')}
clearIconAriaLabel={translate('clear')}
/>
</PortalPopup>
</div>
);

return this.state.open ? (
<FocusOutHandler onFocusOut={this.handleClickOutside}>
<OutsideClickHandler onClickOutside={this.handleClickOutside}>{search}</OutsideClickHandler>
</FocusOutHandler>
return open ? (
<OutsideClickHandler onClickOutside={this.handleClickOutside}>{search}</OutsideClickHandler>
) : (
search
);
}
}

export default withRouter(Search);
export default withRouter(GlobalSearch);

+ 65
- 0
server/sonar-web/src/main/js/app/components/global-search/GlobalSearchResult.tsx View File

@@ -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<Props> {
doSelect = () => {
this.props.onSelect(this.props.component.key);
};

render() {
const { component, selected } = this.props;
const to = getComponentOverviewUrl(component.key, component.qualifier);
return (
<ItemLink
className={classNames('sw-flex sw-flex-col sw-items-start sw-space-y-1', {
active: selected,
})}
innerRef={(node: HTMLAnchorElement | null) => this.props.innerRef(component.key, node)}
key={component.key}
onClick={this.props.onClose}
onPointerEnter={this.doSelect}
to={to}
>
<div className="sw-flex sw-justify-between sw-items-center sw-w-full">
<SearchText match={component.match} name={component.name} />
{component.isFavorite && <FavoriteIcon favorite={true} size={16} />}
{!component.isFavorite && component.isRecentlyBrowsed && (
<ClockIcon aria-label={translate('recently_browsed')} />
)}
</div>
<TextMuted text={component.key} />
</ItemLink>
);
}
}

server/sonar-web/src/main/js/app/components/search/SearchResults.tsx → server/sonar-web/src/main/js/app/components/global-search/GlobalSearchResults.tsx View File

@@ -17,13 +17,14 @@
* 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 SearchShowMore from './SearchShowMore';
import GlobalSearchShowMore from './GlobalSearchShowMore';
import { ComponentResult, More, Results, sortQualifiers } from './utils';

export interface Props {
allowMore: boolean;
query: string;
loadingMore?: string;
more: More;
onMoreClick: (qualifier: string) => void;
@@ -34,30 +35,25 @@ export interface Props {
selected?: string;
}

export default function SearchResults(props: Props): React.ReactElement<Props> {
export default function GlobalSearchResults(props: Props): React.ReactElement<Props> {
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(
<>
<h2 className="menu-header no-margin" id={translate('qualifiers', qualifier)}>
{translate('qualifiers', qualifier)}
</h2>
<ul
className="menu"
key={`header-${qualifier}`}
aria-labelledby={translate('qualifiers', qualifier)}
>
<li key={`group-${qualifier}`}>
<ul key={`header-${qualifier}`} aria-labelledby={translate('qualifiers', qualifier)}>
<ItemHeader>
<p id={translate('qualifiers', qualifier)}>{translate('qualifiers', qualifier)}</p>
</ItemHeader>
{components.map((component) => props.renderResult(component))}
{more !== undefined && more > 0 && (
<SearchShowMore
allowMore={props.allowMore}
<GlobalSearchShowMore
allowMore={allowMore}
key={`more-${qualifier}`}
loadingMore={props.loadingMore}
onMoreClick={props.onMoreClick}
@@ -66,11 +62,12 @@ export default function SearchResults(props: Props): React.ReactElement<Props> {
selected={props.selected === `qualifier###${qualifier}`}
/>
)}
<ItemDivider />
</ul>
</>
</li>
);
}
});

return renderedComponents.length > 0 ? <div>{renderedComponents}</div> : props.renderNoResults();
return renderedComponents.length > 0 ? <>{renderedComponents}</> : props.renderNoResults();
}

server/sonar-web/src/main/js/app/components/search/SearchShowMore.tsx → server/sonar-web/src/main/js/app/components/global-search/GlobalSearchShowMore.tsx View File

@@ -18,9 +18,8 @@
* 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 { FormattedMessage } from 'react-intl';
import DeferredSpinner from '../../../components/ui/DeferredSpinner';
import { translate } from '../../../helpers/l10n';

interface Props {
@@ -32,50 +31,38 @@ interface Props {
selected: boolean;
}

export default class SearchShowMore extends React.PureComponent<Props> {
handleMoreClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
export default class GlobalSearchShowMore extends React.PureComponent<Props> {
handleMoreClick = (event: React.MouseEvent<HTMLButtonElement>, qualifier: string) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.blur();
const { qualifier } = event.currentTarget.dataset;
if (qualifier) {
this.props.onMoreClick(qualifier);
}
};

handleMoreMouseEnter = (event: React.MouseEvent<HTMLAnchorElement>) => {
const { qualifier } = event.currentTarget.dataset;
handleMouseEnter = (qualifier: string) => {
if (qualifier) {
this.props.onSelect(`qualifier###${qualifier}`);
}
};

render() {
const { loadingMore, qualifier, selected } = this.props;
const { loadingMore, qualifier, selected, allowMore } = this.props;

return (
<li className={classNames('menu-footer', { active: selected })} key={`more-${qualifier}`}>
<DeferredSpinner className="navbar-search-icon" loading={loadingMore === qualifier}>
<a
className={classNames({ 'cursor-not-allowed': !this.props.allowMore })}
data-qualifier={qualifier}
href="#"
onClick={this.handleMoreClick}
onMouseEnter={this.handleMoreMouseEnter}
>
<div className="pull-right text-muted-2 menu-footer-note">
<FormattedMessage
defaultMessage={translate('search.show_more.hint')}
id="search.show_more.hint"
values={{
key: <span className="shortcut-button shortcut-button-small">Enter</span>,
}}
/>
</div>
<span>{translate('show_more')}</span>
</a>
<ItemButton
className={classNames({ active: selected })}
disabled={!allowMore}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => this.handleMoreClick(e, qualifier)}
onPointerEnter={() => {
this.handleMouseEnter(qualifier);
}}
>
<DeferredSpinner loading={loadingMore === qualifier}>
{translate('show_more')}
</DeferredSpinner>
</li>
</ItemButton>
);
}
}

+ 214
- 0
server/sonar-web/src/main/js/app/components/global-search/__tests__/GlobalSearch-it.tsx View File

@@ -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: '<mark>Bar</mark>',
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(<GlobalSearchWithoutRouter router={router} />);
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(<GlobalSearch />);
}

server/sonar-web/src/main/js/app/components/search/utils.ts → server/sonar-web/src/main/js/app/components/global-search/utils.ts View File


+ 0
- 125
server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css View File

@@ -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;
}
}

+ 15
- 15
server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx View File

@@ -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 (
<div style={{ height: sizes.globalNavHeight }}>
<div className="navbar global-navbar" id="global-navigation">
<div className="global-navbar-inner">
<GlobalNavBranding />

<MainSonarQubeBar>
<div className="sw-flex" id="global-navigation">
<div className="it__global-navbar-menu sw-flex sw-justify-start sw-items-center sw-flex-1">
<GlobalNavMenu currentUser={currentUser} location={location} />
<div className="sw-px-8 sw-flex-1">
<GlobalSearch />
</div>
</div>

<div className="global-navbar-menu global-navbar-menu-right">
<EmbedDocsPopupHelper />
<Search />
<GlobalNavUser currentUser={currentUser} />
<div className="sw-flex sw-items-center sw-ml-2">
<EmbedDocsPopupHelper />
<div className="sw-ml-4">
<GlobalNavUser />
</div>
</div>
</div>
</div>
</MainSonarQubeBar>
);
}


+ 21
- 58
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMenu.tsx View File

@@ -18,19 +18,18 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import classNames from 'classnames';
import { MainMenu, MainMenuItem } from 'design-system';
import * as React from 'react';
import { NavLink } from 'react-router-dom';
import { isMySet } from '../../../../apps/issues/utils';
import Link from '../../../../components/common/Link';
import Dropdown from '../../../../components/controls/Dropdown';
import DropdownIcon from '../../../../components/icons/DropdownIcon';
import { translate } from '../../../../helpers/l10n';
import { getQualityGatesUrl } from '../../../../helpers/urls';
import { AppState } from '../../../../types/appstate';
import { ComponentQualifier } from '../../../../types/component';
import { Extension } from '../../../../types/types';
import { CurrentUser } from '../../../../types/users';
import withAppStateContext from '../../app-state/withAppStateContext';
import GlobalNavMore from './GlobalNavMore';

interface Props {
appState: AppState;
@@ -39,14 +38,15 @@ interface Props {
}

const ACTIVE_CLASS_NAME = 'active';
export class GlobalNavMenu extends React.PureComponent<Props> {

class GlobalNavMenu extends React.PureComponent<Props> {
renderProjects() {
const active =
this.props.location.pathname.startsWith('/projects') &&
this.props.location.pathname !== '/projects/create';

return (
<li>
<MainMenuItem>
<Link
aria-current={active ? 'page' : undefined}
className={classNames({ active })}
@@ -54,17 +54,17 @@ export class GlobalNavMenu extends React.PureComponent<Props> {
>
{translate('projects.page')}
</Link>
</li>
</MainMenuItem>
);
}

renderPortfolios() {
return (
<li>
<MainMenuItem>
<NavLink className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')} to="/portfolios">
{translate('portfolios.page')}
</NavLink>
</li>
</MainMenuItem>
);
}

@@ -76,50 +76,50 @@ export class GlobalNavMenu extends React.PureComponent<Props> {
).toString();

return (
<li>
<MainMenuItem>
<NavLink
className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')}
to={{ pathname: '/issues', search }}
>
{translate('issues.page')}
</NavLink>
</li>
</MainMenuItem>
);
}

renderRulesLink() {
return (
<li>
<MainMenuItem>
<NavLink
className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')}
to="/coding_rules"
>
{translate('coding_rules.page')}
</NavLink>
</li>
</MainMenuItem>
);
}

renderProfilesLink() {
return (
<li>
<MainMenuItem>
<NavLink className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')} to="/profiles">
{translate('quality_profiles.page')}
</NavLink>
</li>
</MainMenuItem>
);
}

renderQualityGatesLink() {
return (
<li>
<MainMenuItem>
<NavLink
className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')}
to={getQualityGatesUrl()}
>
{translate('quality_gates.page')}
</NavLink>
</li>
</MainMenuItem>
);
}

@@ -129,51 +129,14 @@ export class GlobalNavMenu extends React.PureComponent<Props> {
}

return (
<li>
<MainMenuItem>
<NavLink
className={({ isActive }) => (isActive ? ACTIVE_CLASS_NAME : '')}
to="/admin/settings"
>
{translate('layout.settings')}
</NavLink>
</li>
);
}

renderGlobalPageLink = ({ key, name }: Extension) => {
return (
<li key={key}>
<Link to={`/extension/${key}`}>{name}</Link>
</li>
);
};

renderMore() {
const { globalPages = [] } = this.props.appState;
const withoutPortfolios = globalPages.filter((page) => page.key !== 'governance/portfolios');
if (withoutPortfolios.length === 0) {
return null;
}
return (
<Dropdown
overlay={<ul className="menu">{withoutPortfolios.map(this.renderGlobalPageLink)}</ul>}
tagName="li"
>
{({ onToggleClick, open }) => (
<a
aria-expanded={open}
aria-haspopup="menu"
role="button"
className={classNames('dropdown-toggle', { active: open })}
href="#"
id="global-navigation-more"
onClick={onToggleClick}
>
{translate('more')}
<DropdownIcon className="little-spacer-left text-middle" />
</a>
)}
</Dropdown>
</MainMenuItem>
);
}

@@ -184,7 +147,7 @@ export class GlobalNavMenu extends React.PureComponent<Props> {

return (
<nav aria-label={translate('global')}>
<ul className="global-navbar-menu">
<MainMenu>
{this.renderProjects()}
{governanceInstalled && this.renderPortfolios()}
{this.renderIssuesLink()}
@@ -192,8 +155,8 @@ export class GlobalNavMenu extends React.PureComponent<Props> {
{this.renderProfilesLink()}
{this.renderQualityGatesLink()}
{this.renderAdministrationLink()}
{this.renderMore()}
</ul>
<GlobalNavMore />
</MainMenu>
</nav>
);
}

+ 69
- 0
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMore.tsx View File

@@ -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 { Dropdown, ItemNavLink, MainMenuItem, PopupPlacement } from 'design-system';
import * as React from 'react';
import { translate } from '../../../../helpers/l10n';
import { AppState } from '../../../../types/appstate';
import { Extension } from '../../../../types/types';
import withAppStateContext from '../../app-state/withAppStateContext';

const renderGlobalPageLink = ({ key, name }: Extension) => {
return (
<ItemNavLink key={key} to={`/extension/${key}`}>
{name}
</ItemNavLink>
);
};

function GlobalNavMore({ appState: { globalPages = [] } }: { appState: AppState }) {
const withoutPortfolios = globalPages.filter((page) => page.key !== 'governance/portfolios');

if (withoutPortfolios.length === 0) {
return null;
}

return (
<Dropdown
id="moreMenuDropdown"
overlay={<ul>{withoutPortfolios.map(renderGlobalPageLink)}</ul>}
placement={PopupPlacement.BottomLeft}
>
{({ onToggleClick, open }) => (
<ul>
<MainMenuItem>
<a
aria-expanded={open}
aria-haspopup="menu"
href="#"
id="global-navigation-more"
onClick={onToggleClick}
role="button"
>
{translate('more')}
</a>
</MainMenuItem>
</ul>
)}
</Dropdown>
);
}

export default withAppStateContext(GlobalNavMore);

+ 54
- 78
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.tsx View File

@@ -17,99 +17,75 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import {
Avatar,
BareButton,
ButtonSecondary,
Dropdown,
PopupPlacement,
PopupZLevel,
Tooltip,
} from 'design-system';
import * as React from 'react';
import Link from '../../../../components/common/Link';
import Dropdown from '../../../../components/controls/Dropdown';
import { Router, withRouter } from '../../../../components/hoc/withRouter';
import Avatar from '../../../../components/ui/Avatar';
import { translate } from '../../../../helpers/l10n';
import { getBaseUrl } from '../../../../helpers/system';
import { CurrentUser, isLoggedIn, LoggedInUser } from '../../../../types/users';
import { rawSizes } from '../../../theme';
import { GlobalSettingKeys } from '../../../../types/settings';
import { isLoggedIn } from '../../../../types/users';
import { AppStateContext } from '../../app-state/AppStateContext';
import { CurrentUserContext } from '../../current-user/CurrentUserContext';
import { GlobalNavUserMenu } from './GlobalNavUserMenu';

interface Props {
currentUser: CurrentUser;
router: Router;
}
export function GlobalNavUser() {
const userContext = React.useContext(CurrentUserContext);
const currentUser = userContext?.currentUser;

export class GlobalNavUser extends React.PureComponent<Props> {
focusNode = (node: HTMLAnchorElement | null) => {
if (node) {
node.focus();
}
};
const { settings } = React.useContext(AppStateContext);

handleLogin = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
event.preventDefault();
const handleLogin = React.useCallback(() => {
const returnTo = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `${getBaseUrl()}/sessions/new?return_to=${returnTo}${
window.location.hash
}`;
};

handleLogout = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
event.preventDefault();
this.props.router.push('/sessions/logout');
};
}, []);

renderAuthenticated() {
const currentUser = this.props.currentUser as LoggedInUser;
return (
<Dropdown
className="js-user-authenticated"
overlay={
<ul className="menu">
<li className="menu-item">
<div className="text-ellipsis text-muted" title={currentUser.name}>
<strong>{currentUser.name}</strong>
</div>
{currentUser.email != null && (
<div
className="little-spacer-top text-ellipsis text-muted"
title={currentUser.email}
>
{currentUser.email}
</div>
)}
</li>
<li className="divider" />
<li>
<Link ref={this.focusNode} to="/account">
{translate('my_account.page')}
</Link>
</li>
<li>
<a href="#" onClick={this.handleLogout}>
{translate('layout.logout')}
</a>
</li>
</ul>
}
>
<a className="dropdown-toggle navbar-avatar" href="#" title={currentUser.name}>
<Avatar
hash={currentUser.avatar}
name={currentUser.name}
size={rawSizes.globalNavContentHeightRaw}
/>
</a>
</Dropdown>
);
}

renderAnonymous() {
if (!currentUser || !isLoggedIn(currentUser)) {
return (
<div>
<Link className="navbar-login" to="/sessions/new" onClick={this.handleLogin}>
{translate('layout.login')}
</Link>
<ButtonSecondary onClick={handleLogin}>{translate('layout.login')}</ButtonSecondary>
</div>
);
}

render() {
return isLoggedIn(this.props.currentUser) ? this.renderAuthenticated() : this.renderAnonymous();
}
}
const enableGravatar = settings[GlobalSettingKeys.EnableGravatar] === 'true';
const gravatarServerUrl = settings[GlobalSettingKeys.GravatarServerUrl] ?? '';

export default withRouter(GlobalNavUser);
return (
<Dropdown
id="userAccountMenuDropdown"
placement={PopupPlacement.BottomRight}
zLevel={PopupZLevel.Global}
overlay={<GlobalNavUserMenu currentUser={currentUser} />}
>
{({ a11yAttrs: { role, ...a11yAttrs }, onToggleClick, open }) => (
<Tooltip
mouseEnterDelay={0.2}
overlay={translate('global_nav.account.tooltip')}
visible={open ? false : undefined}
>
<BareButton
aria-label={translate('global_nav.account.tooltip')}
onClick={onToggleClick}
{...a11yAttrs}
>
<Avatar
enableGravatar={enableGravatar}
gravatarServerUrl={gravatarServerUrl}
hash={currentUser.avatar}
name={currentUser.name}
/>
</BareButton>
</Tooltip>
)}
</Dropdown>
);
}

+ 66
- 0
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUserMenu.tsx View File

@@ -0,0 +1,66 @@
/*
* 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 {
ItemButton,
ItemDivider,
ItemHeader,
ItemHeaderHighlight,
ItemNavLink,
} from 'design-system';
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { translate } from '../../../../helpers/l10n';
import { LoggedInUser } from '../../../../types/users';

interface UserAccountMenuProps {
currentUser: LoggedInUser;
}

export function GlobalNavUserMenu({ currentUser }: UserAccountMenuProps) {
const navigateTo = useNavigate();
const firstItemRef = React.useRef<HTMLAnchorElement>(null);

const handleLogout = React.useCallback(() => {
navigateTo('/sessions/logout');
}, [navigateTo]);

React.useEffect(() => {
firstItemRef.current?.focus();
}, [firstItemRef]);

return (
<>
<ItemHeader>
<ItemHeaderHighlight title={currentUser.name}>{currentUser.name}</ItemHeaderHighlight>
{currentUser.email != null && (
<div className="sw-mt-1" title={currentUser.email}>
{currentUser.email}
</div>
)}
</ItemHeader>
<ItemDivider />
<ItemNavLink end={true} to="/account" innerRef={firstItemRef}>
{translate('my_account.page')}
</ItemNavLink>
<ItemDivider />
<ItemButton onClick={handleLogout}>{translate('layout.logout')}</ItemButton>
</>
);
}

+ 0
- 0
server/sonar-web/src/main/js/app/components/nav/global/MainSonarQubeBar.tsx View File


Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save