diff --git a/app/assets/stylesheets/lazy_bundles/gridstack.scss b/app/assets/stylesheets/lazy_bundles/gridstack.scss new file mode 100644 index 0000000000000000000000000000000000000000..235b225d7479e0ac80c84b3345d4e1fb38fe5886 --- /dev/null +++ b/app/assets/stylesheets/lazy_bundles/gridstack.scss @@ -0,0 +1 @@ +@import 'gridstack/dist/gridstack'; diff --git a/config/application.rb b/config/application.rb index d45e0bab54d955abb81d1de6e2c02d34e293bd6a..368036ce0649f16ff30499a1d19714487dc13b6b 100644 --- a/config/application.rb +++ b/config/application.rb @@ -324,6 +324,7 @@ class Application < Rails::Application config.assets.precompile << "page_bundles/xterm.css" config.assets.precompile << "lazy_bundles/cropper.css" config.assets.precompile << "lazy_bundles/select2.css" + config.assets.precompile << "lazy_bundles/gridstack.css" config.assets.precompile << "performance_bar.css" config.assets.precompile << "disable_animations.css" config.assets.precompile << "test_environment.css" diff --git a/doc/development/fe_guide/customizable_dashboards.md b/doc/development/fe_guide/customizable_dashboards.md new file mode 100644 index 0000000000000000000000000000000000000000..38ee750d4210d6ad148f5b476d06971ae3839a3a --- /dev/null +++ b/doc/development/fe_guide/customizable_dashboards.md @@ -0,0 +1,99 @@ +--- +stage: Analytics +group: Product Analytics +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + +# Customizable dashboards **(PREMIUM)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/98610) in GitLab 15.5 as an [Alpha feature](../../policy/alpha-beta-support.md#alpha-features). + +Customizable dashboards provide a dashboard structure that allows users to create +their own dashboards and commit the structure to a repository. + +## Usage + +To use customizable dashboards: + +1. Create your dashboard component. +1. Render an instance of `CustomizableDashboard`. +1. Pass a list of widgets to render. + +For example, a customizable dashboard for users over time: + +```vue +<script> +import CustomizableDashboard from 'ee/vue_shared/components/customizable_dashboard/customizable_dashboard.vue'; +import { s__ } from '~/locale'; + +export default { + name: 'AnalyticsDashboard', + components: { + CustomizableDashboard, + }, + data() { + return { + widgets: [ + { + component: 'CubeLineChart', // The name of the widget component. + title: s__('ProductAnalytics|Users / Time'), // The title shown on the widget component. + // Gridstack settings based upon https://github.com/gridstack/gridstack.js/tree/master/doc#item-options. + // All values are grid row/column numbers up to 12. + // We use the default 12 column grid https://github.com/gridstack/gridstack.js#change-grid-columns. + gridAttributes: { + size: { + height: 4, + width: 6, + minHeight: 4, + minWidth: 6, + }, + position: { + xPos: 0, + yPos: 0, + }, + }, + // Options that are used to set bespoke values for each widget. + // Available customizations are determined by the widget itself. + customizations: {}, + // Chart options defined by the charting library being used by the widget. + chartOptions: { + xAxis: { name: __('Time'), type: 'time' }, + yAxis: { name: __('Counts') }, + }, + // The data for the widget. + // This could be imported or in this case, a query passed to be used by the widgets API. + // Each widget type determines how it handles this property. + data: { + query: { + users: { + measures: ['Jitsu.count'], + dimensions: ['Jitsu.eventType'], + }, + }, + }, + }, + ] + }; + }, +}; +</script> + +<template> + <h1>{{ s__('ProductAnalytics|Analytics dashboard') }}</h1> + <customizable-dashboard :widgets="widgets" /> +</template> +``` + +The widgets data can be retrieved from a file or API request, or imported through HTML data attributes. + +For each widget, a `component` is defined. Each `component` is a component declaration and should be included in +[`vue_shared/components/customizable_dashboard/widgets_base.vue`](https://gitlab.com/gitlab-org/gitlab/blob/master/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/widgets_base.vue) +as a dynamic import, to keep the memory usage down until it is used. + +For example: + +```javascript +components: { + CubeLineChart: () => import('ee/product_analytics/dashboards/components/widgets/cube_line_chart.vue') +} +``` diff --git a/ee/app/assets/javascripts/product_analytics/dashboards/components/analytics_dashboard.vue b/ee/app/assets/javascripts/product_analytics/dashboards/components/analytics_dashboard.vue index 27d83815fe3361355f617c0b84fc67b350a64692..dd6e77994a1d77efe3d1ac4dd8b5e77007cbf8f4 100644 --- a/ee/app/assets/javascripts/product_analytics/dashboards/components/analytics_dashboard.vue +++ b/ee/app/assets/javascripts/product_analytics/dashboards/components/analytics_dashboard.vue @@ -1,15 +1,31 @@ <script> +import CustomizableDashboard from 'ee/vue_shared/components/customizable_dashboard/customizable_dashboard.vue'; +import { s__ } from '~/locale'; + export default { name: 'AnalyticsDashboard', - + components: { + CustomizableDashboard, + }, data() { return { - dashboard: {}, + widgets: [ + { + component: 'CubeLineChart', + title: s__('ProductAnalytics|Audience'), + gridAttributes: { + size: { + width: 3, + height: 3, + }, + }, + }, + ], }; }, }; </script> <template> - <div></div> + <customizable-dashboard :widgets="widgets" :editable="false" /> </template> diff --git a/ee/app/assets/javascripts/product_analytics/dashboards/components/widgets/cube_line_chart.vue b/ee/app/assets/javascripts/product_analytics/dashboards/components/widgets/cube_line_chart.vue new file mode 100644 index 0000000000000000000000000000000000000000..51634244cdbc3b353df6146778f63dbcc39a6f8f --- /dev/null +++ b/ee/app/assets/javascripts/product_analytics/dashboards/components/widgets/cube_line_chart.vue @@ -0,0 +1,31 @@ +<script> +import { s__ } from '~/locale'; + +export default { + name: 'CubeLineChart', + props: { + data: { + type: Object, + required: false, + default: () => ({}), + }, + chartOptions: { + type: Object, + required: false, + default: () => ({}), + }, + customizations: { + type: Object, + required: false, + default: () => ({}), + }, + }, + i18n: { + content: s__('ProductAnalytics|Widgets content'), + }, +}; +</script> + +<template> + <p>{{ $options.i18n.content }}</p> +</template> diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/constants.js b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..a2708525af1ff1dc6977748bc8f80c0d707c11a8 --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/constants.js @@ -0,0 +1,2 @@ +export const GRIDSTACK_MARGIN = 10; +export const GRIDSTACK_CSS_HANDLE = '.grid-stack-item-handle'; diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.stories.js b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..a4856a342efb44230dd8696422527fddce0cc11f --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.stories.js @@ -0,0 +1,46 @@ +import { s__ } from '~/locale'; +import CustomizableDashboard from './customizable_dashboard.vue'; + +export default { + component: CustomizableDashboard, + title: 'ee/vue_shared/components/customizable_dashboard', +}; + +const Template = (args, { argTypes }) => ({ + components: { CustomizableDashboard }, + props: Object.keys(argTypes), + template: '<customizable-dashboard v-bind="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + editable: false, + widgets: [ + { + component: 'CubeLineChart', + title: s__('ProductAnalytics|Audience'), + gridAttributes: { + size: { + width: 3, + height: 3, + }, + }, + }, + { + component: 'CubeLineChart', + title: s__('ProductAnalytics|Audience'), + gridAttributes: { + size: { + width: 3, + height: 3, + }, + }, + }, + ], +}; + +export const Editable = Template.bind({}); +Editable.args = { + ...Default.args, + editable: true, +}; diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue new file mode 100644 index 0000000000000000000000000000000000000000..835d17426c28d827c79d025b5d60f3295a9d1c8b --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue @@ -0,0 +1,113 @@ +<script> +import { GridStack } from 'gridstack'; +import * as Sentry from '@sentry/browser'; +import { loadCSSFile } from '~/lib/utils/css_utils'; +import WidgetsBase from './widgets_base.vue'; +import { GRIDSTACK_MARGIN, GRIDSTACK_CSS_HANDLE } from './constants'; + +export default { + name: 'CustomizableDashboard', + components: { + WidgetsBase, + }, + props: { + editable: { + type: Boolean, + required: false, + default: false, + }, + widgets: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + cssLoaded: false, + mounted: true, + }; + }, + computed: { + loaded() { + return this.cssLoaded && this.mounted; + }, + }, + watch: { + cssLoaded() { + this.initGridStack(); + }, + mounted() { + this.initGridStack(); + }, + }, + async created() { + try { + await loadCSSFile(gon.gridstack_css_path); + this.cssLoaded = true; + } catch (e) { + Sentry.captureException(e); + } + }, + mounted() { + this.mounted = true; + }, + unmounted() { + this.mounted = false; + }, + methods: { + initGridStack() { + if (this.loaded) { + GridStack.init({ + staticGrid: !this.editable, + margin: GRIDSTACK_MARGIN, + handle: GRIDSTACK_CSS_HANDLE, + }); + } + }, + getGridAttribute(widget, attribute) { + const { gridAttributes: { position = {}, size = {} } = {} } = widget; + + if (position[attribute]) { + return position[attribute]; + } + + if (size[attribute]) { + return size[attribute]; + } + + return undefined; + }, + }, +}; +</script> + +<template> + <div class="grid-stack-container gl-py-6"> + <div class="grid-stack"> + <div + v-for="(widget, index) in widgets" + :key="index" + :gs-id="index" + :gs-x="getGridAttribute(widget, 'xPos')" + :gs-y="getGridAttribute(widget, 'yPos')" + :gs-h="getGridAttribute(widget, 'height')" + :gs-w="getGridAttribute(widget, 'width')" + :gs-min-h="getGridAttribute(widget, 'minHeight')" + :gs-min-w="getGridAttribute(widget, 'minWidth')" + :gs-max-h="getGridAttribute(widget, 'maxHeight')" + :gs-max-w="getGridAttribute(widget, 'maxWidth')" + class="grid-stack-item" + data-testid="grid-stack-widget" + > + <widgets-base + :component="widget.component" + :title="widget.title" + :data="widget.data" + :chart-options="widget.chartOptions" + :customizations="widget.customizations" + /> + </div> + </div> + </div> +</template> diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/widgets_base.stories.js b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/widgets_base.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..362ab14b0511c638962a3b79338343946515172c --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/widgets_base.stories.js @@ -0,0 +1,22 @@ +import { s__ } from '~/locale'; +import WidgetsBase from './widgets_base.vue'; + +export default { + component: WidgetsBase, + title: 'ee/vue_shared/components/widgets_base', +}; + +const Template = (args, { argTypes }) => ({ + components: { WidgetsBase }, + props: Object.keys(argTypes), + template: '<widgets-base v-bind="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + component: 'CubeLineChart', + title: s__('ProductAnalytics|Audience'), + data: {}, + chartOptions: {}, + customizations: {}, +}; diff --git a/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/widgets_base.vue b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/widgets_base.vue new file mode 100644 index 0000000000000000000000000000000000000000..4f3b8a54d655c32280f02771595ed64a13bbc313 --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/widgets_base.vue @@ -0,0 +1,50 @@ +<script> +export default { + name: 'AnalyticsDashboardWidget', + components: { + CubeLineChart: () => + import('ee/product_analytics/dashboards/components/widgets/cube_line_chart.vue'), + }, + props: { + component: { + type: String, + required: true, + }, + title: { + type: String, + required: false, + default: '', + }, + data: { + type: Object, + required: false, + default: () => ({}), + }, + chartOptions: { + type: Object, + required: false, + default: () => ({}), + }, + customizations: { + type: Object, + required: false, + default: () => ({}), + }, + }, +}; +</script> + +<template> + <div + class="grid-stack-item-content gl-shadow gl-rounded-base gl-p-4 gl-display-flex gl-flex-direction-column" + > + <strong v-if="title" class="gl-mb-4" data-testid="widget-title">{{ title }}</strong> + <component + :is="component" + :data="data" + :customizations="customizations" + :chart-options="chartOptions" + class="gl-overflow-y-auto" + /> + </div> +</template> diff --git a/ee/spec/frontend/product_analytics/dashboards/components/analytics_dashboard_spec.js b/ee/spec/frontend/product_analytics/dashboards/components/analytics_dashboard_spec.js index 6a04104e9bd3a270bb7e5247148c96c5cc7bb4b6..40daac344c4a8e23eedfdc9e0bc0f7e08a921e4c 100644 --- a/ee/spec/frontend/product_analytics/dashboards/components/analytics_dashboard_spec.js +++ b/ee/spec/frontend/product_analytics/dashboards/components/analytics_dashboard_spec.js @@ -1,18 +1,16 @@ -import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import AnalyticsDashboard from 'ee/product_analytics/dashboards/components/analytics_dashboard.vue'; +import CustomizableDashboard from 'ee/vue_shared/components/customizable_dashboard/customizable_dashboard.vue'; +import { widgets } from 'ee_jest/vue_shared/components/customizable_dashboard/mock_data'; -describe('ee/product_analytics/dashboards/components/analytics_dashboard.vue', () => { +describe('AnalyticsDashboard', () => { let wrapper; - afterEach(() => { - wrapper.destroy(); - }); - const createWrapper = (data = {}) => { - wrapper = mountExtended(AnalyticsDashboard, { + wrapper = shallowMountExtended(AnalyticsDashboard, { data() { return { - dashboard: {}, + widgets: [], ...data, }; }, @@ -21,11 +19,16 @@ describe('ee/product_analytics/dashboards/components/analytics_dashboard.vue', ( describe('when mounted', () => { beforeEach(() => { - createWrapper(); + createWrapper({ + widgets, + }); }); it('should render', () => { - expect(wrapper.exists()).toBe(true); + expect(wrapper.findComponent(CustomizableDashboard).props()).toStrictEqual({ + widgets, + editable: false, + }); }); }); }); diff --git a/ee/spec/frontend/product_analytics/dashboards/components/widgets/cube_line_chart_spec.js b/ee/spec/frontend/product_analytics/dashboards/components/widgets/cube_line_chart_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..3ebd4cc2eac864a921f65667c25e934b3d6e8a56 --- /dev/null +++ b/ee/spec/frontend/product_analytics/dashboards/components/widgets/cube_line_chart_spec.js @@ -0,0 +1,27 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CubeLineChart from 'ee/product_analytics/dashboards/components/widgets/cube_line_chart.vue'; + +describe('CubeLineChart', () => { + let wrapper; + + const createWrapper = (props = {}) => { + wrapper = shallowMountExtended(CubeLineChart, { + propsData: { + data: {}, + chartOptions: {}, + customizations: {}, + ...props, + }, + }); + }; + + describe('when mounted', () => { + beforeEach(() => { + createWrapper(); + }); + + it('should render', () => { + expect(wrapper.exists()).toBe(true); + }); + }); +}); diff --git a/ee/spec/frontend/vue_shared/components/customizable_dashboard/customizable_dashboard_spec.js b/ee/spec/frontend/vue_shared/components/customizable_dashboard/customizable_dashboard_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..5be2c6b3e693c8b99bdad281bb9515f769e86a62 --- /dev/null +++ b/ee/spec/frontend/vue_shared/components/customizable_dashboard/customizable_dashboard_spec.js @@ -0,0 +1,100 @@ +import { GridStack } from 'gridstack'; +import * as Sentry from '@sentry/browser'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CustomizableDashboard from 'ee/vue_shared/components/customizable_dashboard/customizable_dashboard.vue'; +import WidgetsBase from 'ee/vue_shared/components/customizable_dashboard/widgets_base.vue'; +import { + GRIDSTACK_MARGIN, + GRIDSTACK_CSS_HANDLE, +} from 'ee/vue_shared/components/customizable_dashboard/constants'; +import { loadCSSFile } from '~/lib/utils/css_utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import { widgets } from './mock_data'; + +jest.mock('gridstack', () => ({ + GridStack: { + init: jest.fn(), + }, +})); + +jest.mock('~/lib/utils/css_utils', () => ({ + loadCSSFile: jest.fn(), +})); + +describe('CustomizableDashboard', () => { + let wrapper; + + const sentryError = new Error('Network error'); + + const createWrapper = (props = {}) => { + wrapper = shallowMountExtended(CustomizableDashboard, { + propsData: { + editable: false, + widgets: [], + ...props, + }, + }); + }; + + const findGridStackWidgets = () => wrapper.findAllByTestId('grid-stack-widget'); + const findWidgets = () => wrapper.findAllComponents(WidgetsBase); + describe('when being created an error occurs while loading the CSS', () => { + beforeEach(() => { + jest.spyOn(Sentry, 'captureException'); + loadCSSFile.mockRejectedValue(sentryError); + + createWrapper({ + widgets, + }); + }); + + it('reports the error to sentry', async () => { + await waitForPromises(); + expect(Sentry.captureException.mock.calls[0][0]).toStrictEqual(sentryError); + }); + }); + + describe('when mounted', () => { + beforeEach(() => { + createWrapper({ + widgets, + }); + }); + + it('sets up GridStack', () => { + expect(GridStack.init).toHaveBeenCalledWith({ + staticGrid: true, + margin: GRIDSTACK_MARGIN, + handle: GRIDSTACK_CSS_HANDLE, + }); + }); + + it.each( + widgets.map((widget, index) => [ + widget.component, + widget.title, + widget.gridAttributes, + widget.customizations, + widget.chartOptions, + widget.data, + index, + ]), + )( + 'should render the widget for %s', + (component, title, gridAttributes, customizations, chartOptions, data, index) => { + expect(findWidgets().at(index).props()).toMatchObject({ + component, + title, + customizations, + chartOptions, + data, + }); + expect(findGridStackWidgets().at(index).attributes()).toMatchObject({ + 'gs-id': `${index}`, + 'gs-h': `${gridAttributes.size.height}`, + 'gs-w': `${gridAttributes.size.width}`, + }); + }, + ); + }); +}); diff --git a/ee/spec/frontend/vue_shared/components/customizable_dashboard/mock_data.js b/ee/spec/frontend/vue_shared/components/customizable_dashboard/mock_data.js new file mode 100644 index 0000000000000000000000000000000000000000..978b02153ec4ee2da9d2f6b6d63a792bc074a8f7 --- /dev/null +++ b/ee/spec/frontend/vue_shared/components/customizable_dashboard/mock_data.js @@ -0,0 +1,20 @@ +import { __ } from '~/locale'; + +export const widgets = [ + { + component: 'CubeLineChart', + title: __('Test A'), + gridAttributes: { size: { width: 3, height: 3 } }, + customizations: {}, + chartOptions: {}, + data: {}, + }, + { + component: 'CubeLineChart', + title: __('Test B'), + gridAttributes: { size: { width: 2, height: 4 } }, + customizations: {}, + chartOptions: {}, + data: {}, + }, +]; diff --git a/ee/spec/frontend/vue_shared/components/customizable_dashboard/widgets_base_spec.js b/ee/spec/frontend/vue_shared/components/customizable_dashboard/widgets_base_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..49b7101e3205d408d813a4dc0f94e9ad332740d0 --- /dev/null +++ b/ee/spec/frontend/vue_shared/components/customizable_dashboard/widgets_base_spec.js @@ -0,0 +1,42 @@ +import CubeLineChart from 'ee/product_analytics/dashboards/components/widgets/cube_line_chart.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import WidgetsBase from 'ee/vue_shared/components/customizable_dashboard/widgets_base.vue'; +import { widgets } from './mock_data'; + +describe('WidgetsBase', () => { + const widgetConfig = widgets[0]; + + let wrapper; + + const createWrapper = (props = {}) => { + wrapper = shallowMountExtended(WidgetsBase, { + propsData: { + ...props, + }, + }); + }; + + const findWidget = () => wrapper.findComponent(CubeLineChart); + const findWidgetTitle = () => wrapper.findByTestId('widget-title'); + + describe('when mounted', () => { + beforeEach(() => { + createWrapper({ + component: widgetConfig.component, + title: widgetConfig.title, + data: widgetConfig.data, + chartOptions: widgetConfig.chartOptions, + customizations: widgetConfig.customizations, + }); + }); + + it('should render', () => { + expect(findWidgetTitle().text()).toBe(widgetConfig.title); + expect(findWidget().props()).toStrictEqual({ + data: widgetConfig.data, + chartOptions: widgetConfig.chartOptions, + customizations: widgetConfig.customizations, + }); + }); + }); +}); diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 814040d29e1ac5db42a959587f1014c80056eb9a..bdb7484f3d6e3c779b1ddd242d0244d11f35c7c1 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -33,6 +33,7 @@ def add_gon_variables gon.sprite_file_icons = IconsHelper.sprite_file_icons_path gon.emoji_sprites_css_path = ActionController::Base.helpers.stylesheet_path('emoji_sprites') gon.select2_css_path = ActionController::Base.helpers.stylesheet_path('lazy_bundles/select2.css') + gon.gridstack_css_path = ActionController::Base.helpers.stylesheet_path('lazy_bundles/gridstack.css') gon.test_env = Rails.env.test? gon.disable_animations = Gitlab.config.gitlab['disable_animations'] gon.suggested_label_colors = LabelsHelper.suggested_colors diff --git a/locale/gitlab.pot b/locale/gitlab.pot index a4ac7b42231a7e6c773c34274dbd9a10ea8b22f6..003f529c90dc3a29d640b26ae77607e063d65b93 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -30800,9 +30800,15 @@ msgstr "" msgid "Product Analytics" msgstr "" +msgid "ProductAnalytics|Audience" +msgstr "" + msgid "ProductAnalytics|There is no data for this type of chart currently. Please see the Setup tab if you have not configured the product analytics tool already." msgstr "" +msgid "ProductAnalytics|Widgets content" +msgstr "" + msgid "Productivity" msgstr "" diff --git a/package.json b/package.json index bfeb065cfd1db9abb49c0bef60ff01e1befa6460..12139224df6ea345c1e3dc6c86127c15b0f76c6c 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ "fuzzaldrin-plus": "^0.6.0", "graphql": "^15.7.2", "graphql-tag": "^2.11.0", + "gridstack": "^7.0.0", "highlight.js": "^11.5.1", "immer": "^9.0.15", "ipaddr.js": "^1.9.1", diff --git a/yarn.lock b/yarn.lock index 3e6bb44735106e4936dd2b8a8d2e09a0a347c79b..1a66f91e7037020fa66f388b25161b9e3316d067 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6331,6 +6331,11 @@ graphql@^15.7.2: resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.7.2.tgz#85ab0eeb83722977151b3feb4d631b5f2ab287ef" integrity sha512-AnnKk7hFQFmU/2I9YSQf3xw44ctnSFCfp3zE0N6W174gqe9fWG/2rKaKxROK7CcI3XtERpjEKFqts8o319Kf7A== +gridstack@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-7.0.0.tgz#2d00b28efa8d22a8b9ad2640c8ab64b494bbfdc9" + integrity sha512-iBts/PRuqg6OQvdpv7A84p3RROxzXVSKjM3SJHrdl2pdDZKmIpGo2oxjdCHv6l+SzU2EuptcHd1Rqouocwl1Cg== + gzip-size@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462"