├── .babelrc.json ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── ci.yaml │ ├── main-preview.yml │ ├── pr-preview-build.yml │ ├── pr-preview-deploy.yml │ ├── release-beta.yml │ ├── release-v6.yml │ └── release.yaml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .npmrc ├── .prettierignore ├── .prettierrc.js ├── .storybook ├── decorators │ ├── withLang.tsx │ └── withMobile.tsx ├── main.ts ├── manager.ts ├── preview.tsx ├── theme-addon │ └── register.tsx └── theme.ts ├── .stylelintrc.json ├── AUTHORS ├── CHANGELOG.md ├── CODEOWNERS ├── CONTRIBUTING ├── LICENSE ├── README-ru.md ├── README.md ├── commitlint.config.js ├── gulpfile.js ├── jest.config.js ├── jest ├── .eslintrc └── setup.js ├── package-lock.json ├── package.json ├── src ├── components │ ├── ActionPanel │ │ ├── ActionPanel.scss │ │ ├── ActionPanel.tsx │ │ └── types.ts │ ├── DashKit │ │ ├── DashKit.tsx │ │ ├── __stories__ │ │ │ ├── CssApiShowcase.tsx │ │ │ ├── DashKit.stories.scss │ │ │ ├── DashKit.stories.tsx │ │ │ ├── DashKitDnDShowcase.tsx │ │ │ ├── DashKitGroupsShowcase.tsx │ │ │ ├── DashKitShowcase.scss │ │ │ ├── DashKitShowcase.tsx │ │ │ ├── Demo.scss │ │ │ ├── Demo.tsx │ │ │ └── utils.ts │ │ └── index.ts │ ├── DashKitDnDWrapper │ │ └── DashKitDnDWrapper.tsx │ ├── DashKitView │ │ ├── DashKitView.scss │ │ └── DashKitView.tsx │ ├── GridItem │ │ ├── GridItem.js │ │ └── GridItem.scss │ ├── GridLayout │ │ ├── GridLayout.js │ │ └── ReactGridLayout.js │ ├── Item │ │ ├── Item.js │ │ └── Item.scss │ ├── MobileLayout │ │ ├── MobileLayout.scss │ │ ├── MobileLayout.tsx │ │ └── helpers.ts │ ├── OverlayControls │ │ ├── OverlayControls.scss │ │ └── OverlayControls.tsx │ └── index.ts ├── constants │ ├── common.ts │ └── index.ts ├── context │ ├── DashKitContext.ts │ ├── DashKitDnDContext.ts │ ├── DashkitOverlayControlsContext.ts │ └── index.ts ├── helpers.ts ├── hocs │ ├── prepareItem.js │ └── withContext.js ├── hooks │ ├── useCalcLayout.ts │ ├── useDeepEqualMemo.ts │ └── useDnDItemProps.ts ├── i18n │ ├── en.json │ ├── index.ts │ └── ru.json ├── index.ts ├── plugins │ ├── Text │ │ ├── Text.scss │ │ ├── Text.tsx │ │ └── index.ts │ ├── Title │ │ ├── Title.scss │ │ ├── Title.tsx │ │ ├── __stories__ │ │ │ └── Title.stories.tsx │ │ ├── constants.ts │ │ ├── index.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── constants.ts │ └── index.ts ├── shared │ ├── constants │ │ ├── common.ts │ │ └── index.ts │ ├── index.ts │ ├── modules │ │ ├── __tests__ │ │ │ ├── helpers.test.ts │ │ │ └── uniq-id.test.ts │ │ ├── helpers.ts │ │ ├── index.ts │ │ ├── state-and-params.ts │ │ └── uniq-id.ts │ ├── types │ │ ├── common.ts │ │ ├── config.ts │ │ ├── index.ts │ │ ├── plugin.ts │ │ └── state-and-params.ts │ └── units │ │ ├── __tests__ │ │ ├── configs.json │ │ └── state-and-params.test.ts │ │ ├── datalens │ │ └── index.ts │ │ └── index.ts ├── typings │ ├── common.ts │ ├── config.ts │ ├── global.d.ts │ ├── index.ts │ └── plugin.ts └── utils │ ├── __tests__ │ └── update-manager.test.ts │ ├── cn.ts │ ├── get-new-id.ts │ ├── grid-layout.ts │ ├── group-helpers.ts │ ├── index.ts │ ├── register-manager.ts │ └── update-manager.ts └── tsconfig.json /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "sourceType": "unambiguous", 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "targets": { 8 | "chrome": 100 9 | } 10 | } 11 | ], 12 | "@babel/preset-typescript", 13 | "@babel/preset-react" 14 | ], 15 | "plugins": [] 16 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | max_line_length = 120 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | max_line_length = 0 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | build 4 | storybook-static 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@gravity-ui/eslint-config", 4 | "@gravity-ui/eslint-config/client", 5 | "@gravity-ui/eslint-config/prettier", 6 | "@gravity-ui/eslint-config/import-order" 7 | ], 8 | "root": true, 9 | "env": { 10 | "node": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main, v6] 6 | pull_request: 7 | branches: [main, v6] 8 | 9 | jobs: 10 | verify_files: 11 | name: Verify Files 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 0 18 | - name: Setup Node 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: 18 22 | cache: 'npm' 23 | - name: Install Packages 24 | run: npm ci 25 | - name: Lint Files 26 | run: npm run lint 27 | - name: Typecheck 28 | run: npm run typecheck 29 | 30 | tests: 31 | name: Tests 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@v2 36 | with: 37 | fetch-depth: 0 38 | - name: Setup Node 39 | uses: actions/setup-node@v2 40 | with: 41 | node-version: 18 42 | cache: 'npm' 43 | - name: Install Packages 44 | run: npm ci 45 | - name: Unit Tests 46 | run: npm run test 47 | -------------------------------------------------------------------------------- /.github/workflows/main-preview.yml: -------------------------------------------------------------------------------- 1 | name: Main Preview 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | main: 9 | name: Build and Deploy 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 16 | - name: Setup Node 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: 18 20 | - name: Install Packages 21 | run: npm ci 22 | shell: bash 23 | - name: Build Storybook 24 | run: npx sb build 25 | shell: bash 26 | - name: Upload to S3 27 | uses: gravity-ui/preview-upload-to-s3-action@v1 28 | with: 29 | src-path: storybook-static 30 | dest-path: /dashkit/main/ 31 | s3-key-id: ${{ secrets.STORYBOOK_S3_KEY_ID }} 32 | s3-secret-key: ${{ secrets.STORYBOOK_S3_SECRET_KEY }} 33 | -------------------------------------------------------------------------------- /.github/workflows/pr-preview-build.yml: -------------------------------------------------------------------------------- 1 | name: PR Preview Build 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | build: 8 | name: Build 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: gravity-ui/preview-build-action@v2 12 | with: 13 | node-version: 18 14 | -------------------------------------------------------------------------------- /.github/workflows/pr-preview-deploy.yml: -------------------------------------------------------------------------------- 1 | name: PR Preview Deploy 2 | 3 | on: 4 | workflow_run: 5 | workflows: ['PR Preview Build'] 6 | types: 7 | - completed 8 | 9 | jobs: 10 | deploy: 11 | name: Deploy 12 | if: > 13 | github.event.workflow_run.event == 'pull_request' && 14 | github.event.workflow_run.conclusion == 'success' 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: gravity-ui/preview-deploy-action@v1 18 | with: 19 | project: dashkit 20 | github-token: ${{ secrets.GRAVITY_UI_BOT_GITHUB_TOKEN }} 21 | s3-key-id: ${{ secrets.STORYBOOK_S3_KEY_ID }} 22 | s3-secret-key: ${{ secrets.STORYBOOK_S3_SECRET_KEY }} 23 | -------------------------------------------------------------------------------- /.github/workflows/release-beta.yml: -------------------------------------------------------------------------------- 1 | # Build and publish -beta tag for @gravity-ui/dashkit 2 | # Runs manually in Actions tabs in github 3 | # Runs on any branch except main 4 | 5 | name: Release beta version 6 | 7 | on: 8 | workflow_dispatch: 9 | inputs: 10 | version: 11 | type: string 12 | required: false 13 | description: 'If your build failed and the version is already exists you can set version of package manually, e.g. 3.0.0-beta.0. Use the prefix `beta` otherwise you will get error.' 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - run: | 20 | if [ "${{ github.event.inputs.version }}" != "" ]; then 21 | if [[ "${{ github.event.inputs.version }}" != *"beta"* ]]; then 22 | echo "version set incorrectly! Check that is contains beta in it's name" 23 | exit 1 24 | fi 25 | fi 26 | - uses: actions/checkout@v2 27 | - uses: actions/setup-node@v1 28 | with: 29 | node-version: 18 30 | registry-url: 'https://registry.npmjs.org' 31 | - run: npm ci 32 | shell: bash 33 | - run: npm test 34 | shell: bash 35 | - name: Bump and commit version 36 | run: | 37 | echo ${{ github.event.inputs.version }} 38 | 39 | if [ "${{ github.event.inputs.version }}" == "" ]; then 40 | npm version prerelease --preid=beta --git-tag-version=false 41 | else 42 | npm version ${{ github.event.inputs.version }} --git-tag-version=false 43 | fi 44 | - name: Publish version 45 | run: npm publish --tag beta --access public 46 | env: 47 | NODE_AUTH_TOKEN: ${{ secrets.GRAVITY_UI_BOT_NPM_TOKEN }} 48 | shell: bash 49 | -------------------------------------------------------------------------------- /.github/workflows/release-v6.yml: -------------------------------------------------------------------------------- 1 | name: Release V6 2 | 3 | on: 4 | push: 5 | branches: [v6] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: gravity-ui/release-action@v1 12 | with: 13 | github-token: ${{ secrets.GRAVITY_UI_BOT_GITHUB_TOKEN }} 14 | npm-token: ${{ secrets.GRAVITY_UI_BOT_NPM_TOKEN }} 15 | node-version: 18 16 | default-branch: v6 17 | npm-dist-tag: untagged 18 | skip-github-release: true -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: gravity-ui/release-action@v1 12 | with: 13 | github-token: ${{ secrets.GRAVITY_UI_BOT_GITHUB_TOKEN }} 14 | npm-token: ${{ secrets.GRAVITY_UI_BOT_NPM_TOKEN }} 15 | node-version: 18 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Settings 2 | .idea 3 | .DS_Store 4 | .vscode 5 | 6 | # Libs 7 | node_modules 8 | 9 | # Generated content 10 | build 11 | storybook-static 12 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx commitlint -e 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx --no-install lint-staged 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | legacy-peer-deps=true 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | storybook-static 2 | build 3 | CHANGELOG.md 4 | CONTRIBUTING.md 5 | package-lock.json 6 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@gravity-ui/prettier-config'); 2 | -------------------------------------------------------------------------------- /.storybook/decorators/withLang.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type {Decorator} from '@storybook/react'; 3 | import {Lang, configure} from '@gravity-ui/uikit'; 4 | 5 | export const withLang: Decorator = (Story, context) => { 6 | const lang = context.globals.lang; 7 | 8 | React.useEffect(() => { 9 | configure({ 10 | lang: lang as Lang, 11 | }); 12 | }, [lang]); 13 | 14 | return ; 15 | }; 16 | -------------------------------------------------------------------------------- /.storybook/decorators/withMobile.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type {Decorator} from '@storybook/react'; 3 | import {DashKit} from '../../src/components/DashKit/DashKit'; 4 | 5 | export const withMobile: Decorator = (Story, context) => { 6 | const platform = context.globals.platform; 7 | 8 | DashKit.setSettings({ 9 | isMobile: platform === 'mobile', 10 | }); 11 | 12 | return ; 13 | }; 14 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type {StorybookConfig} from '@storybook/react-webpack5'; 2 | 3 | const config: StorybookConfig = { 4 | framework: { 5 | name: '@storybook/react-webpack5', 6 | options: {fastRefresh: true}, 7 | }, 8 | stories: ['../src/**/*.stories.@(ts|tsx)'], 9 | docs: { 10 | autodocs: false, 11 | }, 12 | addons: [ 13 | '@storybook/preset-scss', 14 | {name: '@storybook/addon-essentials', options: {backgrounds: false}}, 15 | './theme-addon/register.tsx', 16 | ], 17 | }; 18 | 19 | export default config; 20 | -------------------------------------------------------------------------------- /.storybook/manager.ts: -------------------------------------------------------------------------------- 1 | import {addons} from '@storybook/addons'; 2 | import {themes} from './theme'; 3 | 4 | addons.setConfig({ 5 | theme: themes.light, 6 | }); 7 | -------------------------------------------------------------------------------- /.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {MINIMAL_VIEWPORTS} from '@storybook/addon-viewport'; 3 | import type {Decorator} from '@storybook/react'; 4 | import {withMobile} from './decorators/withMobile'; 5 | import {withLang} from './decorators/withLang'; 6 | import {ThemeProvider, MobileProvider, configure, Lang} from '@gravity-ui/uikit'; 7 | 8 | import '@gravity-ui/uikit/styles/styles.scss'; 9 | 10 | configure({ 11 | lang: Lang.En, 12 | }); 13 | 14 | const withContextProvider: Decorator = (Story, context) => { 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | }; 25 | 26 | export const decorators = [withMobile, withLang, withContextProvider]; 27 | 28 | export const parameters = { 29 | jsx: {showFunctions: true}, // To show functions in sources 30 | viewport: { 31 | viewports: MINIMAL_VIEWPORTS, 32 | }, 33 | options: { 34 | storySort: { 35 | order: ['Theme', 'Components', ['Basic']], 36 | method: 'alphabetical', 37 | }, 38 | }, 39 | }; 40 | 41 | export const globalTypes = { 42 | theme: { 43 | name: 'Theme', 44 | defaultValue: 'light', 45 | toolbar: { 46 | icon: 'mirror', 47 | items: [ 48 | {value: 'light', right: '☼', title: 'Light'}, 49 | {value: 'dark', right: '☾', title: 'Dark'}, 50 | {value: 'light-hc', right: '☼', title: 'High Contrast Light'}, 51 | {value: 'dark-hc', right: '☾', title: 'High Contrast Dark'}, 52 | ], 53 | }, 54 | }, 55 | lang: { 56 | name: 'Language', 57 | defaultValue: 'en', 58 | toolbar: { 59 | icon: 'globe', 60 | items: [ 61 | {value: 'en', right: '🇬🇧', title: 'En'}, 62 | {value: 'ru', right: '🇷🇺', title: 'Ru'}, 63 | ], 64 | }, 65 | }, 66 | platform: { 67 | name: 'Platform', 68 | defaultValue: 'desktop', 69 | toolbar: { 70 | items: [ 71 | {value: 'desktop', title: 'Desktop', icon: 'browser'}, 72 | {value: 'mobile', title: 'Mobile', icon: 'mobile'}, 73 | ], 74 | }, 75 | }, 76 | }; 77 | -------------------------------------------------------------------------------- /.storybook/theme-addon/register.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {addons, types} from '@storybook/addons'; 3 | import {useGlobals} from '@storybook/api'; 4 | import {FORCE_RE_RENDER} from '@storybook/core-events'; 5 | import {getThemeType} from '@gravity-ui/uikit'; 6 | import {themes} from '../theme'; 7 | 8 | const ADDON_ID = 'dashkit-theme-addon'; 9 | const TOOL_ID = `${ADDON_ID}tool`; 10 | 11 | addons.register(ADDON_ID, (api) => { 12 | addons.add(TOOL_ID, { 13 | type: types.TOOL, 14 | title: 'Theme', 15 | render: () => { 16 | return ; 17 | }, 18 | }); 19 | }); 20 | 21 | function Tool({api}) { 22 | const [{theme}] = useGlobals(); 23 | React.useEffect(() => { 24 | api.setOptions({theme: themes[getThemeType(theme)]}); 25 | addons.getChannel().emit(FORCE_RE_RENDER); 26 | }, [theme]); 27 | return null; 28 | } 29 | -------------------------------------------------------------------------------- /.storybook/theme.ts: -------------------------------------------------------------------------------- 1 | import {create} from '@storybook/theming'; 2 | 3 | export const CloudThemeLight = create({ 4 | base: 'light', 5 | 6 | colorPrimary: '#027bf3', 7 | colorSecondary: 'rgba(2, 123, 243, 0.6)', 8 | 9 | // Typography 10 | fontBase: '"Helvetica Neue", Arial, Helvetica, sans-serif', 11 | fontCode: 12 | '"SF Mono", "Menlo", "Monaco", "Consolas", "Ubuntu Mono", "Liberation Mono", "DejaVu Sans Mono", "Courier New", "Courier", monospace', 13 | 14 | // Text colors 15 | textColor: 'black', 16 | textInverseColor: 'black', 17 | 18 | // Toolbar default and active colors 19 | barTextColor: 'silver', 20 | barSelectedColor: '#027bf3', 21 | // barBg: '#027bf3', 22 | 23 | // Form colors 24 | inputBg: 'white', 25 | inputBorder: 'silver', 26 | inputTextColor: 'black', 27 | inputBorderRadius: 4, 28 | 29 | brandUrl: 'https://github.com/gravity-ui/dashkit', 30 | brandTitle: `
DashKit
31 |
Dashkit Component
`, 32 | }); 33 | 34 | export const CloudThemeDark = create({ 35 | base: 'dark', 36 | }); 37 | 38 | export const themes = { 39 | light: CloudThemeLight, 40 | dark: CloudThemeDark, 41 | }; 42 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@gravity-ui/stylelint-config", "@gravity-ui/stylelint-config/prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | The following authors have created the source code of "Yandex Cloud DashKit" published and distributed 2 | by YANDEX LLC as the owner: 3 | 4 | Ivan Chernobuk 5 | Artem Luchin 6 | Elena Martynova 7 | Mihail Shabrikov 8 | Evgeniy Shangin 9 | Kirill Smorodi 10 | Viktor Rozaev 11 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @jhoncool @Marginy605 @flops 2 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | # Notice to external contributors 2 | 3 | ## General info 4 | 5 | Hello! In order for us (YANDEX LLC) to accept patches and other contributions from you, you will have to adopt our Yandex Contributor License Agreement (the “**CLA**”). The current version of the CLA can be found here: 6 | 7 | 1. https://yandex.ru/legal/cla/?lang=en (in English) and 8 | 2. https://yandex.ru/legal/cla/?lang=ru (in Russian). 9 | 10 | By adopting the CLA, you state the following: 11 | 12 | - You obviously wish and are willingly licensing your contributions to us for our open source projects under the terms of the CLA, 13 | - You have read the terms and conditions of the CLA and agree with them in full, 14 | - You are legally able to provide and license your contributions as stated, 15 | - We may use your contributions for our open source projects and for any other our project too, 16 | - We rely on your assurances concerning the rights of third parties in relation to your contributions. 17 | 18 | If you agree with these principles, please read and adopt our CLA. By providing us your contributions, you hereby declare that you have already read and adopt our CLA, and we may freely merge your contributions with our corresponding open source project and use it in further in accordance with terms and conditions of the CLA. 19 | 20 | ## Provide contributions 21 | 22 | If you have already adopted terms and conditions of the CLA, you are able to provide your contributions. When you submit your pull request, please add the following information into it: 23 | 24 | ``` 25 | I hereby agree to the terms of the CLA available at: [link]. 26 | ``` 27 | 28 | Replace the bracketed text as follows: 29 | 30 | - [link] is the link to the current version of the CLA: https://yandex.ru/legal/cla/?lang=en (in English) or https://yandex.ru/legal/cla/?lang=ru (in Russian). 31 | 32 | It is enough to provide us such notification once. 33 | 34 | ## Other questions 35 | 36 | If you have any questions, please mail us at opensource@yandex-team.ru. 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 YANDEX LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {extends: ['@commitlint/config-conventional']}; 2 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const utils = require('@gravity-ui/gulp-utils'); 4 | const {task, src, dest, series, parallel} = require('gulp'); 5 | const sass = require('gulp-dart-sass'); 6 | const sourcemaps = require('gulp-sourcemaps'); 7 | const rimraf = require('rimraf'); 8 | 9 | const BUILD_DIR = path.resolve('build'); 10 | 11 | task('clean', (done) => { 12 | rimraf.sync(BUILD_DIR); 13 | done(); 14 | }); 15 | 16 | async function compileTs(modules = false) { 17 | const tsProject = await utils.createTypescriptProject({ 18 | compilerOptions: { 19 | declaration: true, 20 | module: modules ? 'esnext' : 'nodenext', 21 | moduleResolution: modules ? 'bundler' : 'nodenext', 22 | ...(modules ? undefined : {verbatimModuleSyntax: false}), 23 | }, 24 | }); 25 | 26 | const transformers = [ 27 | tsProject.customTransformers.transformScssImports, 28 | tsProject.customTransformers.transformLocalModules, 29 | ]; 30 | 31 | const moduleType = modules ? 'esm' : 'cjs'; 32 | 33 | return new Promise((resolve) => { 34 | src([ 35 | 'src/**/*.{js,jsx,ts,tsx}', 36 | '!src/**/__stories__/**/*.{js,jsx,ts,tsx}', 37 | '!src/**/__test__/**/*.*])', 38 | ]) 39 | .pipe(sourcemaps.init()) 40 | .pipe( 41 | tsProject({ 42 | customTransformers: { 43 | before: transformers, 44 | afterDeclarations: transformers, 45 | }, 46 | }), 47 | ) 48 | .pipe(sourcemaps.write('.', {includeContent: true, sourceRoot: '../../src'})) 49 | .pipe( 50 | utils.addVirtualFile({ 51 | fileName: 'package.json', 52 | text: JSON.stringify({type: modules ? 'module' : 'commonjs'}), 53 | }), 54 | ) 55 | .pipe(dest(path.resolve(BUILD_DIR, moduleType))) 56 | .on('end', resolve); 57 | }); 58 | } 59 | 60 | task('compile-to-esm', () => { 61 | return compileTs(true); 62 | }); 63 | 64 | task('compile-to-cjs', () => { 65 | return compileTs(); 66 | }); 67 | 68 | task('copy-js-declarations', () => { 69 | return src(['src/**/*.d.ts', '!src/**/__stories__/**/*.d.ts']) 70 | .pipe(dest(path.resolve(BUILD_DIR, 'esm'))) 71 | .pipe(dest(path.resolve(BUILD_DIR, 'cjs'))); 72 | }); 73 | 74 | task('copy-i18n', () => { 75 | return src(['src/**/i18n/*.json']) 76 | .pipe(dest(path.resolve(BUILD_DIR, 'esm'))) 77 | .pipe(dest(path.resolve(BUILD_DIR, 'cjs'))); 78 | }); 79 | 80 | task('styles-components', () => { 81 | return src(['src/**/*.scss', '!src/components/**/__stories__/**/*.scss']) 82 | .pipe(sass().on('error', sass.logError)) 83 | .pipe(dest(path.resolve(BUILD_DIR, 'esm'))) 84 | .pipe(dest(path.resolve(BUILD_DIR, 'cjs'))); 85 | }); 86 | 87 | task( 88 | 'build', 89 | series([ 90 | 'clean', 91 | parallel(['compile-to-esm', 'compile-to-cjs']), 92 | parallel(['copy-js-declarations', 'copy-i18n']), 93 | 'styles-components', 94 | ]), 95 | ); 96 | 97 | task('default', series(['build'])); 98 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: true, 3 | setupFilesAfterEnv: ['/jest/setup.js'], 4 | roots: ['/jest', '/src'], 5 | moduleDirectories: ['node_modules', '/jest'], 6 | transform: { 7 | '^.+\\.(t|j)sx?$': ['ts-jest', {tsconfig: './tsconfig.json'}], 8 | }, 9 | transformIgnorePatterns: ['node_modules/(?!(@gravity-ui)/)'], 10 | snapshotSerializers: ['enzyme-to-json/serializer'], 11 | }; 12 | -------------------------------------------------------------------------------- /jest/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@gravity-ui/eslint-config/server" 3 | } 4 | -------------------------------------------------------------------------------- /jest/setup.js: -------------------------------------------------------------------------------- 1 | const Enzyme = require('enzyme'); 2 | const Adapter = require('enzyme-adapter-react-16'); 3 | 4 | Enzyme.configure({adapter: new Adapter()}); 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gravity-ui/dashkit", 3 | "version": "9.1.0", 4 | "description": "Library for rendering dashboard grid layout", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/gravity-ui/dashkit" 9 | }, 10 | "exports": { 11 | ".": { 12 | "types": "./build/esm/index.d.ts", 13 | "require": "./build/cjs/index.js", 14 | "import": "./build/esm/index.js" 15 | }, 16 | "./helpers": { 17 | "types": "./build/esm/helpers.d.ts", 18 | "require": "./build/cjs/helpers.js", 19 | "import": "./build/esm/helpers.js" 20 | } 21 | }, 22 | "files": [ 23 | "build" 24 | ], 25 | "sideEffects": [ 26 | "*.scss", 27 | "*.css" 28 | ], 29 | "publishConfig": { 30 | "access": "public" 31 | }, 32 | "main": "./build/cjs/index.js", 33 | "module": "./build/esm/index.js", 34 | "types": "./build/esm/index.d.ts", 35 | "typesVersions": { 36 | "*": { 37 | "index.d.ts": [ 38 | "./build/esm/index.d.ts" 39 | ], 40 | "helpers": [ 41 | "./build/esm/helpers.d.ts" 42 | ] 43 | } 44 | }, 45 | "scripts": { 46 | "lint:prettier": "prettier --check --log-level=error '**/*.{js,jsx,ts,tsx,scss}'", 47 | "lint:prettier:fix": "prettier --write '**/*.md'", 48 | "lint:js": "eslint src --ext .js,.jsx,.ts,.tsx", 49 | "lint:js:fix": "npm run lint:js -- --fix", 50 | "lint:styles": "stylelint 'src/**/*.scss'", 51 | "lint:styles:fix": "npm run lint:styles -- --fix", 52 | "lint": "run-p lint:*", 53 | "test": "jest", 54 | "clean": "gulp clean", 55 | "build": "gulp", 56 | "start": "storybook dev -p 7120", 57 | "typecheck": "tsc --noEmit", 58 | "build-storybook": "sb build -c .storybook -o storybook-static", 59 | "prepare": "husky install", 60 | "prepublishOnly": "npm run lint && npm run test && npm run build" 61 | }, 62 | "dependencies": { 63 | "@bem-react/classname": "^1.6.0", 64 | "hashids": "^2.2.8", 65 | "immutability-helper": "^3.1.1", 66 | "prop-types": "^15.8.1", 67 | "react-grid-layout": "^1.4.4", 68 | "react-transition-group": "^4.4.5" 69 | }, 70 | "peerDependencies": { 71 | "@gravity-ui/icons": "^2.13.0", 72 | "@gravity-ui/uikit": "^7.0.0", 73 | "react": "^16.8.0 || ^17 || ^18" 74 | }, 75 | "devDependencies": { 76 | "@commitlint/cli": "^19.6.1", 77 | "@commitlint/config-conventional": "^19.6.0", 78 | "@gravity-ui/eslint-config": "^3.2.0", 79 | "@gravity-ui/gulp-utils": "^1.0.2", 80 | "@gravity-ui/icons": "^2.13.0", 81 | "@gravity-ui/prettier-config": "^1.1.0", 82 | "@gravity-ui/stylelint-config": "^4.0.1", 83 | "@gravity-ui/tsconfig": "^1.0.0", 84 | "@gravity-ui/uikit": "^7.2.0", 85 | "@storybook/addon-essentials": "^7.6.15", 86 | "@storybook/addon-knobs": "^7.0.2", 87 | "@storybook/cli": "^7.6.15", 88 | "@storybook/preset-scss": "^1.0.3", 89 | "@storybook/react": "^7.6.15", 90 | "@storybook/react-webpack5": "^7.6.15", 91 | "@types/enzyme": "^3.10.8", 92 | "@types/jest": "^29.5.14", 93 | "@types/lodash": "^4.14.170", 94 | "@types/react": "^18.0.27", 95 | "@types/react-grid-layout": "^1.3.5", 96 | "copyfiles": "^2.4.1", 97 | "enzyme": "^3.11.0", 98 | "enzyme-adapter-react-16": "^1.15.6", 99 | "enzyme-to-json": "^3.6.1", 100 | "eslint": "^8.57.1", 101 | "gulp": "^4.0.2", 102 | "gulp-cli": "^2.3.0", 103 | "gulp-dart-sass": "^1.0.2", 104 | "gulp-sourcemaps": "^3.0.0", 105 | "husky": "^9.0.11", 106 | "jest": "^29.7.0", 107 | "lint-staged": "^13.0.3", 108 | "npm-run-all": "^4.1.5", 109 | "postcss": "^8.4.12", 110 | "prettier": "^3.2.5", 111 | "react": "^18.2.0", 112 | "react-docgen-typescript": "^2.2.2", 113 | "react-dom": "^18.2.0", 114 | "rimraf": "^3.0.2", 115 | "sass": "^1.53.0", 116 | "sass-loader": "^10.3.1", 117 | "source-map": "^0.7.4", 118 | "storybook": "^7.6.15", 119 | "style-loader": "^3.3.3", 120 | "stylelint": "^15.11.0", 121 | "terser-webpack-plugin": "^4.2.3", 122 | "ts-jest": "^29.2.5", 123 | "ts-node": "^10.9.2", 124 | "typescript": "^5.7.3" 125 | }, 126 | "husky": { 127 | "hooks": { 128 | "pre-commit": "lint-staged" 129 | } 130 | }, 131 | "lint-staged": { 132 | "*.{css,scss}": [ 133 | "stylelint --fix --quiet", 134 | "prettier --write" 135 | ], 136 | "*.{js,jsx,ts,tsx}": [ 137 | "eslint --fix --quiet", 138 | "prettier --write" 139 | ] 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/components/ActionPanel/ActionPanel.scss: -------------------------------------------------------------------------------- 1 | .dashkit-action-panel { 2 | $show_panel_transform: translateX(-50%) translateY(0); 3 | $hide_panel_transform: translateX(-50%) translateY(calc(100% + 20px)); 4 | 5 | --_--dashkit-action-panel-color: var(--dashkit-action-panel-color, var(--g-color-base-float)); 6 | --_--dashkit-action-panel-border-color: var( 7 | --dashkit-action-panel-border-color, 8 | var(--g-color-base-brand) 9 | ); 10 | --_--dashkit-action-panel-border-radius: var( 11 | --dashkit-action-panel-border-radius, 12 | var(--g-border-radius-xl) 13 | ); 14 | 15 | background-color: var(--_--dashkit-action-panel-color); 16 | position: fixed; 17 | bottom: 20px; 18 | display: flex; 19 | border-radius: var(--_--dashkit-action-panel-border-radius); 20 | border: 2px solid var(--_--dashkit-action-panel-border-color); 21 | padding: 8px; 22 | gap: 0; 23 | left: 50%; 24 | transform: $show_panel_transform; 25 | z-index: 1; 26 | 27 | &-enter { 28 | transform: $hide_panel_transform; 29 | will-change: transform; 30 | 31 | &-active { 32 | transform: $show_panel_transform; 33 | transition: transform 300ms ease; 34 | } 35 | } 36 | 37 | &-exit { 38 | transform: $show_panel_transform; 39 | will-change: transform; 40 | 41 | &-active { 42 | transform: $hide_panel_transform; 43 | transition: transform 300ms ease; 44 | } 45 | } 46 | 47 | &__item { 48 | --_--dashkit-action-panel-item-color: var(--dashkit-action-panel-item-color, transparent); 49 | --_--dashkit-action-panel-item-text-color: var( 50 | --dashkit-action-panel-item-text-color, 51 | var(--g-color-text-primary) 52 | ); 53 | --_--dashkit-action-panel-item-color-hover: var( 54 | --dashkit-action-panel-item-color-hover, 55 | var(--g-color-base-simple-hover) 56 | ); 57 | --_--dashkit-action-panel-item-text-color-hover: var( 58 | --dashkit-action-panel-item-text-color-hover, 59 | var(--g-color-text-primary) 60 | ); 61 | 62 | height: 68px; 63 | width: 98px; 64 | display: flex; 65 | flex-direction: column; 66 | justify-content: center; 67 | align-items: center; 68 | transition: 69 | 300ms color ease-in-out, 70 | 300ms background-color ease-in-out; 71 | border-radius: 6px; 72 | padding: 0 12px; 73 | box-sizing: border-box; 74 | white-space: nowrap; 75 | overflow: hidden; 76 | background-color: var(--_--dashkit-action-panel-item-color); 77 | color: var(--_--dashkit-action-panel-item-text-color); 78 | will-change: color, backgroung-color; 79 | 80 | &:hover { 81 | cursor: pointer; 82 | background-color: var(--_--dashkit-action-panel-item-color-hover); 83 | color: var(--_--dashkit-action-panel-item-text-color-hover); 84 | } 85 | } 86 | 87 | &__icon { 88 | margin-bottom: 4px; 89 | } 90 | 91 | &__title { 92 | font-size: 13px; 93 | font-weight: 400; 94 | line-height: 16px; 95 | max-width: 100%; 96 | white-space: nowrap; 97 | overflow: hidden; 98 | text-overflow: ellipsis; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/components/ActionPanel/ActionPanel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {CSSTransition} from 'react-transition-group'; 4 | 5 | import {useDnDItemProps} from '../../hooks/useDnDItemProps'; 6 | import {cn} from '../../utils/cn'; 7 | 8 | import {ActionPanelItem, ActionPanelProps} from './types'; 9 | 10 | import './ActionPanel.scss'; 11 | 12 | const b = cn('dashkit-action-panel'); 13 | 14 | export const ActionPanelItemContainer = ({item}: {item: ActionPanelItem}) => { 15 | const dndProps = useDnDItemProps(item); 16 | 17 | return ( 18 |
25 |
{item.icon}
26 |
27 | {item.title} 28 |
29 |
30 | ); 31 | }; 32 | 33 | export const ActionPanel = (props: ActionPanelProps) => { 34 | const isDisabled = props.disable ?? false; 35 | const isAnimated = props.toggleAnimation ?? false; 36 | const nodeRef = React.useRef(null); 37 | 38 | const content = ( 39 |
40 | {props.items.map(({wrapTo, ...item}) => { 41 | const key = `dk-action-panel-${item.id}`; 42 | const children = ; 43 | 44 | return wrapTo ? wrapTo({...item, key, children}) : children; 45 | })} 46 |
47 | ); 48 | 49 | if (isAnimated) { 50 | return ( 51 | 58 | {content} 59 | 60 | ); 61 | } else { 62 | return isDisabled ? null : content; 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /src/components/ActionPanel/types.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type {ItemDragProps} from '../../shared'; 4 | 5 | export type ActionPanelItem = { 6 | id: string; 7 | icon: React.ReactNode; 8 | title: string; 9 | className?: string; 10 | qa?: string; 11 | onClick?: () => void; 12 | dragProps?: ItemDragProps; 13 | wrapTo?: ( 14 | props: Omit & {key: React.Key; children: React.ReactNode}, 15 | ) => React.ReactNode; 16 | }; 17 | 18 | export type ActionPanelProps = { 19 | items: ActionPanelItem[]; 20 | className?: string; 21 | disable?: boolean; 22 | toggleAnimation?: boolean; 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/DashKit/DashKit.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import noop from 'lodash/noop'; 4 | import pick from 'lodash/pick'; 5 | 6 | import {DEFAULT_GROUP, DEFAULT_NAMESPACE} from '../../constants'; 7 | import {DashKitDnDContext} from '../../context'; 8 | import type { 9 | Config, 10 | ConfigItem, 11 | ConfigLayout, 12 | GlobalParams, 13 | ItemDropProps, 14 | ItemsStateAndParams, 15 | } from '../../shared'; 16 | import type { 17 | AddConfigItem, 18 | AddNewItemOptions, 19 | ContextProps, 20 | DashKitGroup, 21 | GridReflowOptions, 22 | ItemManipulationCallback, 23 | MenuItem, 24 | Plugin, 25 | ReactGridLayoutProps, 26 | SetConfigItem, 27 | Settings, 28 | SettingsProps, 29 | } from '../../typings'; 30 | import {RegisterManager, UpdateManager, reflowLayout} from '../../utils'; 31 | import {DashKitDnDWrapper} from '../DashKitDnDWrapper/DashKitDnDWrapper'; 32 | import DashKitView from '../DashKitView/DashKitView'; 33 | import GridLayout from '../GridLayout/GridLayout'; 34 | import type {OverlayControlItem, PreparedCopyItemOptions} from '../OverlayControls/OverlayControls'; 35 | 36 | interface DashKitGeneralProps { 37 | config: Config; 38 | editMode: boolean; 39 | draggableHandleClassName?: string; 40 | overlayControls?: Record | null; 41 | overlayMenuItems?: MenuItem[] | null; 42 | } 43 | 44 | interface DashKitDefaultProps { 45 | defaultGlobalParams: GlobalParams; 46 | globalParams: GlobalParams; 47 | itemsStateAndParams: ItemsStateAndParams; 48 | settings: SettingsProps; 49 | context: ContextProps; 50 | noOverlay: boolean; 51 | focusable?: boolean; 52 | groups?: DashKitGroup[]; 53 | 54 | onItemEdit: (item: ConfigItem) => void; 55 | onChange: (data: { 56 | config: Config; 57 | itemsStateAndParams: ItemsStateAndParams; 58 | groups?: DashKitGroup[]; 59 | }) => void; 60 | 61 | onDrop?: (dropProps: ItemDropProps) => void; 62 | 63 | onItemMountChange?: (item: ConfigItem, state: {isAsync: boolean; isMounted: boolean}) => void; 64 | onItemRender?: (item: ConfigItem) => void; 65 | 66 | getPreparedCopyItemOptions?: (options: PreparedCopyItemOptions) => PreparedCopyItemOptions; 67 | onCopyFulfill?: (error: null | Error, data?: PreparedCopyItemOptions) => void; 68 | 69 | onItemFocus?: (item: ConfigItem) => void; 70 | onItemBlur?: (item: ConfigItem) => void; 71 | 72 | onDragStart?: ItemManipulationCallback; 73 | onDrag?: ItemManipulationCallback; 74 | onDragStop?: ItemManipulationCallback; 75 | onResizeStart?: ItemManipulationCallback; 76 | onResize?: ItemManipulationCallback; 77 | onResizeStop?: ItemManipulationCallback; 78 | } 79 | 80 | export interface DashKitProps extends DashKitGeneralProps, Partial {} 81 | 82 | type DashKitInnerProps = DashKitGeneralProps & DashKitDefaultProps; 83 | 84 | const registerManager = new RegisterManager(); 85 | 86 | const getReflowProps = (props: ReactGridLayoutProps): GridReflowOptions => 87 | Object.assign( 88 | {compactType: 'vertical', cols: 36}, 89 | pick(props, 'cols', 'maxRows', 'compactType'), 90 | ); 91 | 92 | const getReflowGroupsConfig = (groups: DashKitGroup[] = []) => { 93 | const defaultGridProps = getReflowProps(registerManager.gridLayout); 94 | 95 | return { 96 | defaultProps: defaultGridProps, 97 | groups: groups.reduce>((memo, g) => { 98 | const groupId = g.id || DEFAULT_GROUP; 99 | memo[groupId] = g.gridProperties 100 | ? getReflowProps(g.gridProperties(defaultGridProps)) 101 | : defaultGridProps; 102 | 103 | return memo; 104 | }, {}), 105 | }; 106 | }; 107 | 108 | export class DashKit extends React.PureComponent { 109 | static defaultProps: DashKitDefaultProps = { 110 | onItemEdit: noop, 111 | onChange: noop, 112 | onDrop: noop, 113 | defaultGlobalParams: {}, 114 | globalParams: {}, 115 | itemsStateAndParams: {}, 116 | settings: { 117 | autoupdateInterval: 0, 118 | silentLoading: false, 119 | }, 120 | context: {}, 121 | noOverlay: false, 122 | focusable: false, 123 | }; 124 | 125 | static contextType = DashKitDnDContext; 126 | 127 | static registerPlugins(...plugins: Plugin[]) { 128 | plugins.forEach((plugin) => { 129 | registerManager.registerPlugin(plugin); 130 | }); 131 | } 132 | 133 | static reloadPlugins(...plugins: Plugin[]) { 134 | plugins.forEach((plugin) => { 135 | registerManager.reloadPlugin(plugin); 136 | }); 137 | } 138 | 139 | static setSettings(settings: Settings) { 140 | registerManager.setSettings(settings); 141 | } 142 | 143 | static setItem({ 144 | item: setItem, 145 | namespace = DEFAULT_NAMESPACE, 146 | config, 147 | options = {}, 148 | groups = [], 149 | }: { 150 | item: SetConfigItem; 151 | namespace?: string; 152 | config: Config; 153 | options?: Omit; 154 | groups?: DashKitGroup[]; 155 | }): Config { 156 | if (setItem.id) { 157 | return UpdateManager.editItem({ 158 | item: setItem, 159 | namespace, 160 | config, 161 | options, 162 | }); 163 | } else { 164 | const item = setItem as AddConfigItem; 165 | const layout = {...registerManager.getItem(item.type).defaultLayout}; 166 | 167 | const reflowLayoutOptions = getReflowGroupsConfig(groups); 168 | 169 | const copyItem = {...item}; 170 | 171 | if (copyItem.layout) { 172 | Object.assign(layout, copyItem.layout); 173 | delete copyItem.layout; 174 | } 175 | 176 | return UpdateManager.addItem({ 177 | item: copyItem, 178 | namespace, 179 | config, 180 | layout, 181 | options: {...options, reflowLayoutOptions}, 182 | }); 183 | } 184 | } 185 | 186 | static removeItem({ 187 | id, 188 | config, 189 | itemsStateAndParams, 190 | }: { 191 | id: string; 192 | config: Config; 193 | itemsStateAndParams: ItemsStateAndParams; 194 | }): {config: Config; itemsStateAndParams: ItemsStateAndParams} { 195 | return UpdateManager.removeItem({id, config, itemsStateAndParams}); 196 | } 197 | 198 | static reflowLayout({ 199 | newLayoutItem, 200 | layout, 201 | groups, 202 | }: { 203 | newLayoutItem?: ConfigLayout; 204 | layout: ConfigLayout[]; 205 | groups?: DashKitGroup[]; 206 | }) { 207 | return reflowLayout({ 208 | newLayoutItem, 209 | layout, 210 | reflowLayoutOptions: getReflowGroupsConfig(groups), 211 | }); 212 | } 213 | 214 | metaRef = React.createRef(); 215 | 216 | render() { 217 | const content = ( 218 | 219 | ); 220 | 221 | if (!this.context && this.props.groups) { 222 | return {content}; 223 | } 224 | 225 | return content; 226 | } 227 | 228 | getItemsMeta() { 229 | return this.metaRef.current?.getItemsMeta(); 230 | } 231 | 232 | reloadItems(options?: {targetIds?: string[]; force?: boolean}) { 233 | this.metaRef.current?.reloadItems(options); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/components/DashKit/__stories__/CssApiShowcase.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { 4 | ChartColumn, 5 | Heading, 6 | Layers3Diagonal, 7 | PlugConnection, 8 | Sliders, 9 | TextAlignLeft, 10 | } from '@gravity-ui/icons'; 11 | import {Icon} from '@gravity-ui/uikit'; 12 | 13 | import {ActionPanel, DashKit} from '../../..'; 14 | 15 | import {Demo, DemoRow} from './Demo'; 16 | import {getConfig} from './utils'; 17 | 18 | export const CssApiShowcase: React.FC = () => { 19 | const items = React.useMemo( 20 | () => [ 21 | { 22 | id: 'chart', 23 | icon: , 24 | title: 'Chart', 25 | className: 'test', 26 | qa: 'chart', 27 | }, 28 | { 29 | id: 'selector', 30 | icon: , 31 | title: 'Selector', 32 | qa: 'selector', 33 | }, 34 | { 35 | id: 'text', 36 | icon: , 37 | title: 'Text', 38 | }, 39 | { 40 | id: 'header', 41 | icon: , 42 | title: 'Header', 43 | }, 44 | { 45 | id: 'links', 46 | icon: , 47 | title: 'Links', 48 | }, 49 | { 50 | id: 'tabs', 51 | icon: , 52 | title: 'Tabs', 53 | }, 54 | ], 55 | [], 56 | ); 57 | 58 | return ( 59 | <> 60 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /src/components/DashKit/__stories__/DashKit.stories.scss: -------------------------------------------------------------------------------- 1 | @import '~@gravity-ui/uikit/styles/mixins'; 2 | 3 | .stories-dashkit { 4 | &__custom-plugin { 5 | position: absolute; 6 | inset: 0; 7 | } 8 | 9 | &__custom-plugin-container { 10 | width: 100%; 11 | height: 100%; 12 | background-image: url(https://avatars.githubusercontent.com/u/107542106); 13 | background-size: cover; 14 | background-repeat: no-repeat; 15 | background-position: center; 16 | display: flex; 17 | align-items: center; 18 | justify-content: center; 19 | overflow: hidden; 20 | } 21 | 22 | &__custom-plugin-text { 23 | @include overflow-ellipsis(); 24 | @include text-body-3(); 25 | color: var(--g-color-text-light-primary); 26 | padding: 5px; 27 | background-color: var(--g-color-base-info-light); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/components/DashKit/__stories__/DashKit.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {Meta, Story} from '@storybook/react'; 4 | 5 | import pluginText from '../../../plugins/Text/Text'; 6 | import pluginTitle from '../../../plugins/Title/Title'; 7 | import {cn} from '../../../utils/cn'; 8 | import {DashKit, DashKitProps} from '../DashKit'; 9 | 10 | import {CssApiShowcase} from './CssApiShowcase'; 11 | import {DashKitDnDShowcase} from './DashKitDnDShowcase'; 12 | import {DashKitGroupsShowcase} from './DashKitGroupsShowcase'; 13 | import {DashKitShowcase} from './DashKitShowcase'; 14 | import {getConfig} from './utils'; 15 | 16 | import './DashKit.stories.scss'; 17 | 18 | const b = cn('stories-dashkit'); 19 | 20 | // window.initialized helps to prevent registration of a plugin that is already registered 21 | 22 | const getInitialized = () => { 23 | // @ts-expect-error 24 | return (window && window.initialized) ?? false; 25 | }; 26 | 27 | const setInitialized = (value: boolean) => { 28 | if (window) { 29 | // @ts-expect-error 30 | window.initialized = value; 31 | } 32 | }; 33 | 34 | export default { 35 | title: 'Components/DashKit', 36 | component: DashKit, 37 | args: { 38 | config: getConfig(), 39 | editMode: true, 40 | }, 41 | } as Meta; 42 | 43 | if (!getInitialized()) { 44 | DashKit.registerPlugins( 45 | pluginTitle, 46 | pluginText.setSettings({ 47 | apiHandler: ({text}) => Promise.resolve({result: text}), 48 | }), 49 | ); 50 | 51 | const customPlugin = { 52 | type: 'custom', 53 | defaultLayout: { 54 | w: 20, 55 | h: 20, 56 | }, 57 | renderer: function CustomPlugin() { 58 | return ( 59 |
60 |
61 |
Custom widget
62 |
63 |
64 | ); 65 | }, 66 | }; 67 | 68 | DashKit.registerPlugins(customPlugin); 69 | DashKit.reloadPlugins({ 70 | ...customPlugin, 71 | defaultLayout: { 72 | w: 10, 73 | h: 10, 74 | }, 75 | }); 76 | 77 | DashKit.setSettings({ 78 | gridLayout: {margin: [8, 8]}, 79 | }); 80 | 81 | setInitialized(true); 82 | } 83 | 84 | const DefaultTemplate: Story = (args) => ; 85 | export const Default = DefaultTemplate.bind({}); 86 | 87 | const ShowcaseTemplate: Story = () => ; 88 | export const Showcase = ShowcaseTemplate.bind({}); 89 | 90 | const CssApiShowcaseTemplate: Story = () => ; 91 | export const CSS_API = CssApiShowcaseTemplate.bind({}); 92 | 93 | const DndShowcaseTemplate: Story = () => ; 94 | export const DragNDrop = DndShowcaseTemplate.bind({}); 95 | 96 | const GroupsShowcaseTemplate: Story = () => ; 97 | export const Groups = GroupsShowcaseTemplate.bind({}); 98 | -------------------------------------------------------------------------------- /src/components/DashKit/__stories__/DashKitDnDShowcase.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {ChartColumn, Copy, Heading, Sliders, TextAlignLeft} from '@gravity-ui/icons'; 4 | import {Icon} from '@gravity-ui/uikit'; 5 | 6 | import {ActionPanel, DashKit, DashKitDnDWrapper, DashKitProps} from '../../..'; 7 | 8 | import {Demo, DemoRow} from './Demo'; 9 | import {getConfig} from './utils'; 10 | 11 | export const DashKitDnDShowcase: React.FC = () => { 12 | const onClick = () => { 13 | console.log('click'); 14 | }; 15 | 16 | const items = React.useMemo( 17 | () => [ 18 | { 19 | id: 'chart', 20 | icon: , 21 | title: 'Chart', 22 | className: 'test', 23 | qa: 'chart', 24 | dragProps: { 25 | type: 'custom', 26 | }, 27 | onClick, 28 | }, 29 | { 30 | id: 'selector', 31 | icon: , 32 | title: 'Selector', 33 | qa: 'selector', 34 | dragProps: { 35 | type: 'custom', 36 | }, 37 | onClick, 38 | }, 39 | { 40 | id: 'text', 41 | icon: , 42 | title: 'Text', 43 | dragProps: { 44 | type: 'text', 45 | }, 46 | onClick, 47 | }, 48 | { 49 | id: 'header', 50 | icon: , 51 | title: 'Header', 52 | dragProps: { 53 | type: 'title', 54 | }, 55 | onClick, 56 | }, 57 | { 58 | id: 'custom', 59 | icon: , 60 | title: 'Custom', 61 | dragProps: { 62 | type: 'title', 63 | layout: { 64 | h: 10, 65 | w: 36, 66 | }, 67 | }, 68 | onClick, 69 | }, 70 | ], 71 | [], 72 | ); 73 | const [config, setConfig] = React.useState(getConfig()); 74 | 75 | const onChange = React.useCallback(({config}: {config: DashKitProps['config']}) => { 76 | setConfig(config); 77 | }, []); 78 | 79 | const onDrop = React.useCallback>( 80 | (dropProps) => { 81 | let data = null; 82 | const type = dropProps.dragProps?.type; 83 | if (type === 'custom') { 84 | data = {}; 85 | } else { 86 | const text = prompt('Enter text'); 87 | if (text) { 88 | data = 89 | type === 'title' 90 | ? { 91 | size: 'm', 92 | text, 93 | showInTOC: true, 94 | } 95 | : {text}; 96 | } 97 | } 98 | 99 | if (data) { 100 | const newConfig = DashKit.setItem({ 101 | item: { 102 | data, 103 | type, 104 | namespace: 'default', 105 | layout: dropProps.itemLayout, 106 | }, 107 | config, 108 | options: { 109 | updateLayout: dropProps.newLayout, 110 | }, 111 | }); 112 | setConfig(newConfig); 113 | } 114 | 115 | dropProps.commit(); 116 | }, 117 | [config], 118 | ); 119 | 120 | const onDragStart = React.useCallback(() => { 121 | console.log('dragStarted'); 122 | }, []); 123 | 124 | const onDragEnd = React.useCallback(() => { 125 | console.log('dragEnded'); 126 | }, []); 127 | 128 | const onItemMountChange = React.useCallback< 129 | Exclude 130 | >((item, state) => { 131 | console.log('onItemMountChange', item, state); 132 | }, []); 133 | 134 | const onItemRender = React.useCallback>( 135 | (item) => { 136 | console.log('onItemRender', item); 137 | }, 138 | [], 139 | ); 140 | 141 | return ( 142 | 143 | 144 | 145 | 154 | 155 | 156 | 157 | 158 | ); 159 | }; 160 | -------------------------------------------------------------------------------- /src/components/DashKit/__stories__/DashKitGroupsShowcase.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {ChartColumn, Copy, Heading, Pin, Sliders, TextAlignLeft, TrashBin} from '@gravity-ui/icons'; 4 | import {Button, Icon} from '@gravity-ui/uikit'; 5 | 6 | import { 7 | ActionPanel, 8 | ConfigItem, 9 | ConfigLayout, 10 | DashKit, 11 | DashKitDnDWrapper, 12 | DashKitGroup, 13 | DashKitProps, 14 | DashkitGroupRenderProps, 15 | ItemManipulationCallback, 16 | ReactGridLayoutProps, 17 | } from '../../..'; 18 | import {DEFAULT_GROUP, MenuItems} from '../../../helpers'; 19 | import {i18n} from '../../../i18n'; 20 | import {cn} from '../../../utils/cn'; 21 | 22 | import {Demo, DemoRow} from './Demo'; 23 | import {fixedGroup, getConfig} from './utils'; 24 | 25 | import './DashKitShowcase.scss'; 26 | 27 | const b = cn('stories-dashkit-showcase'); 28 | 29 | const MAX_ROWS = 2; 30 | const GRID_COLUMNS = 36; 31 | 32 | export const DashKitGroupsShowcase: React.FC = () => { 33 | const [editMode, setEditMode] = React.useState(true); 34 | const [headerInteractions, setHeaderInteractions] = React.useState(true); 35 | 36 | const onClick = () => { 37 | console.log('click'); 38 | }; 39 | 40 | const items = React.useMemo( 41 | () => [ 42 | { 43 | id: 'chart', 44 | icon: , 45 | title: 'Chart', 46 | className: 'test', 47 | qa: 'chart', 48 | dragProps: { 49 | type: 'custom', 50 | }, 51 | onClick, 52 | }, 53 | { 54 | id: 'selector', 55 | icon: , 56 | title: 'Selector', 57 | qa: 'selector', 58 | dragProps: { 59 | type: 'custom', 60 | }, 61 | onClick, 62 | }, 63 | { 64 | id: 'text', 65 | icon: , 66 | title: 'Text', 67 | dragProps: { 68 | type: 'text', 69 | }, 70 | onClick, 71 | }, 72 | { 73 | id: 'header', 74 | icon: , 75 | title: 'Header', 76 | dragProps: { 77 | type: 'title', 78 | }, 79 | onClick, 80 | }, 81 | { 82 | id: 'custom', 83 | icon: , 84 | title: 'Custom', 85 | dragProps: { 86 | type: 'title', 87 | layout: { 88 | h: 10, 89 | w: 36, 90 | }, 91 | }, 92 | onClick, 93 | }, 94 | ], 95 | [], 96 | ); 97 | const [config, setConfig] = React.useState(getConfig(true)); 98 | 99 | const onChange = React.useCallback(({config}: {config: DashKitProps['config']}) => { 100 | setConfig(config); 101 | }, []); 102 | 103 | const groups = React.useMemo( 104 | () => [ 105 | { 106 | id: fixedGroup, 107 | render: (id: string, children: React.ReactNode, props: DashkitGroupRenderProps) => { 108 | return ( 109 |
113 | {children} 114 |
115 | ); 116 | }, 117 | gridProperties: (props: ReactGridLayoutProps) => { 118 | const overrideProps: ReactGridLayoutProps = { 119 | ...props, 120 | compactType: 'horizontal-nowrap', 121 | maxRows: MAX_ROWS, 122 | }; 123 | 124 | if (headerInteractions) { 125 | return overrideProps; 126 | } 127 | 128 | return { 129 | ...overrideProps, 130 | noOverlay: true, 131 | isResizable: false, 132 | isDraggable: false, 133 | resizeHandles: [], 134 | }; 135 | }, 136 | }, 137 | { 138 | id: DEFAULT_GROUP, 139 | gridProperties: (props: ReactGridLayoutProps) => { 140 | return { 141 | ...props, 142 | compactType: null, 143 | allowOverlap: true, 144 | resizeHandles: ['s', 'w', 'e', 'n', 'sw', 'nw', 'se', 'ne'], 145 | }; 146 | }, 147 | }, 148 | ], 149 | [headerInteractions], 150 | ); 151 | 152 | const overlayMenuItems = React.useMemo(() => { 153 | const layoutById = config.layout.reduce>((memo, item) => { 154 | memo[item.i] = item; 155 | return memo; 156 | }, {}); 157 | const maxOffset = config.layout 158 | .filter(({parent}) => parent === fixedGroup) 159 | .reduce((offset, {x, w}) => Math.max(offset, x + w), 0); 160 | const maxWidth = GRID_COLUMNS - maxOffset; 161 | 162 | const changeParent = (item: ConfigItem) => { 163 | const itemId = item.id; 164 | const layoutItem = config.layout.find(({i}) => i === itemId); 165 | 166 | if (!layoutItem) { 167 | return; 168 | } 169 | 170 | const copyItem = { 171 | ...layoutItem, 172 | }; 173 | const fromParent = layoutItem.parent; 174 | 175 | if (fromParent) { 176 | delete copyItem.parent; 177 | copyItem.x = 0; 178 | copyItem.y = 0; 179 | } else { 180 | copyItem.parent = fixedGroup; 181 | copyItem.x = maxOffset; 182 | copyItem.y = 0; 183 | copyItem.h = MAX_ROWS; 184 | } 185 | 186 | setConfig({ 187 | ...config, 188 | layout: DashKit.reflowLayout({ 189 | newLayoutItem: copyItem, 190 | layout: config.layout.filter(({i}) => i !== itemId), 191 | groups, 192 | }), 193 | }); 194 | }; 195 | 196 | const controls: DashKitProps['overlayMenuItems'] = [ 197 | { 198 | id: 'unpin-item', 199 | title: 'Unpin', 200 | icon: , 201 | visible: (item) => Boolean(layoutById[item.id]?.parent), 202 | handler: changeParent, 203 | }, 204 | { 205 | id: 'pin-item', 206 | title: 'Pin', 207 | icon: , 208 | visible: (item) => { 209 | const layoutItem = layoutById[item.id]; 210 | 211 | return !layoutItem?.parent && layoutItem.w <= maxWidth; 212 | }, 213 | handler: changeParent, 214 | }, 215 | { 216 | id: MenuItems.Delete, 217 | title: i18n('label_delete'), // for language change check 218 | icon: , 219 | className: 'dashkit-overlay-controls__item_danger', 220 | }, 221 | ]; 222 | 223 | return controls; 224 | }, [config, groups, setConfig]); 225 | 226 | const onDrop = React.useCallback>( 227 | (dropProps) => { 228 | let data = null; 229 | const type = dropProps.dragProps?.type; 230 | if (type === 'custom') { 231 | data = {}; 232 | } else { 233 | const text = prompt('Enter text'); 234 | if (text) { 235 | data = 236 | type === 'title' 237 | ? { 238 | size: 'm', 239 | text, 240 | showInTOC: true, 241 | } 242 | : {text}; 243 | } 244 | } 245 | 246 | if (data) { 247 | const newConfig = DashKit.setItem({ 248 | item: { 249 | data, 250 | type, 251 | namespace: 'default', 252 | layout: dropProps.itemLayout, 253 | }, 254 | config, 255 | options: { 256 | updateLayout: dropProps.newLayout, 257 | }, 258 | groups, 259 | }); 260 | setConfig(newConfig); 261 | } 262 | 263 | dropProps.commit(); 264 | }, 265 | [config, groups], 266 | ); 267 | 268 | const updateConfigOrder = React.useCallback( 269 | (eventProps) => { 270 | const index = config.items.findIndex((item) => item.id === eventProps.newItem.i); 271 | 272 | const copyItems = [...config.items]; 273 | copyItems.push(copyItems.splice(index, 1)[0]); 274 | 275 | const copyLyaout = [...config.layout]; 276 | copyLyaout.push(copyLyaout.splice(index, 1)[0]); 277 | 278 | setConfig({ 279 | ...config, 280 | items: copyItems, 281 | layout: copyLyaout, 282 | }); 283 | }, 284 | [config], 285 | ); 286 | 287 | const context = React.useMemo( 288 | () => ({editModeHeader: headerInteractions}), 289 | [headerInteractions], 290 | ); 291 | 292 | return ( 293 | { 295 | console.log('dragStarted'); 296 | }} 297 | onDragEnd={() => { 298 | console.log('dragEnded'); 299 | }} 300 | onDropDragOver={(item) => headerInteractions || item.parent !== fixedGroup} 301 | > 302 | 303 | 304 |
305 | 313 | 324 |
325 |
326 | 327 | 338 | 339 | 340 |
341 |
342 | ); 343 | }; 344 | -------------------------------------------------------------------------------- /src/components/DashKit/__stories__/DashKitShowcase.scss: -------------------------------------------------------------------------------- 1 | .stories-dashkit-showcase { 2 | &__controls-line { 3 | display: flex; 4 | margin-top: 10px; 5 | flex-wrap: wrap; 6 | max-width: 800px; 7 | } 8 | 9 | &__btn-contol { 10 | margin-bottom: 10px; 11 | margin-right: 10px; 12 | } 13 | 14 | &__inline-group { 15 | background-color: var(--g-color-base-generic-ultralight); 16 | position: sticky; 17 | overflow: auto; 18 | top: 0; 19 | z-index: 3; 20 | min-height: 52px; 21 | display: flex; 22 | flex-direction: column; 23 | margin-bottom: 8px; 24 | 25 | &_edit-mode { 26 | position: static; 27 | overflow: visible; 28 | } 29 | 30 | > .react-grid-layout { 31 | flex: 1; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/DashKit/__stories__/Demo.scss: -------------------------------------------------------------------------------- 1 | .dashkit-demo { 2 | padding: 20px; 3 | 4 | &__title { 5 | margin: 0 0 20px; 6 | padding-bottom: 15px; 7 | border-bottom: 1px solid var(--g-color-line-generic); 8 | 9 | font-family: 'YS Display', Helvetica, Arial, sans-serif; 10 | font-weight: 500; 11 | font-size: 26px; 12 | line-height: 30px; 13 | 14 | color: var(--g-color-text-complementary); 15 | } 16 | 17 | &__row { 18 | &:not(:last-child) { 19 | margin-bottom: 20px; 20 | } 21 | 22 | &-title { 23 | margin: 0 0 10px; 24 | 25 | font-weight: 500; 26 | font-size: 15px; 27 | line-height: 20px; 28 | 29 | color: var(--g-color-text-complementary); 30 | } 31 | } 32 | 33 | .dashkit_focused { 34 | outline: 1px dashed var(--g-color-text-danger); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/components/DashKit/__stories__/Demo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {cn} from '../../../utils/cn'; 4 | 5 | import './Demo.scss'; 6 | 7 | export type DemoProps = React.PropsWithChildren<{ 8 | title: string; 9 | }>; 10 | 11 | export type DemoRowProps = React.PropsWithChildren<{ 12 | title: string; 13 | }>; 14 | 15 | const b = cn('dashkit-demo'); 16 | 17 | export const Demo = ({title, children}: DemoProps) => { 18 | return ( 19 |
20 |

{title}

21 |
{children}
22 |
23 | ); 24 | }; 25 | 26 | export const DemoRow = ({title, children}: DemoRowProps) => { 27 | return ( 28 |
29 |
{title}
30 |
{children}
31 |
32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/DashKit/__stories__/utils.ts: -------------------------------------------------------------------------------- 1 | import {DashKitProps} from '../DashKit'; 2 | 3 | export function makeid(length: number) { 4 | let result = ''; 5 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 6 | const charactersLength = characters.length; 7 | for (let i = 0; i < length; i++) { 8 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 9 | } 10 | return result; 11 | } 12 | 13 | export const titleId = 'nk'; 14 | 15 | export const specialWidgetId = 'Ea'; 16 | 17 | export const fixedGroup = 'fixedGroup'; 18 | 19 | export const getConfig = (withGroups?: boolean): DashKitProps['config'] => ({ 20 | salt: '0.46703554571365613', 21 | counter: 5, 22 | items: [ 23 | { 24 | id: titleId, 25 | data: { 26 | size: 'm', 27 | text: 'Title widget', 28 | showInTOC: true, 29 | }, 30 | type: 'title', 31 | namespace: 'default', 32 | orderId: 1, 33 | }, 34 | { 35 | id: specialWidgetId, 36 | data: { 37 | text: 'special mode _editActive', 38 | _editActive: true, 39 | }, 40 | type: 'text', 41 | namespace: 'default', 42 | }, 43 | { 44 | id: 'zR', 45 | data: { 46 | text: 'Text widget', 47 | }, 48 | type: 'text', 49 | namespace: 'default', 50 | orderId: 0, 51 | }, 52 | { 53 | id: 'Dk', 54 | data: { 55 | foo: 'bar', 56 | }, 57 | type: 'custom', 58 | namespace: 'default', 59 | orderId: 5, 60 | }, 61 | ...(withGroups 62 | ? [ 63 | { 64 | id: 'Fk', 65 | data: { 66 | size: 'm', 67 | text: 'Title group widget', 68 | showInTOC: true, 69 | }, 70 | type: 'title', 71 | namespace: 'default', 72 | orderId: 1, 73 | }, 74 | { 75 | id: 'Fr', 76 | data: { 77 | text: 'special mode _editActive', 78 | _editActive: true, 79 | }, 80 | type: 'text', 81 | namespace: 'default', 82 | }, 83 | ] 84 | : []), 85 | ], 86 | layout: [ 87 | { 88 | h: 2, 89 | i: titleId, 90 | w: 36, 91 | x: 0, 92 | y: 0, 93 | }, 94 | { 95 | h: 6, 96 | i: 'Ea', 97 | w: 12, 98 | x: 0, 99 | y: 2, 100 | }, 101 | { 102 | h: 6, 103 | i: 'zR', 104 | w: 12, 105 | x: 12, 106 | y: 2, 107 | }, 108 | { 109 | h: 10, 110 | i: 'Dk', 111 | w: 10, 112 | x: 0, 113 | y: 8, 114 | }, 115 | ...(withGroups 116 | ? [ 117 | { 118 | h: 2, 119 | i: 'Fk', 120 | w: 10, 121 | x: 0, 122 | y: 0, 123 | parent: fixedGroup, 124 | }, 125 | { 126 | h: 2, 127 | i: 'Fr', 128 | w: 10, 129 | x: 10, 130 | y: 0, 131 | parent: fixedGroup, 132 | }, 133 | ] 134 | : []), 135 | ], 136 | aliases: {}, 137 | connections: [], 138 | }); 139 | -------------------------------------------------------------------------------- /src/components/DashKit/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DashKit'; 2 | -------------------------------------------------------------------------------- /src/components/DashKitDnDWrapper/DashKitDnDWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {DashKitDnDContext} from '../../context'; 4 | import type {DraggedOverItem, ItemDragProps} from '../../shared'; 5 | 6 | type DashKitDnDWrapperProps = { 7 | dragImageSrc?: string; 8 | onDropDragOver?: ( 9 | draggedItem: DraggedOverItem, 10 | sharedItem: DraggedOverItem | null, 11 | ) => void | boolean; 12 | onDragStart?: (dragProps: ItemDragProps) => void; 13 | onDragEnd?: () => void; 14 | children: React.ReactElement; 15 | }; 16 | 17 | const defaultImageSrc = 18 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; 19 | 20 | export const DashKitDnDWrapper: React.FC = (props) => { 21 | const [dragProps, setDragProps] = React.useState(null); 22 | 23 | const dragImagePreview = React.useMemo(() => { 24 | const img = new Image(); 25 | img.src = props.dragImageSrc || defaultImageSrc; 26 | return img; 27 | }, [props.dragImageSrc]); 28 | 29 | const onDragStartProp = props.onDragStart; 30 | const onDragStart = React.useCallback( 31 | (_: React.DragEvent, itemDragProps: ItemDragProps) => { 32 | setDragProps(itemDragProps); 33 | onDragStartProp?.(itemDragProps); 34 | }, 35 | [setDragProps, onDragStartProp], 36 | ); 37 | 38 | const onDragEndProp = props.onDragEnd; 39 | const onDragEnd = React.useCallback( 40 | (_: React.DragEvent) => { 41 | setDragProps(null); 42 | onDragEndProp?.(); 43 | }, 44 | [setDragProps, onDragEndProp], 45 | ); 46 | 47 | const contextValue = React.useMemo(() => { 48 | return { 49 | dragProps, 50 | dragImagePreview, 51 | onDragStart, 52 | onDragEnd, 53 | onDropDragOver: props.onDropDragOver, 54 | }; 55 | }, [dragProps, dragImagePreview, onDragStart, onDragEnd, props.onDropDragOver]); 56 | 57 | return ( 58 | 59 | {props.children} 60 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /src/components/DashKitView/DashKitView.scss: -------------------------------------------------------------------------------- 1 | .dashkit { 2 | width: 100%; 3 | position: relative; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/DashKitView/DashKitView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {DashKitContext} from '../../context'; 4 | import {withContext} from '../../hocs/withContext'; 5 | import {useCalcPropsLayout} from '../../hooks/useCalcLayout'; 6 | import type {RegisterManager} from '../../utils'; 7 | import {cn} from '../../utils/cn'; 8 | import type {DashKitProps} from '../DashKit'; 9 | import GridLayout from '../GridLayout/GridLayout'; 10 | import MobileLayout from '../MobileLayout/MobileLayout'; 11 | 12 | import './DashKitView.scss'; 13 | 14 | const b = cn('dashkit'); 15 | 16 | type DashKitViewProps = DashKitProps & { 17 | registerManager: RegisterManager; 18 | }; 19 | 20 | function DashKitView() { 21 | const context = React.useContext(DashKitContext); 22 | const {registerManager, forwardedMetaRef} = context; 23 | 24 | return ( 25 |
26 | {registerManager.settings.isMobile ? ( 27 | 28 | ) : ( 29 | 30 | )} 31 |
32 | ); 33 | } 34 | 35 | const DashKitViewWithContext = withContext(DashKitView); 36 | 37 | const DashKitViewForwardedMeta = React.forwardRef((props: DashKitViewProps, ref) => { 38 | const layout = useCalcPropsLayout(props.config, props.registerManager); 39 | 40 | return ; 41 | }); 42 | 43 | DashKitViewForwardedMeta.displayName = 'DashKitViewForwardedMeta'; 44 | 45 | export default DashKitViewForwardedMeta; 46 | -------------------------------------------------------------------------------- /src/components/GridItem/GridItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import PropTypes from 'prop-types'; 4 | 5 | import {FOCUSED_CLASS_NAME} from '../../constants'; 6 | import {DashKitContext} from '../../context'; 7 | import {cn} from '../../utils/cn'; 8 | import Item from '../Item/Item'; 9 | import OverlayControls from '../OverlayControls/OverlayControls'; 10 | 11 | import './GridItem.scss'; 12 | 13 | const b = cn('dashkit-grid-item'); 14 | 15 | class WindowFocusObserver { 16 | constructor() { 17 | this.subscribers = 0; 18 | this.isFocused = !document.hidden; 19 | 20 | window.addEventListener('blur', this.blurHandler, true); 21 | window.addEventListener('focus', this.focusHandler, true); 22 | } 23 | 24 | blurHandler = (e) => { 25 | if (e.target === window) { 26 | this.isFocused = false; 27 | } 28 | }; 29 | 30 | focusHandler = (e) => { 31 | if (e.target === window) { 32 | this.isFocused = true; 33 | } 34 | }; 35 | 36 | // Method to get state after all blur\focus events in document are triggered 37 | async getFocusedState() { 38 | return new Promise((resolve) => { 39 | requestAnimationFrame(() => { 40 | resolve(this.isFocused); 41 | }); 42 | }); 43 | } 44 | } 45 | 46 | const windowFocusObserver = new WindowFocusObserver(); 47 | 48 | class GridItem extends React.PureComponent { 49 | static propTypes = { 50 | adjustWidgetLayout: PropTypes.func.isRequired, 51 | gridLayout: PropTypes.object, 52 | id: PropTypes.string, 53 | item: PropTypes.object, 54 | isDragging: PropTypes.bool, 55 | isDraggedOut: PropTypes.bool, 56 | layout: PropTypes.array, 57 | 58 | forwardedRef: PropTypes.any, 59 | forwardedPluginRef: PropTypes.any, 60 | isPlaceholder: PropTypes.bool, 61 | 62 | onItemMountChange: PropTypes.func, 63 | onItemRender: PropTypes.func, 64 | 65 | // from react-grid-layout: 66 | children: PropTypes.node, 67 | className: PropTypes.string, 68 | style: PropTypes.object, 69 | noOverlay: PropTypes.bool, 70 | focusable: PropTypes.bool, 71 | withCustomHandle: PropTypes.bool, 72 | onMouseDown: PropTypes.func, 73 | onMouseUp: PropTypes.func, 74 | onTouchEnd: PropTypes.func, 75 | onTouchStart: PropTypes.func, 76 | onItemFocus: PropTypes.func, 77 | onItemBlur: PropTypes.func, 78 | }; 79 | 80 | static contextType = DashKitContext; 81 | 82 | _isAsyncItem = false; 83 | 84 | state = { 85 | isFocused: false, 86 | }; 87 | 88 | renderOverlay() { 89 | const {isPlaceholder} = this.props; 90 | const {editMode} = this.context; 91 | 92 | if (editMode && this.props.item.data._editActive) { 93 | // needed for correst pointer-events: none work in firefox 94 | return
; 95 | } 96 | 97 | if (!editMode || this.props.item.data._editActive || isPlaceholder) { 98 | return null; 99 | } 100 | 101 | const {item, focusable} = this.props; 102 | 103 | return ( 104 | 105 |
106 | 110 | 111 | ); 112 | } 113 | 114 | onOverlayItemClick = () => { 115 | // Creating button element to trigger focus out 116 | const focusDummy = document.createElement('button'); 117 | const styles = { 118 | width: '0', 119 | height: '0', 120 | opacity: '0', 121 | position: 'fixed', 122 | top: '0', 123 | left: '0', 124 | }; 125 | 126 | Object.entries(styles).forEach(([key, value]) => { 127 | focusDummy.style[key] = value; 128 | }); 129 | 130 | // requestAnimationFrame to make call after alert() or confirm() 131 | requestAnimationFrame(() => { 132 | // Adding elment an changing focus 133 | document.body.appendChild(focusDummy); 134 | focusDummy.focus(); 135 | document.body.removeChild(focusDummy); 136 | 137 | this.setState({isFocused: false}); 138 | }); 139 | }; 140 | 141 | onFocusHandler = () => { 142 | this.setState({isFocused: true}); 143 | 144 | if (this.props.onItemFocus) { 145 | // Sync focus and blur handlers 146 | windowFocusObserver.getFocusedState().then(() => { 147 | this.props.onItemFocus?.(this.props.item); 148 | }); 149 | } 150 | 151 | if (this.controller) { 152 | this.controller.abort(); 153 | } 154 | }; 155 | 156 | onBlurHandler = () => { 157 | this.controller = new AbortController(); 158 | 159 | windowFocusObserver.getFocusedState().then((isWindowFocused) => { 160 | if (!this.controller?.signal.aborted && isWindowFocused) { 161 | this.setState({isFocused: false}); 162 | this.props.onItemBlur?.(this.props.item); 163 | } 164 | 165 | this.controller = null; 166 | }); 167 | }; 168 | 169 | render() { 170 | // из-за бага, что Grid Items unmounts при изменении static, isDraggable, isResaizable 171 | // https://github.com/STRML/react-grid-layout/issues/721 172 | const { 173 | style, 174 | onMouseDown, 175 | onMouseUp, 176 | onTouchEnd, 177 | onTouchStart, 178 | children, 179 | className, 180 | isDragging, 181 | isDraggedOut, 182 | noOverlay, 183 | focusable, 184 | withCustomHandle, 185 | isPlaceholder = false, 186 | } = this.props; 187 | const {editMode} = this.context; 188 | const {isFocused} = this.state; 189 | 190 | const width = Number.parseInt(style.width, 10); 191 | const height = Number.parseInt(style.height, 10); 192 | const transform = style.transform; 193 | const preparedClassName = 194 | (editMode 195 | ? className 196 | : className 197 | .replace('react-resizable', '') 198 | .replace('react-draggable', '') 199 | .replace(FOCUSED_CLASS_NAME, '')) + 200 | (isFocused ? ` ${FOCUSED_CLASS_NAME}` : ''); 201 | const computedClassName = b( 202 | { 203 | 'is-dragging': isDragging, 204 | 'is-dragged-out': isDraggedOut, 205 | 'is-focused': isFocused, 206 | 'with-custom-handle': withCustomHandle, 207 | }, 208 | preparedClassName, 209 | ); 210 | 211 | const preparedChildren = editMode ? children : null; 212 | const reactGridLayoutProps = editMode 213 | ? {onMouseDown, onMouseUp, onTouchEnd, onTouchStart} 214 | : {}; 215 | const reactFocusProps = focusable 216 | ? { 217 | onFocus: this.onFocusHandler, 218 | onBlur: this.onBlurHandler, 219 | tabIndex: -1, 220 | } 221 | : {}; 222 | const {_editActive} = this.props.item.data; 223 | 224 | return ( 225 |
233 |
234 | 249 |
250 | {!noOverlay && this.renderOverlay()} 251 | {preparedChildren} 252 |
253 | ); 254 | } 255 | } 256 | 257 | const GridItemForwarderRef = React.forwardRef((props, ref) => { 258 | return ; 259 | }); 260 | 261 | GridItemForwarderRef.displayName = 'forwardRef(GridItem)'; 262 | 263 | export default GridItemForwarderRef; 264 | -------------------------------------------------------------------------------- /src/components/GridItem/GridItem.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:map'; 2 | 3 | .dashkit-grid-item { 4 | position: relative; 5 | $_border-radius: 3px; 6 | 7 | &:focus { 8 | outline: none; 9 | } 10 | 11 | &__overlay { 12 | --_-dashkit-overlay-color: var(--dashkit-overlay-color, var(--g-color-base-generic)); 13 | --_-dashkit-overlay-border-color: var(--dashkit-overlay-border-color, rgba(0, 0, 0, 0.1)); 14 | --_-dashkit-overlay-opacity: var(--dashkit-overlay-opacity, 1); 15 | --_-dashkit-overlay-border-radius: var( 16 | --dashkit-grid-item-border-radius, 17 | #{$_border-radius} 18 | ); 19 | 20 | position: absolute; 21 | inset: 0; 22 | border-radius: var(--_-dashkit-overlay-border-radius); 23 | background-color: var(--_-dashkit-overlay-color); 24 | border: solid 1px var(--_-dashkit-overlay-border-color); 25 | opacity: var(--_-dashkit-overlay-opacity); 26 | } 27 | 28 | &__overlay-placeholder { 29 | position: absolute; 30 | inset: 0; 31 | } 32 | 33 | &__item { 34 | --_-dashkit-grid-item-border-radius: var( 35 | --dashkit-grid-item-border-radius, 36 | #{$_border-radius} 37 | ); 38 | --_-dashkit-grid-item-edit-opacity: var(--dashkit-grid-item-edit-opacity, 0.5); 39 | 40 | position: absolute; 41 | inset: 0; 42 | border-radius: var(--_-dashkit-grid-item-border-radius); 43 | 44 | &_editMode { 45 | opacity: var(--_-dashkit-grid-item-edit-opacity); 46 | border-color: transparent; 47 | pointer-events: none; 48 | } 49 | } 50 | 51 | // needs for drag n drop between multiple groups 52 | &_is-dragged-out { 53 | user-select: none; 54 | pointer-events: none; 55 | touch-action: none; 56 | } 57 | } 58 | 59 | .react-grid-item.dropping { 60 | // Disable evety mouse event for dropping element placeholder 61 | user-select: none; 62 | pointer-events: none; 63 | touch-action: none; 64 | } 65 | 66 | .react-grid-layout { 67 | position: relative; 68 | transition: height 200ms ease; 69 | } 70 | 71 | .react-grid-item.cssTransforms { 72 | transition-property: transform; 73 | } 74 | 75 | .react-grid-item.resizing { 76 | z-index: 1; 77 | will-change: width, height; 78 | } 79 | 80 | .react-grid-item.react-draggable:not(.dashkit-grid-item_with-custom-handle) { 81 | cursor: move; 82 | } 83 | 84 | .react-grid-item.react-draggable-dragging { 85 | transition: none; 86 | z-index: 3; 87 | will-change: transform; 88 | } 89 | 90 | .react-grid-item.dashkit-grid-item_is-focused { 91 | z-index: 2; 92 | } 93 | 94 | .react-grid-item.react-grid-placeholder { 95 | --_-dashkit-placeholder-color: var(--dashkit-placeholder-color, #fc0); 96 | --_-dashkit-placeholder-opacity: var(--dashkit-placeholder-opacity, 0.2); 97 | 98 | background: var(--_-dashkit-placeholder-color); 99 | opacity: var(--_-dashkit-placeholder-opacity); 100 | transition-duration: 100ms; 101 | z-index: 2; 102 | user-select: none; 103 | } 104 | 105 | .react-grid-focus-capture { 106 | position: absolute; 107 | display: block; 108 | // Should be the highest between all grid item states 109 | z-index: 6; 110 | max-width: 100%; 111 | max-height: 100%; 112 | } 113 | 114 | .react-grid-item .react-resizable-handle { 115 | position: absolute; 116 | width: 20px; 117 | height: 20px; 118 | z-index: 6; 119 | } 120 | 121 | .react-grid-item .react-resizable-handle::after { 122 | content: ''; 123 | position: absolute; 124 | border: 4px solid transparent; 125 | } 126 | 127 | $handle-color: rgba(0, 0, 0, 0.4); 128 | $handle-list: ( 129 | 's': ( 130 | 'wrapper': ( 131 | bottom: 0, 132 | left: 50%, 133 | transform: translateX(-50%) translateY(0), 134 | ), 135 | 'icon': ( 136 | bottom: -3px, 137 | left: 4px, 138 | border: 6px solid transparent, 139 | border-top-color: $handle-color, 140 | ), 141 | ), 142 | 'w': ( 143 | 'wrapper': ( 144 | top: 50%, 145 | left: 0, 146 | transform: translateX(0) translateY(-50%), 147 | ), 148 | 'icon': ( 149 | top: 4px, 150 | left: -3px, 151 | border: 6px solid transparent, 152 | border-right-color: $handle-color, 153 | ), 154 | ), 155 | 'e': ( 156 | 'wrapper': ( 157 | top: 50%, 158 | right: 0, 159 | transform: translateX(0) translateY(-50%), 160 | ), 161 | 'icon': ( 162 | top: 4px, 163 | right: -3px, 164 | border: 6px solid transparent, 165 | border-left-color: $handle-color, 166 | ), 167 | ), 168 | 'n': ( 169 | 'wrapper': ( 170 | top: 0, 171 | left: 50%, 172 | transform: translateX(-50%) translateY(0), 173 | ), 174 | 'icon': ( 175 | top: -3px, 176 | left: 4px, 177 | border: 6px solid transparent, 178 | border-bottom-color: $handle-color, 179 | ), 180 | ), 181 | 'sw': ( 182 | 'wrapper': ( 183 | bottom: 0, 184 | left: 0, 185 | ), 186 | 'icon': ( 187 | bottom: 3px, 188 | left: 3px, 189 | border-left-color: $handle-color, 190 | border-bottom-color: $handle-color, 191 | ), 192 | ), 193 | 'nw': ( 194 | 'wrapper': ( 195 | top: 0, 196 | left: 0, 197 | ), 198 | 'icon': ( 199 | top: 3px, 200 | left: 3px, 201 | border-left-color: $handle-color, 202 | border-top-color: $handle-color, 203 | ), 204 | ), 205 | 'se': ( 206 | 'wrapper': ( 207 | bottom: 0, 208 | right: 0, 209 | ), 210 | 'icon': ( 211 | bottom: 3px, 212 | right: 3px, 213 | border-right-color: $handle-color, 214 | border-bottom-color: $handle-color, 215 | ), 216 | ), 217 | 'ne': ( 218 | 'wrapper': ( 219 | top: 0, 220 | right: 0, 221 | ), 222 | 'icon': ( 223 | top: 3px, 224 | right: 3px, 225 | border-right-color: $handle-color, 226 | border-top-color: $handle-color, 227 | ), 228 | ), 229 | ); 230 | 231 | @each $handle, $props in $handle-list { 232 | .react-grid-item .react-resizable-handle-#{$handle} { 233 | cursor: #{$handle}-resize; 234 | @each $prop, $value in map.get($props, 'wrapper') { 235 | #{$prop}: $value; 236 | } 237 | 238 | &::after { 239 | @each $prop, $value in map.get($props, 'icon') { 240 | #{$prop}: $value; 241 | } 242 | } 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /src/components/GridLayout/ReactGridLayout.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import ReactGridLayout, {WidthProvider, utils} from 'react-grid-layout'; 4 | 5 | import {DROPPING_ELEMENT_CLASS_NAME, OVERLAY_CLASS_NAME} from '../../constants'; 6 | 7 | class DragOverLayout extends ReactGridLayout { 8 | constructor(...args) { 9 | super(...args); 10 | 11 | this.parentOnDrag = this.onDrag; 12 | this.onDrag = this.extendedOnDrag; 13 | 14 | this.parentOnDragStop = this.onDragStop; 15 | this.onDragStop = this.extendedOnDragStop; 16 | } 17 | 18 | _savedDraggedOutLayout = null; 19 | 20 | componentDidMount() { 21 | super.componentDidMount?.(); 22 | 23 | // If cursor is moved out of the window there is a bug 24 | // which leaves placeholder element in grid, this action needed to reset this state 25 | window.addEventListener('dragend', this.resetExternalPlaceholder); 26 | const innerElement = this.getInnerElement(); 27 | 28 | if (innerElement) { 29 | innerElement.addEventListener('mouseup', this.mouseUpHandler); 30 | innerElement.addEventListener('mouseenter', this.mouseEnterHandler); 31 | innerElement.addEventListener('mouseleave', this.mouseLeaveHandler); 32 | innerElement.addEventListener('mousemove', this.mouseMoveHandler); 33 | } 34 | } 35 | 36 | componentWillUnmount() { 37 | window.removeEventListener('dragend', this.resetExternalPlaceholder); 38 | const innerElement = this.getInnerElement(); 39 | 40 | if (innerElement) { 41 | innerElement.removeEventListener('mouseup', this.mouseUpHandler); 42 | innerElement.removeEventListener('mouseenter', this.mouseEnterHandler); 43 | innerElement.removeEventListener('mouseleave', this.mouseLeaveHandler); 44 | innerElement.removeEventListener('mousemove', this.mouseMoveHandler); 45 | } 46 | } 47 | 48 | // react-grid-layout doens't calculate it's height when last element is removed 49 | // and just keeps the previous value 50 | // so for autosize to work in that case we are resetting it's height value 51 | containerHeight() { 52 | if (this.props.autoSize && this.state.layout.length === 0) { 53 | return; 54 | } 55 | 56 | // eslint-disable-next-line consistent-return 57 | return super.containerHeight(); 58 | } 59 | 60 | // innerRef is passed by WithProvider without this wrapper there are only 61 | // * findDOMNode - deprecated 62 | // * rewrite whole ReactGridLayout.render method 63 | // so in that case don't try to use this class on it's own 64 | // or pass innerRef: React.MutableRef as it's not optional prop 65 | getInnerElement() { 66 | return this.props.innerRef?.current || null; 67 | } 68 | 69 | // Reset placeholder when item dragged from outside 70 | resetExternalPlaceholder = () => { 71 | if (this.dragEnterCounter) { 72 | this.dragEnterCounter = 0; 73 | this.removeDroppingPlaceholder(); 74 | } 75 | }; 76 | 77 | // Hide placeholder when element is dragged out 78 | hideLocalPlaceholder = (i) => { 79 | const {layout} = this.state; 80 | const {cols} = this.props; 81 | const savedLayout = layout.map((item) => ({...item})); 82 | 83 | let hiddenElement; 84 | const newLayout = utils.compact( 85 | layout.filter((item) => { 86 | if (item.i === i) { 87 | hiddenElement = item; 88 | return false; 89 | } 90 | 91 | return true; 92 | }), 93 | utils.compactType(this.props), 94 | cols, 95 | ); 96 | 97 | if (hiddenElement) { 98 | newLayout.push(hiddenElement); 99 | } 100 | 101 | this.setState({ 102 | activeDrag: null, 103 | layout: newLayout, 104 | }); 105 | 106 | return savedLayout; 107 | }; 108 | 109 | extendedOnDrag = (i, x, y, sintEv) => { 110 | if (this.props.isDragCaptured) { 111 | if (!this._savedDraggedOutLayout) { 112 | this._savedDraggedOutLayout = this.hideLocalPlaceholder(i); 113 | } 114 | 115 | return; 116 | } 117 | 118 | this._savedDraggedOutLayout = null; 119 | // parent onDrag will show new placeholder again 120 | this.parentOnDrag(i, x, y, sintEv); 121 | }; 122 | 123 | extendedOnDragStop = (i, x, y, sintEv) => { 124 | // Restoring layout if item was dropped outside of the grid 125 | if (this._savedDraggedOutLayout) { 126 | const savedLayout = this._savedDraggedOutLayout; 127 | const l = utils.getLayoutItem(savedLayout, i); 128 | 129 | // Create placeholder (display only) 130 | const placeholder = { 131 | w: l.w, 132 | h: l.h, 133 | x: l.x, 134 | y: l.y, 135 | placeholder: true, 136 | i: i, 137 | }; 138 | 139 | this.setState( 140 | { 141 | layout: savedLayout, 142 | activeDrag: placeholder, 143 | }, 144 | () => { 145 | this.parentOnDragStop(i, x, y, sintEv); 146 | }, 147 | ); 148 | 149 | this._savedDraggedOutLayout = null; 150 | } else { 151 | this.parentOnDragStop(i, x, y, sintEv); 152 | } 153 | }; 154 | 155 | // Proxy mouse events -> drag methods for dnd between groups 156 | mouseEnterHandler = (e) => { 157 | if (this.props.hasSharedDragItem) { 158 | this.onDragEnter(e); 159 | } else if (this.props.isDragCaptured) { 160 | this.props.onDragTargetRestore?.(); 161 | } 162 | }; 163 | 164 | mouseLeaveHandler = (e) => { 165 | if (this.props.hasSharedDragItem) { 166 | this.onDragLeave(e); 167 | this.props.onDragTargetRestore?.(); 168 | } 169 | }; 170 | 171 | mouseMoveHandler = (e) => { 172 | if (this.props.hasSharedDragItem) { 173 | if (!e.nativeEvent) { 174 | // Emulate nativeEvent for firefox 175 | const target = this.getInnerElement() || e.target; 176 | 177 | e.nativeEvent = { 178 | clientX: e.clientX, 179 | clientY: e.clientY, 180 | target, 181 | }; 182 | } 183 | 184 | this.onDragOver(e); 185 | } 186 | }; 187 | 188 | mouseUpHandler = (e) => { 189 | if (this.props.hasSharedDragItem) { 190 | e.preventDefault(); 191 | const {droppingItem} = this.props; 192 | const {layout} = this.state; 193 | const item = layout.find((l) => l.i === droppingItem.i); 194 | 195 | // reset dragEnter counter on drop 196 | this.resetExternalPlaceholder(); 197 | 198 | this.props.onDrop?.(layout, item, e); 199 | } 200 | }; 201 | 202 | calculateDroppingPosition(itemProps) { 203 | const {containerWidth, cols, w, h, rowHeight, margin, transformScale, droppingPosition} = 204 | itemProps; 205 | const {sharedDragPosition} = this.props; 206 | 207 | let offsetX, offsetY; 208 | 209 | if (sharedDragPosition) { 210 | offsetX = sharedDragPosition.offsetX; 211 | offsetY = sharedDragPosition.offsetY; 212 | } else { 213 | offsetX = (((containerWidth / cols) * w) / 2 || 0) * transformScale; 214 | offsetY = ((h * rowHeight + (h - 1) * margin[1]) / 2 || 0) * transformScale; 215 | } 216 | 217 | return { 218 | ...droppingPosition, 219 | left: droppingPosition.left - offsetX, 220 | top: droppingPosition.top - offsetY, 221 | }; 222 | } 223 | 224 | // Drop item from outside gets 0,0 droppingPosition 225 | // centering cursor on newly creted grid item 226 | // And cause grid-layout using it's own GridItem to make it look 227 | // like overlay adding className 228 | processGridItem(child, isDroppingItem) { 229 | const gridItem = super.processGridItem?.(child, isDroppingItem); 230 | 231 | if (!gridItem) { 232 | return gridItem; 233 | } 234 | 235 | if (isDroppingItem) { 236 | // React.cloneElement is just cleaner then copy-paste whole processGridItem method 237 | return React.cloneElement(gridItem, { 238 | // hiding preview if dragging shared item 239 | style: this.props.hasSharedDragItem 240 | ? {...gridItem.props.style, opacity: 0} 241 | : gridItem.props.style, 242 | className: `${OVERLAY_CLASS_NAME} ${DROPPING_ELEMENT_CLASS_NAME}`, 243 | droppingPosition: this.calculateDroppingPosition(gridItem.props), 244 | }); 245 | } 246 | 247 | return gridItem; 248 | } 249 | } 250 | 251 | // eslint-disable-next-line new-cap 252 | export const Layout = WidthProvider(DragOverLayout); 253 | -------------------------------------------------------------------------------- /src/components/Item/Item.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import PropTypes from 'prop-types'; 4 | 5 | import {prepareItem} from '../../hocs/prepareItem'; 6 | import {cn} from '../../utils/cn'; 7 | 8 | import './Item.scss'; 9 | 10 | const b = cn('dashkit-item'); 11 | 12 | // TODO: getDerivedStateFromError и заглушка с ошибкой 13 | const Item = ({ 14 | registerManager, 15 | rendererProps, 16 | type, 17 | isPlaceholder, 18 | forwardedPluginRef, 19 | onItemRender, 20 | onItemMountChange, 21 | item, 22 | }) => { 23 | // to avoid too frequent re-creation of functions that do not affect the rendering 24 | const isAsyncItemRef = React.useRef(false); 25 | const itemRef = React.useRef(item); 26 | const onItemRenderRef = React.useRef(onItemRender); 27 | const onItemMountChangeRef = React.useRef(onItemMountChange); 28 | 29 | itemRef.current = item; 30 | onItemRenderRef.current = onItemRender; 31 | onItemMountChangeRef.current = onItemMountChange; 32 | 33 | const isRegisteredType = registerManager.check(type); 34 | 35 | React.useLayoutEffect(() => { 36 | if (isRegisteredType && !isPlaceholder) { 37 | onItemMountChangeRef.current?.(itemRef.current, { 38 | isAsync: isAsyncItemRef.current, 39 | isMounted: true, 40 | }); 41 | 42 | if (!isAsyncItemRef.current) { 43 | onItemRenderRef.current?.(itemRef.current); 44 | } 45 | 46 | return () => { 47 | onItemMountChangeRef.current?.(itemRef.current, { 48 | isAsync: isAsyncItemRef.current, 49 | isMounted: false, 50 | }); 51 | }; 52 | } 53 | }, []); 54 | 55 | const onLoad = React.useCallback(() => { 56 | onItemRenderRef.current?.(itemRef.current); 57 | }, []); 58 | 59 | const onBeforeLoad = React.useCallback(() => { 60 | isAsyncItemRef.current = true; 61 | 62 | return onLoad; 63 | }, [onLoad]); 64 | 65 | const itemRendererProps = React.useMemo(() => { 66 | return {...rendererProps, onBeforeLoad}; 67 | }, [rendererProps, onBeforeLoad]); 68 | 69 | if (!isRegisteredType) { 70 | console.warn(`type [${type}] не зарегистрирован`); 71 | return null; 72 | } 73 | 74 | if (isPlaceholder) { 75 | return ( 76 |
77 | {registerManager 78 | .getItem(type) 79 | .placeholderRenderer?.(itemRendererProps, forwardedPluginRef) || null} 80 |
81 | ); 82 | } 83 | 84 | return ( 85 |
86 | {registerManager.getItem(type).renderer(itemRendererProps, forwardedPluginRef)} 87 |
88 | ); 89 | }; 90 | 91 | Item.propTypes = { 92 | forwardedPluginRef: PropTypes.any, 93 | rendererProps: PropTypes.object, 94 | registerManager: PropTypes.object, 95 | type: PropTypes.string, 96 | isPlaceholder: PropTypes.bool, 97 | onItemRender: PropTypes.func, 98 | onItemMountChange: PropTypes.func, 99 | item: PropTypes.object, 100 | }; 101 | 102 | export default prepareItem(Item); 103 | -------------------------------------------------------------------------------- /src/components/Item/Item.scss: -------------------------------------------------------------------------------- 1 | .dashkit-item { 2 | &__placeholder, 3 | &__renderer { 4 | position: absolute; 5 | inset: 0; 6 | } 7 | 8 | &__loader { 9 | position: absolute; 10 | inset: 0; 11 | display: flex; 12 | align-items: center; 13 | justify-content: center; 14 | border: dashed 1px rgba(0, 0, 0, 0.1); 15 | z-index: 3; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/MobileLayout/MobileLayout.scss: -------------------------------------------------------------------------------- 1 | .dashkit-mobile-layout { 2 | &__item { 3 | margin-bottom: 20px; 4 | position: relative; 5 | } 6 | 7 | .dashkit-item__renderer { 8 | overflow-y: auto; 9 | border-radius: 3px; 10 | position: static; 11 | user-select: none; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/MobileLayout/MobileLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import groupBy from 'lodash/groupBy'; 4 | 5 | import {DEFAULT_GROUP} from '../../constants'; 6 | import {DashKitContext} from '../../context'; 7 | import {cn} from '../../utils/cn'; 8 | import Item from '../Item/Item'; 9 | 10 | import {getSortedConfigItems} from './helpers'; 11 | 12 | import './MobileLayout.scss'; 13 | 14 | const b = cn('dashkit-mobile-layout'); 15 | 16 | type MobileLayoutProps = {}; 17 | 18 | type MobileLayoutState = { 19 | itemsWithActiveAutoheight: Record; 20 | }; 21 | 22 | type PlugibRefObject = React.RefObject; 23 | 24 | export default class MobileLayout extends React.PureComponent< 25 | MobileLayoutProps, 26 | MobileLayoutState 27 | > { 28 | static contextType = DashKitContext; 29 | context!: React.ContextType; 30 | 31 | pluginsRefs: PlugibRefObject[] = []; 32 | sortedLayoutItems: Record> | null = null; 33 | 34 | _memoLayout = this.context.layout; 35 | _memoForwardedPluginRef: Array<(refObject: PlugibRefObject) => void> = []; 36 | _memoAdjustWidgetLayout: Record void> = {}; 37 | 38 | state: MobileLayoutState = { 39 | itemsWithActiveAutoheight: {}, 40 | }; 41 | 42 | render() { 43 | const {config, layout, groups = [{id: DEFAULT_GROUP}], context, editMode} = this.context; 44 | 45 | this.pluginsRefs.length = config.items.length; 46 | 47 | const sortedItems = this.getSortedLayoutItems(); 48 | let indexOffset = 0; 49 | 50 | return ( 51 |
52 | {groups.map((group) => { 53 | const groupId = group.id || DEFAULT_GROUP; 54 | const items = sortedItems[groupId] || []; 55 | 56 | const children = items.map((item, index) => { 57 | const isItemWithActiveAutoheight = 58 | item.id in this.state.itemsWithActiveAutoheight; 59 | 60 | return ( 61 |
65 | 77 |
78 | ); 79 | }); 80 | 81 | indexOffset += items.length; 82 | 83 | if (group.render) { 84 | return group.render(groupId, children, { 85 | isMobile: true, 86 | config, 87 | context, 88 | editMode, 89 | items, 90 | layout, 91 | }); 92 | } 93 | 94 | return children; 95 | })} 96 |
97 | ); 98 | } 99 | 100 | getSortedLayoutItems() { 101 | if (this.sortedLayoutItems && this.context.layout === this._memoLayout) { 102 | return this.sortedLayoutItems; 103 | } 104 | 105 | this._memoLayout = this.context.layout; 106 | 107 | const hasOrderId = Boolean(this.context.config.items.find((item) => item.orderId)); 108 | 109 | this.sortedLayoutItems = groupBy( 110 | getSortedConfigItems(this.context.config, hasOrderId), 111 | (item) => item.parent || DEFAULT_GROUP, 112 | ); 113 | 114 | return this.sortedLayoutItems; 115 | } 116 | 117 | getMemoForwardRefCallback(refIndex: number) { 118 | if (!this._memoForwardedPluginRef[refIndex]) { 119 | this._memoForwardedPluginRef[refIndex] = (pluginRef: PlugibRefObject) => { 120 | this.pluginsRefs[refIndex] = pluginRef; 121 | }; 122 | } 123 | 124 | return this._memoForwardedPluginRef[refIndex]; 125 | } 126 | 127 | adjustWidgetLayout(id: string, {needSetDefault}: {needSetDefault: boolean}) { 128 | if (needSetDefault) { 129 | const indexesOfItemsWithActiveAutoheight = { 130 | ...this.state.itemsWithActiveAutoheight, 131 | }; 132 | 133 | delete indexesOfItemsWithActiveAutoheight[id]; 134 | 135 | this.setState({itemsWithActiveAutoheight: indexesOfItemsWithActiveAutoheight}); 136 | } else { 137 | this.setState({ 138 | itemsWithActiveAutoheight: Object.assign({}, this.state.itemsWithActiveAutoheight, { 139 | [id]: true, 140 | }), 141 | }); 142 | } 143 | } 144 | 145 | getMemoAdjustWidgetLayout(id: string) { 146 | if (!this._memoAdjustWidgetLayout[id]) { 147 | this._memoAdjustWidgetLayout[id] = this.adjustWidgetLayout.bind(this, id); 148 | } 149 | 150 | return this._memoAdjustWidgetLayout[id]; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/components/MobileLayout/helpers.ts: -------------------------------------------------------------------------------- 1 | import type {ConfigItem, ConfigLayout} from '../../shared'; 2 | import {DashKitProps} from '../DashKit'; 3 | 4 | const sortByOrderComparator = (prev: ConfigItem, next: ConfigItem, fieldName: keyof ConfigItem) => { 5 | const prevOrderId = prev[fieldName]; 6 | const nextOrderId = next[fieldName]; 7 | 8 | if (prevOrderId === undefined) { 9 | return 1; 10 | } 11 | if (nextOrderId === undefined) { 12 | return -1; 13 | } 14 | if (prevOrderId > nextOrderId) { 15 | return 1; 16 | } else if (prevOrderId < nextOrderId) { 17 | return -1; 18 | } 19 | return 0; 20 | }; 21 | 22 | const getWidgetsSortComparator = (hasOrderId: boolean) => { 23 | return hasOrderId 24 | ? (prev: ConfigItem, next: ConfigItem) => sortByOrderComparator(prev, next, 'orderId') 25 | : (prev: ConfigLayout, next: ConfigLayout) => 26 | prev.y === next.y ? prev.x - next.x : prev.y - next.y; 27 | }; 28 | 29 | export const getSortedConfigItems = (config: DashKitProps['config'], hasOrderId: boolean) => { 30 | const sortComparator = getWidgetsSortComparator(hasOrderId); 31 | 32 | return config.items 33 | .map((item, index) => Object.assign({}, item, config.layout[index])) 34 | .sort(sortComparator); 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/OverlayControls/OverlayControls.scss: -------------------------------------------------------------------------------- 1 | $position-padding: 7px; 2 | 3 | .dashkit-overlay-controls { 4 | position: absolute; 5 | display: flex; 6 | background-color: var(--g-color-base-float); 7 | border-radius: var(--g-border-radius-m); 8 | box-shadow: 0 0 15px rgba(0, 0, 0, 0.15); 9 | 10 | &__default-controls { 11 | display: flex; 12 | } 13 | 14 | &_position { 15 | &_top_right { 16 | top: $position-padding; 17 | right: $position-padding; 18 | } 19 | &_top_left { 20 | top: $position-padding; 21 | left: $position-padding; 22 | } 23 | &_bottom_right { 24 | bottom: $position-padding; 25 | right: $position-padding; 26 | } 27 | &_bottom_left { 28 | bottom: $position-padding; 29 | left: $position-padding; 30 | } 31 | } 32 | 33 | &__item_danger { 34 | color: var(--g-color-text-danger); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/components/OverlayControls/OverlayControls.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {Ellipsis, Gear, Xmark} from '@gravity-ui/icons'; 4 | import { 5 | Button, 6 | ButtonSize, 7 | ButtonView, 8 | DropdownMenu, 9 | DropdownMenuItem, 10 | Icon, 11 | IconProps, 12 | MenuItemProps, 13 | } from '@gravity-ui/uikit'; 14 | import noop from 'lodash/noop'; 15 | 16 | import { 17 | COPIED_WIDGET_STORE_KEY, 18 | DRAGGABLE_CANCEL_CLASS_NAME, 19 | MenuItems, 20 | OVERLAY_CONTROLS_CLASS_NAME, 21 | OVERLAY_ICON_SIZE, 22 | } from '../../constants'; 23 | import {DashkitOvelayControlsContext, OverlayControlsCtxShape} from '../../context'; 24 | import {i18n} from '../../i18n'; 25 | import { 26 | type ConfigItem, 27 | type ItemParams, 28 | type ItemState, 29 | type PluginBase, 30 | isItemWithTabs, 31 | resolveItemInnerId, 32 | } from '../../shared'; 33 | import {MenuItem, Settings} from '../../typings'; 34 | import {cn} from '../../utils/cn'; 35 | 36 | import './OverlayControls.scss'; 37 | 38 | const b = cn(OVERLAY_CONTROLS_CLASS_NAME); 39 | 40 | export enum OverlayControlsPosition { 41 | TopRight = 'top_right', 42 | TopLeft = 'top_left', 43 | BottomRight = 'bottom_right', 44 | BottomLeft = 'bottom_left', 45 | } 46 | 47 | export interface OverlayControlItem { 48 | title?: string; 49 | icon?: IconProps['data']; 50 | iconSize?: number | string; 51 | handler?: (item: ConfigItem) => void; 52 | visible?: (item: ConfigItem) => boolean; 53 | allWidgetsControls?: boolean; // флаг кастомного контрола (без кастомного виджета), которые показываются не в списке меню 54 | excludeWidgetsTypes?: Array; // массив с типами виджетов (плагинов), которые исключаем из отображения контрола по настройке allWidgetsControls 55 | id?: string; // id дефолтного пункта меню для возможноти использования дефолтного action в кастомных контролах 56 | qa?: string; 57 | } 58 | 59 | export interface OverlayCustomControlItem { 60 | id: string; 61 | title?: string; 62 | icon?: MenuItemProps['iconStart']; 63 | iconSize?: number | string; 64 | handler?: (item: ConfigItem, params: ItemParams, state: ItemState) => void; 65 | visible?: (item: ConfigItem) => boolean; 66 | className?: string; 67 | qa?: string; 68 | } 69 | 70 | interface OverlayControlsDefaultProps { 71 | position: OverlayControlsPosition; 72 | view: ButtonView; 73 | size: ButtonSize; 74 | } 75 | 76 | interface OverlayControlsProps extends OverlayControlsDefaultProps { 77 | configItem: ConfigItem; 78 | onItemClick?: () => void | null; 79 | } 80 | 81 | type PreparedCopyItemOptionsArg = Pick & { 82 | timestamp: number; 83 | layout: { 84 | w: number; 85 | h: number; 86 | }; 87 | targetId?: string; 88 | targetInnerId?: string; 89 | }; 90 | 91 | export type PreparedCopyItemOptions = PreparedCopyItemOptionsArg & { 92 | copyContext?: C; 93 | }; 94 | 95 | type OverlayControlsCtx = React.Context; 96 | 97 | const DEFAULT_DROPDOWN_MENU = [MenuItems.Copy, MenuItems.Delete]; 98 | 99 | class OverlayControls extends React.Component { 100 | static contextType = DashkitOvelayControlsContext; 101 | static defaultProps: OverlayControlsDefaultProps = { 102 | position: OverlayControlsPosition.TopRight, 103 | view: 'flat', 104 | size: 'm', 105 | }; 106 | context!: React.ContextType; 107 | render() { 108 | const {position} = this.props; 109 | const items = this.getItems(); 110 | const hasCustomControlsWithWidgets = items.length > 0; 111 | 112 | const controls = hasCustomControlsWithWidgets 113 | ? this.getCustomControlsWithWidgets() 114 | : this.renderControls(); 115 | 116 | return
{controls}
; 117 | } 118 | 119 | private getItems = () => { 120 | const {overlayControls} = this.context; 121 | const {configItem} = this.props; 122 | 123 | return (overlayControls && overlayControls[configItem.type]) || []; 124 | }; 125 | 126 | private renderControlsItem = (item: OverlayControlItem, index: number, length: number) => { 127 | const {view, size, onItemClick} = this.props; 128 | const {title, handler, icon, iconSize, qa} = item; 129 | 130 | const onItemClickHandler = typeof handler === 'function' ? handler : noop; 131 | return ( 132 | 146 | ); 147 | }; 148 | 149 | private getDropDownMenuItemConfig(menuName: string, isDefaultMenu?: boolean) { 150 | switch (menuName) { 151 | case MenuItems.Copy: { 152 | return { 153 | action: this.onCopyItem, 154 | text: i18n('label_copy'), 155 | }; 156 | } 157 | case MenuItems.Delete: { 158 | return { 159 | action: this.onRemoveItem, 160 | text: i18n('label_delete'), 161 | }; 162 | } 163 | case MenuItems.Settings: { 164 | // для дефолтного состояния меню нет настройки settings, а для кастомного можно использовать дефолтный action и text 165 | if (isDefaultMenu) { 166 | return null; 167 | } 168 | return { 169 | action: this.onEditItem, 170 | text: i18n('label_settings'), 171 | }; 172 | } 173 | } 174 | return null; 175 | } 176 | 177 | private getDefaultControls = () => { 178 | const {view, size} = this.props; 179 | 180 | if (this.context.overlayControls === null) { 181 | return null; 182 | } 183 | 184 | return ( 185 | 196 | ); 197 | }; 198 | 199 | private renderControls() { 200 | const customLeftControls = this.getCustomLeftOverlayControls(); 201 | const hasCustomOverlayLeftControls = Boolean(customLeftControls.length); 202 | 203 | const controls = hasCustomOverlayLeftControls 204 | ? customLeftControls.map( 205 | (item: OverlayControlItem, index: number, items: OverlayControlItem[]) => 206 | this.renderControlsItem(item, index, items.length + 1), 207 | ) 208 | : this.getDefaultControls(); 209 | 210 | const menu = this.renderMenu(controls === null); 211 | 212 | return ( 213 |
218 | {controls} 219 | {menu} 220 |
221 | ); 222 | } 223 | private renderMenu(isOnlyOneItem: boolean) { 224 | const {view, size} = this.props; 225 | 226 | const dropdown = this.renderDropdownMenu(isOnlyOneItem); 227 | 228 | if (dropdown) { 229 | return dropdown; 230 | } 231 | 232 | return ( 233 | 244 | ); 245 | } 246 | 247 | private isDefaultMenu(menu: Settings['menu']) { 248 | return menu?.every((item) => 249 | (Object.values(MenuItems) as Array).includes(String(item)), 250 | ); 251 | } 252 | private renderDropdownMenu(isOnlyOneItem: boolean) { 253 | const {view, size, onItemClick} = this.props; 254 | const {menu: contextMenu, itemsParams, itemsState} = this.context; 255 | 256 | const configItem = this.props.configItem; 257 | const itemParams = itemsParams[configItem.id]; 258 | const itemState = itemsState?.[configItem.id] || {}; 259 | 260 | const menu = contextMenu && contextMenu.length > 0 ? contextMenu : DEFAULT_DROPDOWN_MENU; 261 | 262 | const isDefaultMenu = this.isDefaultMenu(menu); 263 | 264 | const items: DropdownMenuItem[] = isDefaultMenu 265 | ? ((menu || []) as string[]).reduce((memo, name: string) => { 266 | const item = this.getDropDownMenuItemConfig(name, true); 267 | if (item) { 268 | memo.push(item); 269 | } 270 | 271 | return memo; 272 | }, []) 273 | : menu.reduce((memo, item: MenuItem) => { 274 | if (typeof item === 'string') { 275 | return memo; 276 | } 277 | // custom menu dropdown item filter 278 | if (item.visible && !item.visible(configItem)) { 279 | return memo; 280 | } 281 | 282 | const itemHandler = item.handler; 283 | 284 | const itemAction = 285 | typeof itemHandler === 'function' 286 | ? () => { 287 | const result = itemHandler(configItem, itemParams, itemState); 288 | onItemClick?.(); 289 | return result; 290 | } 291 | : this.getDropDownMenuItemConfig(item.id)?.action || (() => {}); 292 | 293 | memo.push({ 294 | // @ts-expect-error 295 | text: item.title || i18n(item.id), 296 | iconStart: item.icon, 297 | action: itemAction, 298 | className: item.className, 299 | qa: item.qa, 300 | }); 301 | 302 | return memo; 303 | }, []); 304 | 305 | if (items.length === 0) { 306 | return null; 307 | } 308 | 309 | return ( 310 | ( 314 | 323 | )} 324 | popupProps={{ 325 | className: DRAGGABLE_CANCEL_CLASS_NAME, 326 | }} 327 | /> 328 | ); 329 | } 330 | private getCustomLeftOverlayControls = () => { 331 | // выбираем только items-ы у которых проставлено поле `allWidgetsControls:true` 332 | // те контролы, которые будут показываться слева от меню 333 | let controls: OverlayControlItem[] = []; 334 | const {onItemClick} = this.props; 335 | 336 | for (const controlItem of Object.values(this.context.overlayControls || {})) { 337 | controls = controls.concat( 338 | ( 339 | (controlItem as OverlayControlItem[]).filter((item) => { 340 | // если тип виджета-плагина в списке исключаемых, то не показываем такой контрол 341 | if (item.excludeWidgetsTypes?.includes(this.props.configItem.type)) { 342 | return false; 343 | } 344 | 345 | return item.visible 346 | ? item.visible(this.props.configItem) 347 | : item.allWidgetsControls; 348 | }) || [] 349 | ).map((item) => { 350 | if (!item?.id) { 351 | return item; 352 | } 353 | return { 354 | ...item, 355 | handler: 356 | typeof item.handler === 'function' 357 | ? (...args) => { 358 | const result = item.handler?.(...args); 359 | onItemClick?.(); 360 | return result; 361 | } 362 | : this.getDropDownMenuItemConfig(item.id)?.action || (() => {}), 363 | }; 364 | }), 365 | ); 366 | } 367 | 368 | return controls; 369 | }; 370 | private onCopyItem = () => { 371 | const {configItem} = this.props; 372 | const correspondedItemLayout = this.context.getLayoutItem(configItem.id); 373 | 374 | let targetInnerId; 375 | 376 | if (isItemWithTabs(this.props.configItem)) { 377 | targetInnerId = resolveItemInnerId({ 378 | item: this.props.configItem, 379 | itemsStateAndParams: this.context.itemsStateAndParams, 380 | }); 381 | } 382 | 383 | let options: PreparedCopyItemOptions = { 384 | timestamp: Date.now(), 385 | data: configItem.data, 386 | type: configItem.type, 387 | defaults: configItem.defaults, 388 | namespace: configItem.namespace, 389 | layout: { 390 | w: correspondedItemLayout!.w, 391 | h: correspondedItemLayout!.h, 392 | }, 393 | targetId: this.props.configItem.id, 394 | targetInnerId, 395 | }; 396 | 397 | if (this.context.context?.getPreparedCopyItemOptions) { 398 | console.warn?.( 399 | '`context.getPreparedCopyItemOptions` is deprecated. Please use `getPreparedCopyItemOptions` prop instead', 400 | ); 401 | } 402 | 403 | const getPreparedCopyItemOptions = 404 | this.context?.getPreparedCopyItemOptions ?? 405 | this.context.context?.getPreparedCopyItemOptions; 406 | 407 | if (typeof getPreparedCopyItemOptions === 'function') { 408 | options = getPreparedCopyItemOptions(options); 409 | } 410 | 411 | try { 412 | localStorage.setItem(COPIED_WIDGET_STORE_KEY, JSON.stringify(options)); 413 | this.context.onCopyFulfill?.(null, options); 414 | } catch (e) { 415 | const error = e instanceof Error ? e : new Error('Unknown error while copying item'); 416 | this.context.onCopyFulfill?.(error); 417 | } 418 | // https://stackoverflow.com/questions/35865481/storage-event-not-firing 419 | window.dispatchEvent(new Event('storage')); 420 | this.props.onItemClick?.(); 421 | }; 422 | private onEditItem = () => { 423 | this.context.editItem?.(this.props.configItem); 424 | this.props.onItemClick?.(); 425 | }; 426 | private onRemoveItem = () => { 427 | const {id} = this.props.configItem; 428 | this.context.removeItem(id); 429 | this.props.onItemClick?.(); 430 | }; 431 | private getControlItemPinStyle(index: number, itemsLength: number) { 432 | const isOnlyOneItem = itemsLength === 1; 433 | const isFirstItem = index === 0; 434 | const isLastItem = index === itemsLength - 1; 435 | 436 | if (isOnlyOneItem) { 437 | return 'round-round'; 438 | } 439 | 440 | if (isFirstItem) { 441 | return 'round-brick'; 442 | } 443 | 444 | if (isLastItem) { 445 | return 'brick-round'; 446 | } 447 | 448 | return 'brick-brick'; 449 | } 450 | private getCustomControlsWithWidgets() { 451 | const items = this.getItems(); 452 | 453 | const result = items.map( 454 | (item: OverlayControlItem, index: number, controlItems: OverlayControlItem[]) => 455 | this.renderControlsItem(item, index, controlItems.length), 456 | ); 457 | const isOnlyOneItem = items.length === 0; 458 | 459 | // Добавляем контрол удаления или меню виджета по умолчанию 460 | result.push(this.renderMenu(isOnlyOneItem)); 461 | 462 | return result; 463 | } 464 | } 465 | 466 | export default OverlayControls; 467 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DashKit'; 2 | export type {OverlayControlItem, PreparedCopyItemOptions} from './OverlayControls/OverlayControls'; 3 | export * from './ActionPanel/ActionPanel'; 4 | export type {ActionPanelItem, ActionPanelProps} from './ActionPanel/types'; 5 | export * from './DashKitDnDWrapper/DashKitDnDWrapper'; 6 | -------------------------------------------------------------------------------- /src/constants/common.ts: -------------------------------------------------------------------------------- 1 | export const COPIED_WIDGET_STORE_KEY = 'dashCopiedItem'; 2 | 3 | export const DEFAULT_NAMESPACE = 'default'; 4 | 5 | export const OVERLAY_CONTROLS_CLASS_NAME = 'dashkit-overlay-controls'; 6 | 7 | export const OVERLAY_CLASS_NAME = 'dashkit-grid-item__overlay'; 8 | 9 | export const ITEM_CLASS_NAME = 'dashkit-grid-item'; 10 | 11 | export const OVERLAY_ICON_SIZE = 16; 12 | 13 | export const TEMPORARY_ITEM_ID = '__dropping-elem__'; 14 | 15 | export const MenuItems = { 16 | Copy: 'copy', 17 | Delete: 'delete', 18 | Settings: 'settings', 19 | } as const; 20 | 21 | export const DEFAULT_WIDGET_HEIGHT = 3; 22 | export const DEFAULT_WIDGET_WIDTH = 3; 23 | 24 | export const DEFAULT_GROUP = '__default'; 25 | 26 | export const COMPACT_TYPE_HORIZONTAL_NOWRAP = 'horizontal-nowrap'; 27 | 28 | export const DRAGGABLE_CANCEL_CLASS_NAME = 'dashkit_draggable_cancel'; 29 | 30 | export const FOCUSED_CLASS_NAME = 'dashkit_focused'; 31 | 32 | export const DROPPING_ELEMENT_CLASS_NAME = 'dropping'; 33 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common'; 2 | -------------------------------------------------------------------------------- /src/context/DashKitContext.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type {RegisterManager} from '..//utils'; 4 | import type {DashKitProps} from '../components/DashKit'; 5 | import type { 6 | ConfigItem, 7 | ConfigLayout, 8 | ItemParams, 9 | ItemState, 10 | ItemStateAndParams, 11 | ItemStateAndParamsChangeOptions, 12 | } from '../shared'; 13 | 14 | type DashkitPropsPassedToCtx = Pick< 15 | DashKitProps, 16 | | 'config' 17 | | 'groups' 18 | | 'context' 19 | | 'noOverlay' 20 | | 'focusable' 21 | | 'globalParams' 22 | | 'editMode' 23 | | 'settings' 24 | | 'onItemMountChange' 25 | | 'onItemRender' 26 | | 'draggableHandleClassName' 27 | // default handlers bypass 28 | | 'onDragStart' 29 | | 'onDrag' 30 | | 'onDragStop' 31 | | 'onResizeStart' 32 | | 'onResize' 33 | | 'onResizeStop' 34 | >; 35 | 36 | type PluginType = string; 37 | 38 | export type DashKitCtxShape = DashkitPropsPassedToCtx & { 39 | registerManager: RegisterManager; 40 | forwardedMetaRef: React.ForwardedRef; 41 | 42 | layout: ConfigLayout[]; 43 | temporaryLayout: ConfigLayout[] | null; 44 | memorizeOriginalLayout: ( 45 | widgetId: string, 46 | preAutoHeightLayout: ConfigLayout, 47 | postAutoHeightLayout: ConfigLayout, 48 | ) => void; 49 | revertToOriginalLayout: (widgetId: string) => void; 50 | 51 | itemsState?: Record; 52 | itemsParams: Record; 53 | onItemStateAndParamsChange: ( 54 | id: string, 55 | stateAndParams: ItemStateAndParams, 56 | options: ItemStateAndParamsChangeOptions, 57 | ) => void; 58 | 59 | getItemsMeta: (pluginsRefs: Array>) => Array>; 60 | reloadItems: ( 61 | pluginsRefs: Array>, 62 | data: {silentLoading: boolean; noVeil: boolean}, 63 | ) => void; 64 | 65 | onDrop: (newLayout: ConfigLayout, item: ConfigItem) => void; 66 | onDropDragOver: ( 67 | e: DragEvent | MouseEvent, 68 | group: string | void, 69 | gridProps: Partial, 70 | groupLayout: ConfigLayout[], 71 | sharedItem: (Partial & {type: PluginType}) | void, 72 | ) => void | boolean; 73 | outerDnDEnable: boolean; 74 | dragOverPlugin: null | PluginType; 75 | }; 76 | 77 | const DashKitContext = React.createContext({} as DashKitCtxShape); 78 | 79 | export {DashKitContext}; 80 | -------------------------------------------------------------------------------- /src/context/DashKitDnDContext.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type {DraggedOverItem, ItemDragProps} from '../shared/types'; 4 | 5 | export type DashKitDnDCtxShape = { 6 | dragProps: ItemDragProps | null; 7 | dragImagePreview: HTMLImageElement; 8 | onDragStart: (e: React.DragEvent, itemDragProps: ItemDragProps) => void; 9 | onDragEnd: (e: React.DragEvent) => void; 10 | onDropDragOver?: ( 11 | draggedItem: DraggedOverItem, 12 | sharedItem: DraggedOverItem | null, 13 | ) => void | boolean; 14 | }; 15 | 16 | export const DashKitDnDContext = React.createContext(undefined); 17 | -------------------------------------------------------------------------------- /src/context/DashkitOverlayControlsContext.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type {DashKitProps} from '../components'; 4 | import type {ConfigLayout, ItemParams, ItemState} from '../shared'; 5 | import type {MenuItem} from '../typings'; 6 | 7 | export type OverlayControlsCtxShape = Pick< 8 | DashKitProps, 9 | | 'context' 10 | | 'overlayControls' 11 | | 'itemsStateAndParams' 12 | | 'getPreparedCopyItemOptions' 13 | | 'onCopyFulfill' 14 | > & { 15 | menu: DashKitProps['overlayMenuItems'] | MenuItem[]; 16 | itemsState?: Record; 17 | itemsParams: Record; 18 | 19 | editItem: DashKitProps['onItemEdit']; 20 | removeItem: (id: string) => void; 21 | getLayoutItem: (id: string) => ConfigLayout | void; 22 | }; 23 | 24 | export const DashkitOvelayControlsContext = React.createContext( 25 | undefined, 26 | ); 27 | -------------------------------------------------------------------------------- /src/context/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DashKitContext'; 2 | export * from './DashkitOverlayControlsContext'; 3 | export * from './DashKitDnDContext'; 4 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | export * from './utils'; 2 | export * from './constants'; 3 | export * from './shared'; 4 | -------------------------------------------------------------------------------- /src/hocs/prepareItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import isEqual from 'lodash/isEqual'; 4 | import PropTypes from 'prop-types'; 5 | 6 | import {DashKitContext} from '../context'; 7 | 8 | export function prepareItem(Component) { 9 | return class PrepareItem extends React.Component { 10 | static propTypes = { 11 | gridLayout: PropTypes.object, 12 | adjustWidgetLayout: PropTypes.func.isRequired, 13 | layout: PropTypes.array, 14 | id: PropTypes.string, 15 | item: PropTypes.object, 16 | shouldItemUpdate: PropTypes.bool, 17 | width: PropTypes.number, 18 | height: PropTypes.number, 19 | transform: PropTypes.string, 20 | isPlaceholder: PropTypes.bool, 21 | 22 | onItemRender: PropTypes.func, 23 | onItemMountChange: PropTypes.func, 24 | 25 | forwardedPluginRef: PropTypes.any, 26 | }; 27 | 28 | shouldComponentUpdate(nextProps) { 29 | const {width, height, transform} = this.props; 30 | const {width: widthNext, height: heightNext, transform: transformNext} = nextProps; 31 | if ( 32 | !nextProps.shouldItemUpdate && 33 | width === widthNext && 34 | height === heightNext && 35 | transform === transformNext 36 | ) { 37 | return false; 38 | } 39 | return true; 40 | } 41 | 42 | static contextType = DashKitContext; 43 | 44 | _onStateAndParamsChange = (stateAndParams, options) => { 45 | this.context.onItemStateAndParamsChange(this.props.id, stateAndParams, options); 46 | }; 47 | 48 | _currentRenderProps = {}; 49 | getRenderProps = () => { 50 | const {id, width, height, item, adjustWidgetLayout, layout, isPlaceholder, gridLayout} = 51 | this.props; 52 | const {itemsState, itemsParams, registerManager, settings, context, editMode} = 53 | this.context; 54 | const {data, defaults, namespace} = item; 55 | 56 | const rendererProps = { 57 | data, 58 | editMode, 59 | params: itemsParams[id], 60 | state: itemsState[id], 61 | onStateAndParamsChange: this._onStateAndParamsChange, 62 | width, 63 | height, 64 | id, 65 | defaults, 66 | namespace, 67 | settings, 68 | context, 69 | layout, 70 | gridLayout: gridLayout || registerManager.gridLayout, 71 | adjustWidgetLayout, 72 | isPlaceholder, 73 | }; 74 | 75 | const changedProp = Object.entries(rendererProps).find(([key, value]) => { 76 | // Checking gridLayoout deep as groups gridProperties method has tendancy to creat new objects 77 | if (key === 'gridLayout') { 78 | return !isEqual(this._currentRenderProps[key], value); 79 | } 80 | 81 | return this._currentRenderProps[key] !== value; 82 | }); 83 | 84 | if (changedProp) { 85 | this._currentRenderProps = rendererProps; 86 | } 87 | 88 | return this._currentRenderProps; 89 | }; 90 | 91 | render() { 92 | const {item, isPlaceholder, forwardedPluginRef, onItemMountChange, onItemRender} = 93 | this.props; 94 | const {registerManager} = this.context; 95 | const {type} = item; 96 | 97 | return ( 98 | 108 | ); 109 | } 110 | }; 111 | } 112 | -------------------------------------------------------------------------------- /src/hooks/useCalcLayout.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import isEqual from 'lodash/isEqual'; 4 | 5 | import type {Config} from '../shared'; 6 | import {RegisterManager} from '../utils'; 7 | 8 | function onUpdatePropsConfig(config: Config, registerManager: RegisterManager) { 9 | return config.layout.map((itemLayout, i) => { 10 | const {type} = config.items[i]; 11 | return { 12 | ...registerManager.getItem(type).defaultLayout, 13 | ...itemLayout, 14 | }; 15 | }); 16 | } 17 | 18 | export const useCalcPropsLayout = (config: Config, registerManager: RegisterManager) => { 19 | const [prevConfig, setPrevConfig] = React.useState(config); 20 | const [layout, updateLayout] = React.useState(onUpdatePropsConfig(config, registerManager)); 21 | 22 | if (!isEqual(prevConfig.layout, config.layout)) { 23 | setPrevConfig(config); 24 | updateLayout(onUpdatePropsConfig(config, registerManager)); 25 | } 26 | 27 | return layout; 28 | }; 29 | 30 | // export const useManageLayout = (config: Config, registerManager: RegisterManager) => { 31 | // const propsLayout = useCalcPropsLayout(config, registerManager); 32 | 33 | // // так как мы не хотим хранить параметры виджета с активированной автовысотой в сторе и на сервере, актуальный 34 | // // (видимый юзером в конкретный момент времени) лэйаут (массив объектов с данными о ширине, высоте, 35 | // // расположении конкретного виджета на сетке) будет храниться в стейте, но, для того, чтобы в стор попадал 36 | // // лэйаут без учета вижетов с активированной автовысотой, в момент "подстройки" высоты виджета значение h 37 | // // (высота) из конфига будет запоминаться в originalLayouts, новое значение высоты в adjustedLayouts 38 | // const [currentLayout, setCurrentLayout] = React.useState(propsLayout); 39 | 40 | // }; 41 | -------------------------------------------------------------------------------- /src/hooks/useDeepEqualMemo.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import isEqual from 'lodash/isEqual'; 4 | 5 | export const useDeepEqualMemo = ( 6 | predicate: () => T, 7 | deps: React.DependencyList, 8 | ): T => { 9 | const previousValueRef = React.useRef({} as any); 10 | 11 | return React.useMemo(() => { 12 | const value = predicate(); 13 | 14 | if (!isEqual(previousValueRef.current, value)) { 15 | previousValueRef.current = value; 16 | } 17 | 18 | return previousValueRef.current; 19 | }, [previousValueRef, ...deps]); 20 | }; 21 | -------------------------------------------------------------------------------- /src/hooks/useDnDItemProps.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {ActionPanelItem} from '../components'; 4 | import {DashKitDnDContext} from '../context'; 5 | 6 | type DndProps = null | { 7 | draggable: true; 8 | unselectable: 'on'; 9 | onDragStart: React.DragEventHandler; 10 | onDragEnd: React.DragEventHandler; 11 | }; 12 | 13 | export const useDnDItemProps = (item: ActionPanelItem): DndProps => { 14 | const dragContext = React.useContext(DashKitDnDContext); 15 | 16 | const onDragStart = React.useCallback( 17 | (e: React.DragEvent) => { 18 | if (dragContext && item.dragProps) { 19 | dragContext.onDragStart(e, item.dragProps); 20 | e.dataTransfer.setDragImage(dragContext.dragImagePreview, 0, 0); 21 | } 22 | }, 23 | [dragContext, item.dragProps], 24 | ); 25 | 26 | const onDragEnd = React.useCallback>( 27 | (e) => { 28 | if (dragContext) { 29 | dragContext.onDragEnd(e); 30 | } 31 | }, 32 | [dragContext], 33 | ); 34 | 35 | if (dragContext && item.dragProps) { 36 | return { 37 | draggable: true, 38 | unselectable: 'on', 39 | onDragStart, 40 | onDragEnd, 41 | }; 42 | } 43 | 44 | return null; 45 | }; 46 | -------------------------------------------------------------------------------- /src/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "button_retry": "Repeat", 3 | "label_render-markdown-error": "Markdown transformation error", 4 | "label_settings": "Settings", 5 | "label_copy": "Copy", 6 | "label_delete": "Delete", 7 | "label_error": "Error" 8 | } 9 | -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import {addComponentKeysets} from '@gravity-ui/uikit/i18n'; 2 | 3 | import en from './en.json'; 4 | import ru from './ru.json'; 5 | 6 | export const i18n = addComponentKeysets({en, ru}, 'dashkit'); 7 | -------------------------------------------------------------------------------- /src/i18n/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "button_retry": "Повторить", 3 | "label_render-markdown-error": "Ошибка преобразования в markdown", 4 | "label_settings": "Настройки", 5 | "label_copy": "Копировать", 6 | "label_delete": "Удалить", 7 | "label_error": "Ошибка" 8 | } 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components'; 2 | export * from './plugins'; 3 | export * from './shared/types'; 4 | export * from './typings'; 5 | -------------------------------------------------------------------------------- /src/plugins/Text/Text.scss: -------------------------------------------------------------------------------- 1 | .dashkit-plugin-text { 2 | --rc-loader-active-color: var(--g-color-base-brand); 3 | --rc-loader-off-color: var(--g-color-base-selection-hover); 4 | 5 | white-space: pre-wrap; 6 | overflow: auto; 7 | height: 100%; 8 | padding-right: 5px; 9 | 10 | &_withMarkdown { 11 | white-space: normal; 12 | } 13 | 14 | &_loading { 15 | display: flex; 16 | align-items: center; 17 | justify-content: center; 18 | } 19 | 20 | &_error { 21 | padding: 10px; 22 | } 23 | 24 | &__error { 25 | color: var(--g-color-text-danger); 26 | } 27 | 28 | &__loader { 29 | display: flex; 30 | align-items: center; 31 | justify-content: center; 32 | } 33 | 34 | &__loader-view { 35 | padding: 0 14px; 36 | margin-right: 14px; 37 | } 38 | 39 | &__loader-text { 40 | font-size: 20px; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/plugins/Text/Text.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {Button, Loader} from '@gravity-ui/uikit'; 4 | 5 | import {i18n} from '../../i18n'; 6 | import {Plugin, PluginWidgetProps} from '../../typings'; 7 | import {cn} from '../../utils/cn'; 8 | import {PLUGIN_ROOT_ATTR_NAME} from '../constants'; 9 | 10 | import './Text.scss'; 11 | 12 | // need to set markdown styles separately 13 | // for instance can use yfm-transform 14 | 15 | const b = cn('dashkit-plugin-text'); 16 | 17 | enum LoadStatus { 18 | Pending = 'pending', 19 | Success = 'success', 20 | Fail = 'fail', 21 | } 22 | 23 | export type PluginTextApiHandler = (data: {text: string}) => Promise<{result: string}>; 24 | 25 | export interface PluginTextProps extends PluginWidgetProps { 26 | apiHandler?: PluginTextApiHandler; 27 | data: { 28 | text: string; 29 | } & PluginWidgetProps['data']; 30 | onBeforeRender: () => () => void; 31 | qa?: string; 32 | } 33 | 34 | interface PluginTextState { 35 | htmlText: string; 36 | status: LoadStatus; 37 | needUpdate?: boolean; 38 | text?: string; 39 | } 40 | 41 | export class PluginText extends React.PureComponent { 42 | static getDerivedStateFromProps(props: PluginTextProps, state: PluginTextState) { 43 | const { 44 | data: {text}, 45 | } = props; 46 | const needUpdate = text !== state.text; 47 | return { 48 | text, 49 | needUpdate, 50 | }; 51 | } 52 | 53 | state: PluginTextState = { 54 | htmlText: '', 55 | status: this.withMarkdown ? LoadStatus.Pending : LoadStatus.Success, 56 | }; 57 | 58 | private _isUnmounted = false; 59 | 60 | componentDidMount() { 61 | this._isUnmounted = false; 62 | this.getMarkdown(); 63 | } 64 | 65 | componentDidUpdate() { 66 | if (this.state.needUpdate) { 67 | this.getMarkdown(); 68 | } 69 | } 70 | 71 | componentWillUnmount() { 72 | this._isUnmounted = true; 73 | } 74 | 75 | render() { 76 | switch (this.state.status) { 77 | case LoadStatus.Success: 78 | return this.renderText(); 79 | case LoadStatus.Pending: 80 | return this.renderLoader(); 81 | default: 82 | return this.renderError(); 83 | } 84 | } 85 | 86 | private renderLoader() { 87 | return ( 88 |
89 |
90 |
91 | 92 |
93 |
94 |
95 | ); 96 | } 97 | 98 | private renderError() { 99 | return ( 100 |
101 |
{i18n('label_render-markdown-error')}
102 |
103 | 106 |
107 |
108 | ); 109 | } 110 | 111 | private renderText() { 112 | return ( 113 |
118 | {this.withMarkdown ? ( 119 |
123 | ) : ( 124 | this.state.text 125 | )} 126 |
127 | ); 128 | } 129 | 130 | private async getMarkdown() { 131 | if (typeof this.props.apiHandler !== 'function') { 132 | return; 133 | } 134 | this.setState({status: LoadStatus.Pending}); 135 | 136 | const onLoadComplete = this.props.onBeforeLoad(); 137 | 138 | try { 139 | let htmlText = ''; 140 | if (this.state.text && this.state.text.trim()) { 141 | const loadedData = await this.props.apiHandler({text: this.state.text}); 142 | htmlText = loadedData.result; 143 | } 144 | if (this._isUnmounted) { 145 | return; 146 | } 147 | this.setState({ 148 | htmlText, 149 | status: LoadStatus.Success, 150 | }); 151 | } catch (e) { 152 | if (this._isUnmounted) { 153 | return; 154 | } 155 | this.setState({status: LoadStatus.Fail}); 156 | } 157 | onLoadComplete(); 158 | } 159 | 160 | private onRetryClick = () => { 161 | this.getMarkdown(); 162 | }; 163 | 164 | get withMarkdown() { 165 | return typeof this.props.apiHandler === 'function'; 166 | } 167 | } 168 | 169 | type PluginDataProps = Omit; 170 | 171 | export type PluginTextObjectSettings = {apiHandler?: PluginTextApiHandler}; 172 | 173 | export type PluginTextObject = Plugin & { 174 | setSettings: (settings: PluginTextObjectSettings) => PluginTextObject; 175 | _apiHandler?: PluginTextApiHandler; 176 | }; 177 | 178 | const plugin: PluginTextObject = { 179 | type: 'text', 180 | defaultLayout: {w: 12, h: 6}, 181 | setSettings(settings) { 182 | const {apiHandler} = settings; 183 | plugin._apiHandler = apiHandler; 184 | return plugin; 185 | }, 186 | renderer(props, forwardedRef) { 187 | return ; 188 | }, 189 | }; 190 | 191 | export default plugin; 192 | -------------------------------------------------------------------------------- /src/plugins/Text/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Text'; 2 | export {default as pluginText} from './Text'; 3 | -------------------------------------------------------------------------------- /src/plugins/Title/Title.scss: -------------------------------------------------------------------------------- 1 | .dashkit-plugin-title { 2 | display: flex; 3 | align-items: flex-start; 4 | height: 100%; 5 | font-weight: 500; 6 | padding-bottom: 5px; 7 | padding-right: 5px; 8 | 9 | &_size_xl { 10 | font-size: 32px; 11 | line-height: 40px; 12 | } 13 | 14 | &_size_l { 15 | font-size: 24px; 16 | line-height: 28px; 17 | } 18 | 19 | &_size_m { 20 | font-size: 20px; 21 | line-height: 24px; 22 | } 23 | 24 | &_size_s { 25 | font-size: var(--g-text-body-3-font-size); 26 | line-height: 24px; 27 | } 28 | 29 | &_size_xs { 30 | font-size: 15px; 31 | line-height: 20px; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/plugins/Title/Title.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {Plugin, PluginWidgetProps} from '../../typings'; 4 | import {cn} from '../../utils/cn'; 5 | import {PLUGIN_ROOT_ATTR_NAME} from '../constants'; 6 | 7 | import {RECCOMMENDED_LINE_HEIGHT_MULTIPLIER} from './constants'; 8 | import type {PluginTitleSize, TitleFontParams} from './types'; 9 | import {isCustomSize} from './utils'; 10 | 11 | import './Title.scss'; 12 | 13 | export interface PluginTitleProps extends PluginWidgetProps { 14 | data: { 15 | size: PluginTitleSize | TitleFontParams; 16 | text: string; 17 | showInTOC: boolean; 18 | } & PluginWidgetProps['data']; 19 | } 20 | 21 | const b = cn('dashkit-plugin-title'); 22 | 23 | export class PluginTitle extends React.Component { 24 | render() { 25 | const {data} = this.props; 26 | const text = data.text ? data.text : ''; 27 | 28 | const size = isCustomSize(data.size) ? false : data.size; 29 | const styles = 30 | isCustomSize(data.size) && data.size?.fontSize 31 | ? { 32 | fontSize: data.size.fontSize, 33 | lineHeight: data.size.lineHeight ?? RECCOMMENDED_LINE_HEIGHT_MULTIPLIER, 34 | } 35 | : undefined; 36 | 37 | const id = data.showInTOC && text ? encodeURIComponent(text) : undefined; 38 | 39 | return ( 40 |
46 | {text} 47 |
48 | ); 49 | } 50 | } 51 | 52 | const plugin: Plugin = { 53 | type: 'title', 54 | defaultLayout: {w: 36, h: 2}, 55 | renderer(props, forwardedRef) { 56 | return ; 57 | }, 58 | }; 59 | 60 | export default plugin; 61 | -------------------------------------------------------------------------------- /src/plugins/Title/__stories__/Title.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {Card, Flex} from '@gravity-ui/uikit'; 4 | import {Meta, StoryObj} from '@storybook/react'; 5 | 6 | import {PluginTitle} from '../Title'; 7 | import {PluginTitleSize} from '../types'; 8 | 9 | export default { 10 | title: 'Components/Title', 11 | component: PluginTitle, 12 | } as Meta; 13 | 14 | type Story = StoryObj; 15 | 16 | const sizes: PluginTitleSize[] = ['xs', 's', 'm', 'l', 'xl']; 17 | 18 | const defaultDataParams = {showInTOC: true}; 19 | 20 | export const PresetSizes: Story = { 21 | render: ({data: _, ...args}) => ( 22 | 23 | {sizes.map((size) => ( 24 | 25 | 29 | 30 | ))} 31 | 32 | ), 33 | }; 34 | 35 | export const CustomSize: Story = { 36 | render: ({data: _, ...args}) => ( 37 | 38 | 39 | 47 | 48 | 49 | 57 | 58 | 59 | 67 | 68 | 69 | 77 | 78 | 79 | 87 | 88 | 89 | 97 | 98 | 99 | 107 | 108 | 109 | 117 | 118 | 119 | 127 | 128 | 129 | ), 130 | }; 131 | 132 | export const Default: Story = { 133 | render: ({data, ...args}) => ( 134 | 135 | 136 | 137 | ), 138 | args: { 139 | data: { 140 | ...defaultDataParams, 141 | size: 'm', 142 | text: `Title`, 143 | }, 144 | }, 145 | }; 146 | -------------------------------------------------------------------------------- /src/plugins/Title/constants.ts: -------------------------------------------------------------------------------- 1 | import {PluginTitleSize, TitleFontParams} from './types'; 2 | 3 | export const TITLE_DEFAULT_SIZES: Record = { 4 | xl: { 5 | fontSize: '32px', 6 | lineHeight: '40px', 7 | }, 8 | l: { 9 | fontSize: '24px', 10 | lineHeight: '28px', 11 | }, 12 | m: { 13 | fontSize: '20px', 14 | lineHeight: '24px', 15 | }, 16 | s: { 17 | fontSize: '17px', 18 | lineHeight: '24px', 19 | }, 20 | xs: { 21 | fontSize: '15px', 22 | lineHeight: '20px', 23 | }, 24 | }; 25 | 26 | export const RECCOMMENDED_LINE_HEIGHT_MULTIPLIER = 1.1; 27 | -------------------------------------------------------------------------------- /src/plugins/Title/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Title'; 2 | export {default as pluginTitle} from './Title'; 3 | export {PluginTitleSize, TitleFontParams} from './types'; 4 | export * from './constants'; 5 | -------------------------------------------------------------------------------- /src/plugins/Title/types.ts: -------------------------------------------------------------------------------- 1 | export type PluginTitleSize = 'xl' | 'l' | 'm' | 's' | 'xs'; 2 | 3 | export interface TitleFontParams { 4 | fontSize: string; 5 | lineHeight?: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/plugins/Title/utils.ts: -------------------------------------------------------------------------------- 1 | import type {PluginTitleSize, TitleFontParams} from './types'; 2 | 3 | export function isCustomSize(size: PluginTitleSize | TitleFontParams): size is TitleFontParams { 4 | return typeof size === 'object'; 5 | } 6 | -------------------------------------------------------------------------------- /src/plugins/constants.ts: -------------------------------------------------------------------------------- 1 | export const PLUGIN_ROOT_ATTR_NAME = 'data-plugin-root-el'; 2 | -------------------------------------------------------------------------------- /src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Text'; 2 | export * from './Title'; 3 | export * from './constants'; 4 | -------------------------------------------------------------------------------- /src/shared/constants/common.ts: -------------------------------------------------------------------------------- 1 | export const CURRENT_VERSION = 2; 2 | 3 | export const META_KEY = '__meta__'; 4 | 5 | export const ACTION_PARAM_PREFIX = '_ap_'; 6 | 7 | export const TERMORARY_ITEM_ID = '__dropping-elem__'; 8 | -------------------------------------------------------------------------------- /src/shared/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common'; 2 | -------------------------------------------------------------------------------- /src/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './constants'; 2 | export * from './types'; 3 | export * from './modules'; 4 | export * from './units'; 5 | -------------------------------------------------------------------------------- /src/shared/modules/__tests__/uniq-id.test.ts: -------------------------------------------------------------------------------- 1 | import Hashids from 'hashids'; 2 | 3 | import type {Config} from '../../types'; 4 | import {extractIdsFromConfig, generateUniqId} from '../uniq-id'; 5 | 6 | const salt = 'salt'; 7 | const hashids = new Hashids(salt); 8 | const testCounter = 1; 9 | 10 | const id1 = hashids.encode(testCounter + 1); 11 | const id2 = hashids.encode(testCounter + 2); 12 | const id3 = hashids.encode(testCounter + 3); 13 | const id4 = hashids.encode(testCounter + 4); 14 | 15 | describe('modules.uniq-id: generateUniqId', () => { 16 | it('return correct first id and counter', () => { 17 | const {id, counter} = generateUniqId({ 18 | counter: testCounter, 19 | ids: [], 20 | salt, 21 | }); 22 | 23 | expect(hashids.encode(counter)).toEqual(id1); 24 | 25 | expect(id).toEqual(id1); 26 | expect(counter).toEqual(testCounter + 1); 27 | }); 28 | 29 | it('return correct id and counter with ids=[id1]', () => { 30 | const {id, counter} = generateUniqId({ 31 | counter: testCounter + 1, 32 | ids: [id1], 33 | salt, 34 | }); 35 | 36 | expect(hashids.encode(counter)).toEqual(id2); 37 | 38 | expect(id).toEqual(id2); 39 | expect(counter).toEqual(testCounter + 2); 40 | }); 41 | 42 | it('return correct id4 and counter with ids=[id1, id2, id3] and incorrect counter=1', () => { 43 | const {id, counter} = generateUniqId({ 44 | counter: 1, 45 | ids: [id1, id2, id3], 46 | salt, 47 | }); 48 | 49 | expect(hashids.encode(counter)).toEqual(id4); 50 | 51 | expect(id).toEqual(id4); 52 | expect(counter).toEqual(testCounter + 4); 53 | }); 54 | }); 55 | 56 | const IDS = ['WZ', '9X', '2m', 'R2', 'Pd']; 57 | const config = { 58 | items: [ 59 | { 60 | id: IDS[0], 61 | data: { 62 | tabs: [ 63 | { 64 | id: IDS[1], 65 | }, 66 | { 67 | id: IDS[2], 68 | }, 69 | ], 70 | }, 71 | }, 72 | { 73 | id: IDS[3], 74 | }, 75 | ], 76 | layout: [{i: IDS[0]}, {i: IDS[3]}], 77 | connections: [ 78 | {from: IDS[2], to: IDS[4]}, 79 | {from: IDS[1], to: IDS[3]}, 80 | ], 81 | } as Config; 82 | 83 | describe('modules.uniq-id: extractIdsFromConfig', () => { 84 | it('correct extract ids from config', () => { 85 | const ids = extractIdsFromConfig(config); 86 | 87 | expect(Array.isArray(ids)).toBe(true); 88 | 89 | expect(IDS.concat().sort()).toEqual(ids.sort()); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/shared/modules/helpers.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import invert from 'lodash/invert'; 3 | import isEmpty from 'lodash/isEmpty'; 4 | import keyBy from 'lodash/keyBy'; 5 | import pick from 'lodash/pick'; 6 | 7 | import {ACTION_PARAM_PREFIX, CURRENT_VERSION, META_KEY} from '../constants'; 8 | import { 9 | Config, 10 | ConfigAliases, 11 | ConfigConnection, 12 | ConfigItem, 13 | ConfigItemGroup, 14 | ConfigItemWithGroup, 15 | ConfigItemWithTabs, 16 | ItemStateAndParams, 17 | ItemsStateAndParams, 18 | ItemsStateAndParamsBase, 19 | PluginBase, 20 | QueueItem, 21 | StateAndParamsMetaData, 22 | StringParams, 23 | } from '../types'; 24 | 25 | function getNormalizedPlugins(plugins: PluginBase[]) { 26 | return keyBy(plugins, 'type'); 27 | } 28 | 29 | export function prerenderItems({ 30 | items, 31 | plugins, 32 | }: { 33 | items: ConfigItem[]; 34 | plugins: PluginBase[]; 35 | }): ConfigItem[] { 36 | const normalizedPlugins = getNormalizedPlugins(plugins); 37 | return items.map((item) => { 38 | const {type} = item; 39 | const plugin = normalizedPlugins[type]; 40 | return typeof plugin.prerenderMiddleware === 'function' 41 | ? plugin.prerenderMiddleware(item) 42 | : item; 43 | }); 44 | } 45 | 46 | export function getItemsStateAndParamsMeta(itemsStateAndParams: ItemsStateAndParams) { 47 | const meta = itemsStateAndParams?.[META_KEY] as StateAndParamsMetaData | undefined; 48 | return meta; 49 | } 50 | 51 | export function getCurrentVersion(itemsStateAndParams: ItemsStateAndParams): number { 52 | if (isEmpty(itemsStateAndParams)) { 53 | return CURRENT_VERSION; 54 | } 55 | const meta = getItemsStateAndParamsMeta(itemsStateAndParams); 56 | if (!meta) { 57 | const withParams = Object.keys(itemsStateAndParams).some((id) => { 58 | return Boolean((itemsStateAndParams as ItemsStateAndParamsBase)[id].params); 59 | }); 60 | if (withParams) { 61 | return 1; 62 | } 63 | return CURRENT_VERSION; 64 | } 65 | return meta.version; 66 | } 67 | 68 | function isConfigData( 69 | item: Pick | ConfigItemGroup, 70 | ): item is Pick { 71 | return 'data' in item && 'type' in item; 72 | } 73 | 74 | export function isItemWithTabs( 75 | item: Pick | ConfigItemGroup, 76 | ): item is Pick { 77 | return isConfigData(item) && Array.isArray(item?.data?.tabs); 78 | } 79 | 80 | export function isItemWithGroup( 81 | item: Pick, 82 | ): item is Pick { 83 | return Array.isArray(item?.data?.group); 84 | } 85 | 86 | export type FormedQueueData = { 87 | id: string; 88 | namespace: string; 89 | params: StringParams; 90 | }; 91 | 92 | // Array of parameters from widgets according to queue 93 | export function formQueueData({ 94 | items, 95 | itemsStateAndParams, 96 | }: { 97 | items: ConfigItem[]; 98 | itemsStateAndParams: ItemsStateAndParams; 99 | }): FormedQueueData[] { 100 | const queue = getItemsStateAndParamsMeta(itemsStateAndParams)?.queue || []; 101 | const keyById = keyBy(items, 'id'); 102 | return queue.reduce((queueArray: FormedQueueData[], queueItem: QueueItem) => { 103 | const {id: queueId, tabId, groupItemId} = queueItem; 104 | const item = keyById[queueId]; 105 | const isGroup = isItemWithGroup(item); 106 | if (!item || (isGroup && !groupItemId)) { 107 | return queueArray; 108 | } 109 | 110 | if (isGroup && groupItemId) { 111 | const itemQueueParams: Record = get( 112 | itemsStateAndParams, 113 | [item.id, 'params'], 114 | {}, 115 | ); 116 | 117 | const groupItem = item.data.group.find(({id}) => id === groupItemId); 118 | if (!groupItem) { 119 | return queueArray; 120 | } 121 | const groupItemQueueParams = itemQueueParams[groupItemId]; 122 | const filteredParamsByDefaults = pick( 123 | groupItemQueueParams, 124 | Object.keys(groupItem.defaults || {}), 125 | ); 126 | 127 | /** 128 | * merging filtered params and filtered actionParams with prefixes 129 | */ 130 | const params = { 131 | ...filteredParamsByDefaults, 132 | ...(pickActionParamsFromParams(groupItemQueueParams, true) || {}), 133 | }; 134 | 135 | queueArray.push({ 136 | id: groupItem.id, 137 | namespace: groupItem.namespace, 138 | params, 139 | }); 140 | 141 | return queueArray; 142 | } 143 | 144 | const itemQueueParams: StringParams = get(itemsStateAndParams, [item.id, 'params'], {}); 145 | 146 | let itemDefaultParams: StringParams; 147 | if (isItemWithTabs(item)) { 148 | if (!tabId || resolveItemInnerId({item, itemsStateAndParams}) !== tabId) { 149 | return queueArray; 150 | } 151 | itemDefaultParams = 152 | item.data.tabs.find((tabData) => tabData.id === tabId)?.params || {}; 153 | } else { 154 | itemDefaultParams = item.defaults || {}; 155 | } 156 | 157 | const filteredParamsByDefaults = pick(itemQueueParams, Object.keys(itemDefaultParams)); 158 | 159 | /** 160 | * merging filtered params and filtered actionParams with prefixes 161 | */ 162 | const params = { 163 | ...filteredParamsByDefaults, 164 | ...(pickActionParamsFromParams(itemQueueParams, true) || {}), 165 | }; 166 | 167 | queueArray.push({ 168 | id: item.id, 169 | namespace: item.namespace, 170 | params, 171 | }); 172 | 173 | return queueArray; 174 | }, []); 175 | } 176 | 177 | export function resolveItemInnerId({ 178 | item, 179 | itemsStateAndParams, 180 | }: { 181 | item: Pick; 182 | itemsStateAndParams?: ItemsStateAndParams; 183 | }): string { 184 | const {id} = item; 185 | const stateTabId: string | undefined = (itemsStateAndParams as ItemsStateAndParamsBase)?.[id] 186 | ?.state?.tabId; 187 | 188 | const {tabs} = (item as ConfigItemWithTabs).data; 189 | if (stateTabId && tabs.some((tab) => tab.id === stateTabId)) { 190 | return stateTabId; 191 | } 192 | const indexIsDefault = tabs.findIndex(({isDefault}) => isDefault); 193 | return indexIsDefault === -1 ? tabs[0].id : tabs[indexIsDefault].id; 194 | } 195 | 196 | // В config.connections в from/to может быть как id item'a (item.id), так и id таба (item.data.tabs[].id) 197 | // Тут мы нормализуем к виду Record 198 | export function getMapItemsIgnores({ 199 | items, 200 | ignores, 201 | itemsStateAndParams, 202 | isFirstVersion, 203 | }: { 204 | items: (ConfigItem | ConfigItemGroup)[]; 205 | ignores: ConfigConnection[]; 206 | itemsStateAndParams: ItemsStateAndParams; 207 | isFirstVersion: boolean; 208 | }): Record { 209 | // Record 210 | const mapIds = items.reduce((acc: Record, item) => { 211 | return { 212 | ...acc, 213 | [item.id]: isItemWithTabs(item) 214 | ? resolveItemInnerId({item, itemsStateAndParams}) 215 | : item.id, 216 | }; 217 | }, {}); 218 | // Record 219 | const invertedMapIds = invert(mapIds); 220 | return items.reduce((acc: Record, item) => { 221 | return { 222 | ...acc, 223 | [item.id]: ignores 224 | .filter(({from, to}) => { 225 | if (isFirstVersion) { 226 | // В первой версии был баг - если есть игнор на один таб, то весь виджет игнорит селектор. 227 | // Повторяем это неправильное поведение, 228 | // иначе будут прилетать дефолты селекта в табы, которые не игнорят. 229 | const fromInTabs = 230 | isItemWithTabs(item) && item.data.tabs.some(({id}) => id === from); 231 | return (from === item.id || fromInTabs) && invertedMapIds[to]; 232 | } 233 | return from === mapIds[item.id] && invertedMapIds[to]; 234 | }) 235 | .map(({to}) => invertedMapIds[to]), 236 | }; 237 | }, {}); 238 | } 239 | 240 | export function mergeParamsWithAliases({ 241 | aliases, 242 | namespace, 243 | params, 244 | actionParams, 245 | }: { 246 | aliases: ConfigAliases; 247 | namespace: string; 248 | params: StringParams; 249 | actionParams?: StringParams; 250 | }): StringParams { 251 | const aliasesByNamespace = get(aliases, [namespace], []) as string[][]; 252 | const items = { 253 | ...(params || {}), 254 | ...(actionParams || {}), 255 | }; 256 | return Object.keys(items).reduce((matchedParams: StringParams, paramKey) => { 257 | const paramValue = items[paramKey]; 258 | const collectAliasesParamsKeys = aliasesByNamespace.reduce( 259 | (collect, group) => { 260 | return group.includes(paramKey) ? collect.concat(group) : collect; 261 | }, 262 | [paramKey], 263 | ); 264 | return { 265 | ...matchedParams, 266 | ...collectAliasesParamsKeys.reduce((acc: StringParams, matchedKey) => { 267 | return {...acc, [matchedKey]: paramValue}; 268 | }, {}), 269 | }; 270 | }, {}); 271 | } 272 | 273 | export function getInitialItemsStateAndParamsMeta(): StateAndParamsMetaData { 274 | return { 275 | queue: [], 276 | version: CURRENT_VERSION, 277 | }; 278 | } 279 | 280 | interface ChangeQueueArg { 281 | id: string; 282 | tabId?: string; 283 | groupItemId?: string; 284 | config: Config; 285 | itemsStateAndParams: ItemsStateAndParams; 286 | } 287 | 288 | interface ChangeQueueGroupArg { 289 | id: string; 290 | groupItemIds: string[]; 291 | config: Config; 292 | itemsStateAndParams: ItemsStateAndParams; 293 | } 294 | 295 | function getActualItemsIds(items: ConfigItem[]) { 296 | return items.reduce((ids: string[], item) => { 297 | if (isItemWithGroup(item)) { 298 | item.data.group.forEach((groupItem) => { 299 | ids.push(groupItem.id); 300 | }); 301 | } 302 | 303 | ids.push(item.id); 304 | return ids; 305 | }, []); 306 | } 307 | 308 | export function addToQueue({ 309 | id, 310 | tabId, 311 | config, 312 | itemsStateAndParams, 313 | }: ChangeQueueArg): StateAndParamsMetaData { 314 | const queueItem: QueueItem = {id}; 315 | if (tabId) { 316 | queueItem.tabId = tabId; 317 | } 318 | const meta = getItemsStateAndParamsMeta(itemsStateAndParams); 319 | if (!meta) { 320 | return {queue: [queueItem], version: CURRENT_VERSION}; 321 | } 322 | const actualIds = getActualItemsIds(config.items); 323 | const metaQueue = meta.queue || []; 324 | const notCurrent = (item: QueueItem) => { 325 | if (item.groupItemId) { 326 | return actualIds.includes(item.groupItemId); 327 | } 328 | return item.id !== id; 329 | }; 330 | return { 331 | queue: metaQueue 332 | .filter((item) => actualIds.includes(item.id) && notCurrent(item)) 333 | .concat(queueItem), 334 | version: meta.version || CURRENT_VERSION, 335 | }; 336 | } 337 | 338 | export function addGroupToQueue({ 339 | id, 340 | groupItemIds, 341 | config, 342 | itemsStateAndParams, 343 | }: ChangeQueueGroupArg): StateAndParamsMetaData { 344 | const queueItems: QueueItem[] = groupItemIds.map((groupItemId) => ({ 345 | id, 346 | groupItemId, 347 | })); 348 | const meta = getItemsStateAndParamsMeta(itemsStateAndParams); 349 | if (!meta) { 350 | return {queue: queueItems, version: CURRENT_VERSION}; 351 | } 352 | const actualIds = getActualItemsIds(config.items); 353 | const metaQueue = meta.queue || []; 354 | const notCurrent = (item: QueueItem) => { 355 | if (item.groupItemId) { 356 | return actualIds.includes(item.groupItemId) && !groupItemIds.includes(item.groupItemId); 357 | } 358 | return true; 359 | }; 360 | return { 361 | queue: metaQueue 362 | .filter((item) => actualIds.includes(item.id) && notCurrent(item)) 363 | .concat(queueItems), 364 | version: meta.version || CURRENT_VERSION, 365 | }; 366 | } 367 | 368 | export function deleteFromQueue(data: ChangeQueueArg): StateAndParamsMetaData { 369 | const meta = addToQueue(data); 370 | return { 371 | ...meta, 372 | queue: meta.queue.slice(0, -1), 373 | }; 374 | } 375 | 376 | /** 377 | * public function for getting only actionParams from object (all fields with keys that contains prefix) 378 | * @param params - object for pick fields 379 | * @param returnWithPrefix - format of returning actionParams fields (with actionParams prefix or without them) 380 | * 381 | * ex1: pickActionParamsFromParams({City: 'NY', _ap_Year: '2023'}, true) returns {_ap_Year: '2023'} 382 | * ex2: pickActionParamsFromParams({City: 'NY', _ap_Year: '2023'}) returns {Year: '2023'} 383 | */ 384 | export function pickActionParamsFromParams( 385 | params: ItemStateAndParams['params'], 386 | returnWithPrefix?: boolean, 387 | ) { 388 | if (!params || isEmpty(params)) { 389 | return {}; 390 | } 391 | 392 | const actionParams: StringParams = {}; 393 | for (const [key, val] of Object.entries(params)) { 394 | // starts with actionParams prefix (from'_ap_') 395 | if (key.startsWith(ACTION_PARAM_PREFIX)) { 396 | const paramName = returnWithPrefix ? key : key.slice(ACTION_PARAM_PREFIX.length); 397 | actionParams[paramName] = val; 398 | } 399 | } 400 | return actionParams; 401 | } 402 | 403 | /** 404 | * public function for getting params from object without actionParams 405 | * @param params 406 | */ 407 | export function pickExceptActionParamsFromParams(params: ItemStateAndParams['params']) { 408 | if (!params || isEmpty(params)) { 409 | return {}; 410 | } 411 | 412 | const onlyParams: StringParams = {}; 413 | for (const [key, val] of Object.entries(params)) { 414 | if (!key.startsWith(ACTION_PARAM_PREFIX)) { 415 | onlyParams[key] = val; 416 | } 417 | } 418 | return onlyParams; 419 | } 420 | 421 | /** 422 | * public function for transforming object to actionParams format 423 | * @param params 424 | */ 425 | export function transformParamsToActionParams(params: ItemStateAndParams['params']) { 426 | if (!params || isEmpty(params)) { 427 | return {}; 428 | } 429 | 430 | const actionParams: StringParams = {}; 431 | for (const [key, val] of Object.entries(params)) { 432 | actionParams[`${ACTION_PARAM_PREFIX}${key}`] = val; 433 | } 434 | return actionParams; 435 | } 436 | 437 | /** 438 | * check if object contains actionParams 439 | * @param conf 440 | */ 441 | export function hasActionParam(conf?: StringParams | Record): boolean { 442 | return Object.keys(conf || {}).some((key) => { 443 | if (conf && typeof conf[key] === 'object' && !Array.isArray(conf[key])) { 444 | return Object.keys(conf[key]).some((subkey) => subkey.startsWith(ACTION_PARAM_PREFIX)); 445 | } 446 | return key.startsWith(ACTION_PARAM_PREFIX); 447 | }); 448 | } 449 | 450 | /** 451 | * check if ItemStateAndParams object has actionParams in params or state field 452 | * @param stateAndParams 453 | */ 454 | export function hasActionParams(stateAndParams: ItemStateAndParams) { 455 | if (!stateAndParams || isEmpty(stateAndParams)) { 456 | return false; 457 | } 458 | 459 | return hasActionParam(stateAndParams.params); 460 | } 461 | -------------------------------------------------------------------------------- /src/shared/modules/index.ts: -------------------------------------------------------------------------------- 1 | export * from './helpers'; 2 | export * from './state-and-params'; 3 | export * from './uniq-id'; 4 | -------------------------------------------------------------------------------- /src/shared/modules/state-and-params.ts: -------------------------------------------------------------------------------- 1 | import groupBy from 'lodash/groupBy'; 2 | 3 | import {META_KEY} from '../constants'; 4 | import { 5 | Config, 6 | ConfigItem, 7 | ConfigItemDataWithTabs, 8 | ConfigItemGroup, 9 | GlobalParams, 10 | ItemState, 11 | ItemStateAndParams, 12 | ItemsStateAndParams, 13 | ItemsStateAndParamsBase, 14 | PluginBase, 15 | StateAndParamsMetaData, 16 | StringParams, 17 | } from '../types'; 18 | 19 | import { 20 | FormedQueueData, 21 | formQueueData, 22 | getCurrentVersion, 23 | getMapItemsIgnores, 24 | hasActionParam, 25 | isItemWithGroup, 26 | isItemWithTabs, 27 | mergeParamsWithAliases, 28 | pickActionParamsFromParams, 29 | pickExceptActionParamsFromParams, 30 | prerenderItems, 31 | resolveItemInnerId, 32 | } from './helpers'; 33 | 34 | export interface GetItemsParamsArg { 35 | defaultGlobalParams: GlobalParams; 36 | globalParams: GlobalParams; 37 | config: Config; 38 | itemsStateAndParams: ItemsStateAndParams; 39 | plugins: PluginBase[]; 40 | useStateAsInitial?: boolean; 41 | } 42 | 43 | type GetItemsParamsReturn = Record>; 44 | 45 | const getParamsFromStateAndParams = ({ 46 | itemsStateAndParams, 47 | parentItemId, 48 | item, 49 | }: { 50 | item: ConfigItem | ConfigItemGroup; 51 | itemsStateAndParams: ItemsStateAndParams; 52 | parentItemId?: string; 53 | }) => { 54 | const widgetId = parentItemId || item.id; 55 | 56 | if (!(widgetId in itemsStateAndParams)) { 57 | return {}; 58 | } 59 | 60 | const stateAndParams = itemsStateAndParams as ItemsStateAndParamsBase; 61 | const itemParams = parentItemId 62 | ? Object.assign( 63 | {}, 64 | (stateAndParams[widgetId].params as Record)[item.id], 65 | ) 66 | : Object.assign({}, stateAndParams[widgetId].params as StringParams); 67 | 68 | return itemParams; 69 | }; 70 | 71 | function getItemParams({ 72 | item, 73 | itemsStateAndParams, 74 | mapItemsIgnores, 75 | itemsWithDefaultsByNamespace, 76 | getMergedParams, 77 | defaultGlobalParams, 78 | globalParams, 79 | isFirstVersion, 80 | queueData, 81 | parentItemId, 82 | useStateAsInitial, 83 | }: { 84 | item: ConfigItem | ConfigItemGroup; 85 | itemsStateAndParams: ItemsStateAndParams; 86 | mapItemsIgnores: Record; 87 | itemsWithDefaultsByNamespace: Record; 88 | getMergedParams: (params: StringParams, actionParams?: StringParams) => StringParams; 89 | defaultGlobalParams: StringParams; 90 | globalParams: GlobalParams; 91 | isFirstVersion: boolean; 92 | queueData: FormedQueueData[]; 93 | parentItemId?: string; 94 | useStateAsInitial?: boolean; 95 | }) { 96 | const {id, namespace} = item; 97 | 98 | let defaultWidgetParams: StringParams | Record = {}; 99 | if (isItemWithTabs(item)) { 100 | const currentWidgetTabId = resolveItemInnerId({item, itemsStateAndParams}); 101 | const itemTabs: ConfigItemDataWithTabs['tabs'] = item.data.tabs; 102 | defaultWidgetParams = 103 | itemTabs.find((tabItem) => tabItem?.id === currentWidgetTabId)?.params || {}; 104 | } else { 105 | defaultWidgetParams = item.defaults || {}; 106 | } 107 | 108 | const itemIgnores = mapItemsIgnores[id]; 109 | 110 | const affectingItemsWithDefaults = itemsWithDefaultsByNamespace[namespace].filter( 111 | (itemWithDefaults) => !itemIgnores.includes(itemWithDefaults.id), 112 | ); 113 | 114 | let itemParams: StringParams = Object.assign( 115 | {}, 116 | getMergedParams(defaultGlobalParams), 117 | // default parameters to begin with 118 | affectingItemsWithDefaults.reduceRight((defaultParams: StringParams, itemWithDefaults) => { 119 | return { 120 | ...defaultParams, 121 | ...getMergedParams(itemWithDefaults.defaults || {}), 122 | }; 123 | }, {}), 124 | getMergedParams(globalParams), 125 | useStateAsInitial 126 | ? getParamsFromStateAndParams({parentItemId, item, itemsStateAndParams}) 127 | : {}, 128 | ); 129 | 130 | if (isFirstVersion) { 131 | itemParams = Object.assign( 132 | itemParams, 133 | (itemsStateAndParams as ItemsStateAndParamsBase)?.[id]?.params || {}, 134 | ); 135 | } else { 136 | // params according to queue of its applying 137 | let queueDataItemsParams: StringParams = {}; 138 | for (const data of queueData) { 139 | if (data.namespace !== namespace || itemIgnores.includes(data.id)) { 140 | continue; 141 | } 142 | 143 | let actionParams; 144 | let params = data.params; 145 | const needAliasesForActionParams = data.id !== id && hasActionParam(data.params); 146 | if (needAliasesForActionParams) { 147 | actionParams = pickActionParamsFromParams(data.params); 148 | params = pickExceptActionParamsFromParams(data.params); 149 | } 150 | 151 | const mergedParams = getMergedParams(params, actionParams); 152 | 153 | queueDataItemsParams = { 154 | ...queueDataItemsParams, 155 | ...mergedParams, 156 | }; 157 | } 158 | 159 | itemParams = Object.assign(itemParams, queueDataItemsParams); 160 | } 161 | 162 | return {...defaultWidgetParams, ...itemParams}; 163 | } 164 | 165 | export function getItemsParams({ 166 | defaultGlobalParams = {}, 167 | globalParams = {}, 168 | config, 169 | itemsStateAndParams, 170 | plugins, 171 | useStateAsInitial, 172 | }: GetItemsParamsArg): GetItemsParamsReturn { 173 | const {aliases, connections} = config; 174 | const items = prerenderItems({items: config.items, plugins}); 175 | const isFirstVersion = getCurrentVersion(itemsStateAndParams) === 1; 176 | 177 | const allItems = items.reduce((paramsItems: (ConfigItem | ConfigItemGroup)[], item) => { 178 | if (isItemWithGroup(item)) { 179 | item.data.group.forEach((groupItem) => { 180 | paramsItems.push(groupItem); 181 | }); 182 | 183 | return paramsItems; 184 | } 185 | 186 | paramsItems.push(item); 187 | return paramsItems; 188 | }, []); 189 | 190 | const queueData: FormedQueueData[] = isFirstVersion 191 | ? [] 192 | : formQueueData({items, itemsStateAndParams}); 193 | 194 | // to consider other kind types in future (not only ignore) 195 | const mapItemsIgnores = getMapItemsIgnores({ 196 | items: allItems, 197 | ignores: connections.filter(({kind}) => kind === 'ignore'), 198 | itemsStateAndParams, 199 | isFirstVersion, 200 | }); 201 | const groupByNamespace = groupBy(allItems, 'namespace'); 202 | const itemsWithDefaultsByNamespace = Object.keys(groupByNamespace).reduce( 203 | (acc, namespace) => { 204 | return { 205 | ...acc, 206 | // there are defaults only in selectors by now, need to get them from item.data.tabs[].defaults for widgets 207 | // but make a decision about there's order first 208 | [namespace]: groupByNamespace[namespace].filter((item) => item.defaults), 209 | }; 210 | }, 211 | {} as Record, 212 | ); 213 | 214 | return items.reduce((itemsParams: GetItemsParamsReturn, item: ConfigItem) => { 215 | const {id, namespace} = item; 216 | 217 | const getMergedParams = (params: StringParams, actionParams?: StringParams) => 218 | mergeParamsWithAliases({aliases, namespace, params: params || {}, actionParams}); 219 | 220 | const paramsOptions = { 221 | itemsStateAndParams, 222 | mapItemsIgnores, 223 | itemsWithDefaultsByNamespace, 224 | getMergedParams, 225 | defaultGlobalParams, 226 | globalParams, 227 | isFirstVersion, 228 | queueData, 229 | useStateAsInitial, 230 | }; 231 | 232 | if (isItemWithGroup(item)) { 233 | const groupParams = item.data.group.reduce( 234 | (groupItemParams: Record, groupItem) => { 235 | groupItemParams[groupItem.id] = getItemParams({ 236 | item: groupItem, 237 | parentItemId: item.id, 238 | ...paramsOptions, 239 | }); 240 | return groupItemParams; 241 | }, 242 | {}, 243 | ); 244 | 245 | return {...itemsParams, [id]: groupParams}; 246 | } 247 | 248 | return { 249 | ...itemsParams, 250 | [id]: getItemParams({ 251 | item, 252 | ...paramsOptions, 253 | }), 254 | }; 255 | }, {}); 256 | } 257 | 258 | export function getItemsState({ 259 | config, 260 | itemsStateAndParams, 261 | }: { 262 | config: Config; 263 | itemsStateAndParams: ItemsStateAndParams; 264 | }) { 265 | return config.items.reduce((acc: Record, {id}) => { 266 | acc[id] = (itemsStateAndParams as ItemsStateAndParamsBase)?.[id]?.state || {}; 267 | return acc; 268 | }, {}); 269 | } 270 | 271 | export function getItemsStateAndParams({ 272 | defaultGlobalParams = {}, 273 | globalParams = {}, 274 | config, 275 | itemsStateAndParams, 276 | plugins, 277 | }: GetItemsParamsArg): ItemsStateAndParams { 278 | const params = getItemsParams({ 279 | defaultGlobalParams, 280 | globalParams, 281 | config, 282 | itemsStateAndParams, 283 | plugins, 284 | }); 285 | const state = getItemsState({config, itemsStateAndParams}); 286 | const uniqIds = new Set([...Object.keys(params), ...Object.keys(state)]); 287 | 288 | const result: ItemsStateAndParams = Array.from(uniqIds).reduce( 289 | (acc: ItemsStateAndParams, id) => { 290 | const data = {} as ItemStateAndParams; 291 | if (id in params) { 292 | data.params = params[id]; 293 | } 294 | if (id in state) { 295 | data.state = state[id]; 296 | } 297 | return { 298 | ...acc, 299 | [id]: data, 300 | }; 301 | }, 302 | {}, 303 | ); 304 | const version = getCurrentVersion(itemsStateAndParams); 305 | if (version === 1) { 306 | return result; 307 | } 308 | const meta = { 309 | [META_KEY]: itemsStateAndParams[META_KEY] as StateAndParamsMetaData, 310 | }; 311 | return { 312 | ...meta, 313 | ...result, 314 | }; 315 | } 316 | -------------------------------------------------------------------------------- /src/shared/modules/uniq-id.ts: -------------------------------------------------------------------------------- 1 | import Hashids from 'hashids'; 2 | 3 | import type {Config} from '../types'; 4 | 5 | import {isItemWithGroup, isItemWithTabs} from './helpers'; 6 | 7 | export function extractIdsFromConfig(config: Config): string[] { 8 | const ids: string[] = []; 9 | 10 | const items = config.items || []; 11 | const connections = config.connections || []; 12 | const layout = config.layout || []; 13 | 14 | items.forEach((item) => { 15 | ids.push(item.id); 16 | if (isItemWithTabs(item)) { 17 | item.data.tabs.forEach((tabItem) => ids.push(tabItem.id)); 18 | } 19 | if (isItemWithGroup(item)) { 20 | item.data.group.forEach((groupItem) => ids.push(groupItem.id)); 21 | } 22 | }); 23 | connections.forEach(({from, to}) => ids.push(from, to)); 24 | layout.forEach(({i}) => ids.push(i)); 25 | 26 | return Array.from(new Set(ids)); 27 | } 28 | 29 | type GenerateUniqIdArgs = { 30 | salt: Config['salt']; 31 | counter: Config['counter']; 32 | ids: string[]; 33 | }; 34 | 35 | export function generateUniqId({salt, counter, ids}: GenerateUniqIdArgs) { 36 | let newCounter = counter; 37 | let uniqId: string | null = null; 38 | 39 | const idsSet = new Set(ids); 40 | const hashids = new Hashids(salt); 41 | 42 | while (uniqId === null) { 43 | const id = hashids.encode(++newCounter); 44 | if (!idsSet.has(id)) { 45 | uniqId = id; 46 | } 47 | } 48 | 49 | return {counter: newCounter, id: uniqId}; 50 | } 51 | -------------------------------------------------------------------------------- /src/shared/types/common.ts: -------------------------------------------------------------------------------- 1 | import {ConfigLayout} from './config'; 2 | 3 | export type Dictionary = Record; 4 | 5 | export interface StringParams extends Dictionary {} 6 | 7 | export interface GlobalParams extends StringParams {} 8 | 9 | export type ItemDragProps = { 10 | type: string; 11 | layout?: { 12 | w?: number; 13 | h?: number; 14 | }; 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | extra?: any; 17 | }; 18 | 19 | export type DraggedOverItem = Omit & { 20 | type: string; 21 | i?: ConfigLayout['i']; 22 | }; 23 | 24 | export type ItemDropProps = { 25 | commit: () => void; 26 | dragProps: ItemDragProps; 27 | itemLayout: ConfigLayout; 28 | newLayout: ConfigLayout[]; 29 | }; 30 | -------------------------------------------------------------------------------- /src/shared/types/config.ts: -------------------------------------------------------------------------------- 1 | import {StringParams} from './common'; 2 | 3 | export interface AdditionalWidgetLayout { 4 | parent?: string; 5 | } 6 | 7 | export interface ConfigLayout extends AdditionalWidgetLayout { 8 | i: string; 9 | h: number; 10 | w: number; 11 | x: number; 12 | y: number; 13 | } 14 | 15 | export type ConfigItemGroup = { 16 | id: string; 17 | defaults?: StringParams; 18 | namespace: string; 19 | [key: string]: unknown; 20 | }; 21 | 22 | export interface ConfigItemData { 23 | _editActive?: boolean; 24 | tabs?: { 25 | id: string; 26 | isDefault?: boolean; 27 | params?: StringParams; 28 | [key: string]: unknown; 29 | }[]; 30 | group?: ConfigItemGroup[]; 31 | [key: string]: unknown; 32 | } 33 | 34 | export interface ConfigItemDataWithTabs extends Omit { 35 | tabs: NonNullable; 36 | } 37 | 38 | export interface ConfigItemDataWithGroup extends Omit { 39 | group: NonNullable; 40 | } 41 | 42 | export interface ConfigItem { 43 | id: string; 44 | data: ConfigItemData; 45 | type: string; 46 | namespace: string; 47 | defaults?: StringParams; 48 | orderId?: number; 49 | defaultOrderId?: number; 50 | } 51 | 52 | export interface ConfigItemWithTabs extends Omit { 53 | data: ConfigItemDataWithTabs; 54 | } 55 | 56 | export interface ConfigItemWithGroup extends Omit { 57 | data: ConfigItemDataWithGroup; 58 | } 59 | 60 | export interface ConfigAliases { 61 | [namespace: string]: string[][]; // в массивах имена параметров 62 | } 63 | 64 | export interface ConfigConnection { 65 | from: string; 66 | to: string; 67 | kind: 'ignore'; // пока один kind 68 | } 69 | 70 | export interface Config { 71 | salt: string; 72 | counter: number; 73 | items: ConfigItem[]; 74 | layout: ConfigLayout[]; 75 | aliases: ConfigAliases; 76 | connections: ConfigConnection[]; 77 | } 78 | -------------------------------------------------------------------------------- /src/shared/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common'; 2 | export * from './config'; 3 | export * from './state-and-params'; 4 | export * from './plugin'; 5 | -------------------------------------------------------------------------------- /src/shared/types/plugin.ts: -------------------------------------------------------------------------------- 1 | import type {ConfigItem} from './config'; 2 | 3 | interface PluginSpecialFields { 4 | prerenderMiddleware?: (item: ConfigItem) => ConfigItem; 5 | } 6 | 7 | export interface PluginBase extends PluginSpecialFields { 8 | type: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/types/state-and-params.ts: -------------------------------------------------------------------------------- 1 | import {META_KEY} from '../constants'; 2 | 3 | import {StringParams} from './common'; 4 | 5 | export interface ItemState { 6 | [key: string]: any; 7 | } 8 | 9 | export type ItemParams = StringParams | Record; 10 | 11 | export interface QueueItem { 12 | id: string; 13 | tabId?: string; 14 | groupItemId?: string; 15 | } 16 | 17 | export type StateAndParamsMetaData = { 18 | queue: QueueItem[]; 19 | version: number; 20 | }; 21 | 22 | export type StateAndParamsMeta = { 23 | [META_KEY]: StateAndParamsMetaData; 24 | }; 25 | 26 | export type ItemStateAndParams = { 27 | params?: ItemParams; 28 | state?: ItemState; 29 | }; 30 | 31 | export type ItemStateAndParamsChangeOptions = { 32 | action?: 'setParams' | 'removeItem'; 33 | groupItemIds?: string[]; 34 | }; 35 | 36 | export type ItemsStateAndParamsBase = Record; 37 | 38 | export type ItemsStateAndParams = ItemsStateAndParamsBase | StateAndParamsMeta; 39 | -------------------------------------------------------------------------------- /src/shared/units/datalens/index.ts: -------------------------------------------------------------------------------- 1 | import omit from 'lodash/omit'; 2 | 3 | import {META_KEY} from '../../constants'; 4 | import {GetItemsParamsArg, getItemsStateAndParams} from '../../modules'; 5 | import {ItemsStateAndParamsBase, PluginBase} from '../../types'; 6 | 7 | export const pluginControlBaseDL: PluginBase = { 8 | type: 'control', 9 | }; 10 | 11 | export const pluginGroupControlBaseDL: PluginBase = { 12 | type: 'group_control', 13 | }; 14 | 15 | export const pluginWidgetBaseDL: PluginBase = { 16 | type: 'widget', 17 | }; 18 | 19 | const pluginsBaseDL: PluginBase[] = [ 20 | {type: 'title'}, 21 | {type: 'text'}, 22 | pluginControlBaseDL, 23 | pluginWidgetBaseDL, 24 | pluginGroupControlBaseDL, 25 | ]; 26 | 27 | // Используется в DataLens на серверной стороне для формирования параметров и стейта чарта в рассылках 28 | export function getItemsStateAndParamsDL( 29 | data: Omit, 30 | ): ItemsStateAndParamsBase { 31 | const itemsStateAndParams = getItemsStateAndParams({ 32 | ...data, 33 | plugins: pluginsBaseDL, 34 | }); 35 | return omit(itemsStateAndParams, META_KEY); 36 | } 37 | -------------------------------------------------------------------------------- /src/shared/units/index.ts: -------------------------------------------------------------------------------- 1 | export * from './datalens'; 2 | -------------------------------------------------------------------------------- /src/typings/common.ts: -------------------------------------------------------------------------------- 1 | import type ReactGridLayout from 'react-grid-layout'; 2 | 3 | import type {OverlayCustomControlItem} from '../components/OverlayControls/OverlayControls'; 4 | import {MenuItems} from '../constants'; 5 | import {AdditionalWidgetLayout} from '../shared'; 6 | 7 | export type GridLayoutSettings = ReactGridLayout.ReactGridLayoutProps & { 8 | noOverlay?: boolean; 9 | }; 10 | 11 | export interface Settings { 12 | gridLayout?: GridLayoutSettings; 13 | theme?: string; 14 | isMobile?: boolean; 15 | // @deprecated as it's possibly mutable property use Dashkit overlayMenuItems property instead 16 | menu?: Array; 17 | } 18 | 19 | export type MenuItem = (typeof MenuItems)[keyof typeof MenuItems] | OverlayCustomControlItem; 20 | 21 | export interface SettingsProps { 22 | autoupdateInterval: number; 23 | silentLoading: boolean; 24 | } 25 | 26 | export interface ContextProps { 27 | [key: string]: any; 28 | } 29 | 30 | export interface WidgetLayout extends AdditionalWidgetLayout { 31 | i: string; 32 | w: number; 33 | h: number; 34 | x: number; 35 | y: number; 36 | minW: number; 37 | minH: number; 38 | maxW?: number; 39 | maxH?: number; 40 | } 41 | -------------------------------------------------------------------------------- /src/typings/config.ts: -------------------------------------------------------------------------------- 1 | import type {Layout, Layouts} from 'react-grid-layout'; 2 | 3 | import type {Config, ConfigItem, ConfigLayout} from '../shared'; 4 | 5 | import {GridLayoutSettings} from './common'; 6 | 7 | export interface AddConfigItem extends Omit { 8 | id?: null; 9 | namespace?: string; 10 | layout?: Omit; 11 | } 12 | export type SetConfigItem = ConfigItem | AddConfigItem; 13 | 14 | export type SetItemOptions = { 15 | excludeIds?: string[]; 16 | }; 17 | 18 | export type GridReflowOptions = { 19 | cols: number; 20 | maxRows?: number; 21 | compactType?: CompactType; 22 | }; 23 | 24 | export type CompactType = ReactGridLayout.ReactGridLayoutProps['compactType'] | 'horizontal-nowrap'; 25 | 26 | export type ReflowLayoutOptions = { 27 | defaultProps: GridReflowOptions; 28 | groups?: Record; 29 | }; 30 | 31 | export type AddNewItemOptions = SetItemOptions & { 32 | updateLayout?: ConfigLayout[]; 33 | reflowLayoutOptions?: ReflowLayoutOptions; 34 | }; 35 | 36 | export interface DashkitGroupRenderProps { 37 | config: Config; 38 | editMode: boolean; 39 | isMobile: boolean; 40 | items: ConfigItem[]; 41 | layout: ConfigLayout[]; 42 | context: any; 43 | } 44 | 45 | export type ReactGridLayoutProps = Omit< 46 | GridLayoutSettings, 47 | | 'children' 48 | | 'compactType' 49 | | 'innerRef' 50 | | 'key' 51 | | 'layout' 52 | | 'onDragStart' 53 | | 'onDragStop' 54 | | 'onResizeStart' 55 | | 'onResizeStop' 56 | | 'draggableHandle' 57 | | 'isDroppable' 58 | | 'onDropDragOver' 59 | | 'onDrop' 60 | | 'draggableCancel' 61 | > & { 62 | compactType?: CompactType; 63 | }; 64 | 65 | export interface DashKitGroup { 66 | id?: string; 67 | render?: ( 68 | id: string, 69 | children: React.ReactNode, 70 | props: DashkitGroupRenderProps, 71 | ) => React.ReactNode; 72 | gridProperties?: (props: ReactGridLayoutProps) => ReactGridLayoutProps; 73 | } 74 | 75 | export type ItemManipulationCallback = (eventData: { 76 | layout: Layouts; 77 | oldItem: Layout; 78 | newItem: Layout; 79 | placeholder: Layout; 80 | e: MouseEvent; 81 | element: HTMLElement; 82 | }) => void; 83 | -------------------------------------------------------------------------------- /src/typings/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const content: SVGIconData; 3 | 4 | export default content; 5 | } 6 | 7 | declare module '*.md'; 8 | -------------------------------------------------------------------------------- /src/typings/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common'; 2 | export * from './plugin'; 3 | export * from './config'; 4 | -------------------------------------------------------------------------------- /src/typings/plugin.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import ReactGridLayout from 'react-grid-layout'; 4 | 5 | import type { 6 | ConfigItem, 7 | ItemState, 8 | ItemStateAndParams, 9 | ItemStateAndParamsChangeOptions, 10 | PluginBase, 11 | StringParams, 12 | } from '../shared'; 13 | 14 | import type {ContextProps, SettingsProps, WidgetLayout} from './common'; 15 | 16 | export interface PluginWidgetProps { 17 | id: string; 18 | editMode: boolean; 19 | params: T; 20 | state: ItemState; 21 | onStateAndParamsChange: ( 22 | stateAndParams: ItemStateAndParams, 23 | options?: ItemStateAndParamsChangeOptions, 24 | ) => void; 25 | onBeforeLoad: () => () => void; 26 | width: number; 27 | height: number; 28 | data: ConfigItem['data']; 29 | defaults: ConfigItem['defaults']; 30 | namespace: ConfigItem['namespace']; 31 | settings: SettingsProps; 32 | context: ContextProps; 33 | layout: WidgetLayout[]; 34 | gridLayout: ReactGridLayout.ReactGridLayoutProps; 35 | adjustWidgetLayout: (data: { 36 | widgetId: string; 37 | needSetDefault?: boolean; 38 | adjustedWidgetLayout?: WidgetLayout; 39 | }) => void; 40 | } 41 | 42 | export type PluginDefaultLayout = Partial>; 43 | 44 | export interface Plugin

= any, T = StringParams> extends PluginBase { 45 | defaultLayout?: PluginDefaultLayout; 46 | renderer: (props: P, forwardedRef: React.RefObject) => React.ReactNode; 47 | placeholderRenderer?: (props: P, forwardedRef: React.RefObject) => React.ReactNode; 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/cn.ts: -------------------------------------------------------------------------------- 1 | import {withNaming} from '@bem-react/classname'; 2 | 3 | export const cn = withNaming({e: '__', m: '_'}); 4 | -------------------------------------------------------------------------------- /src/utils/get-new-id.ts: -------------------------------------------------------------------------------- 1 | import {extractIdsFromConfig, generateUniqId} from '../shared'; 2 | import type {Config} from '../shared'; 3 | 4 | type GetNewIdArgs = { 5 | config: Config; 6 | salt: Config['salt']; 7 | counter: Config['counter']; 8 | excludeIds?: string[]; 9 | }; 10 | 11 | export function getNewId({config, salt, counter, excludeIds = []}: GetNewIdArgs) { 12 | const allIds = [...extractIdsFromConfig(config), ...excludeIds]; 13 | return generateUniqId({salt, counter, ids: allIds}); 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/grid-layout.ts: -------------------------------------------------------------------------------- 1 | import gridLayout, {Layout} from 'react-grid-layout'; 2 | 3 | import type {CompactType} from '../'; 4 | 5 | const {utils} = gridLayout as any; 6 | 7 | export const compact = (layout: Layout[], compactType: CompactType, cols: number): Layout[] => { 8 | if (compactType === 'horizontal-nowrap') { 9 | compactType = 'horizontal'; 10 | } 11 | 12 | return utils.compact(layout, compactType, cols); 13 | }; 14 | 15 | export const bottom = (layout: Layout[]): number => { 16 | return utils.bottom(layout); 17 | }; 18 | -------------------------------------------------------------------------------- /src/utils/group-helpers.ts: -------------------------------------------------------------------------------- 1 | import {DEFAULT_GROUP} from '../constants'; 2 | import type {ConfigLayout} from '../shared/types'; 3 | 4 | export const resolveLayoutGroup = (item: ConfigLayout) => { 5 | if (!item.parent) { 6 | return DEFAULT_GROUP; 7 | } 8 | 9 | return item.parent; 10 | }; 11 | 12 | export const isDefaultLayoutGroup = (item: ConfigLayout) => { 13 | return item.parent === DEFAULT_GROUP || item.parent === undefined; 14 | }; 15 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './register-manager'; 2 | export * from './update-manager'; 3 | export * from './group-helpers'; 4 | -------------------------------------------------------------------------------- /src/utils/register-manager.ts: -------------------------------------------------------------------------------- 1 | import ReactGridLayout from 'react-grid-layout'; 2 | 3 | import type {Plugin, PluginDefaultLayout, Settings} from '../typings'; 4 | 5 | interface RegisterManagerDefaultLayout { 6 | x: number; 7 | y: number; 8 | w: number; 9 | h: number; 10 | minW?: number; 11 | minH?: number; 12 | } 13 | 14 | export type RegisterManagerPluginLayout = RegisterManagerDefaultLayout & PluginDefaultLayout; 15 | 16 | export interface RegisterManagerPlugin extends Omit { 17 | defaultLayout: RegisterManagerDefaultLayout & Plugin['defaultLayout']; 18 | } 19 | 20 | export type RegisterManagerSettings = {theme: string} & Settings; 21 | 22 | export class RegisterManager { 23 | private _items: Record = {}; 24 | private _defaultLayout: RegisterManagerDefaultLayout = { 25 | x: 0, 26 | y: Infinity, 27 | w: Infinity, 28 | h: 4, 29 | minW: 4, 30 | minH: 2, 31 | }; 32 | private _gridLayout: ReactGridLayout.ReactGridLayoutProps = { 33 | rowHeight: 18, 34 | cols: 36, 35 | margin: [2, 2], 36 | containerPadding: [0, 0], 37 | }; 38 | private _settings: RegisterManagerSettings = { 39 | theme: 'default', 40 | }; 41 | 42 | registerPlugin(plugin: Plugin) { 43 | const {type} = plugin; 44 | if (type in this._items) { 45 | throw new Error(`DashKit.registerPlugins: type ${type} уже был зарегистрирован`); 46 | } 47 | this.reloadPlugin(plugin); 48 | } 49 | 50 | reloadPlugin(plugin: Plugin) { 51 | const {type, defaultLayout = {}, ...item} = plugin; 52 | 53 | if (typeof plugin.renderer !== 'function') { 54 | throw new Error('DashKit.registerPlugins: renderer должна быть функцией'); 55 | } 56 | 57 | this._items[type] = { 58 | ...item, 59 | type, 60 | defaultLayout: {...this._defaultLayout, ...defaultLayout}, 61 | }; 62 | } 63 | 64 | setSettings(settings: Settings = {}) { 65 | Object.assign(this._settings, settings); 66 | if (settings.gridLayout) { 67 | this._gridLayout = {...this._gridLayout, ...settings.gridLayout}; 68 | } 69 | } 70 | 71 | get settings() { 72 | return this._settings; 73 | } 74 | 75 | get gridLayout() { 76 | return this._gridLayout; 77 | } 78 | 79 | getPlugins() { 80 | return Object.values(this._items); 81 | } 82 | 83 | getItem(type: string) { 84 | return this._items[type]; 85 | } 86 | 87 | check(type: string) { 88 | return type in this._items; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@gravity-ui/tsconfig", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "declaration": true, 6 | "resolveJsonModule": true, 7 | "outDir": "build/esm", 8 | "jsx": "react", 9 | "baseUrl": ".", 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "importHelpers": true, 13 | }, 14 | "include": ["src"] 15 | } 16 | --------------------------------------------------------------------------------