├── .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 |
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 |
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