diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json index 15f41373a83..9e5520c1c45 100644 --- a/packages/twenty-front/package.json +++ b/packages/twenty-front/package.json @@ -79,6 +79,7 @@ "react-datepicker": "^6.7.1", "react-dropzone": "^14.2.3", "react-error-boundary": "^4.0.11", + "react-grid-layout": "^1.5.2", "react-helmet-async": "^1.3.0", "react-hook-form": "^7.45.1", "react-hotkeys-hook": "^4.4.4", @@ -105,6 +106,7 @@ "@types/apollo-upload-client": "^17.0.2", "@types/file-saver": "^2.0.7", "@types/js-cookie": "^3.0.3", + "@types/react-grid-layout": "^1", "@typescript-eslint/eslint-plugin": "^8.39.0", "@typescript-eslint/parser": "^8.39.0", "@typescript-eslint/utils": "^8.39.0", diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 6ca1a4244b7..e7164270a9a 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -297,6 +297,12 @@ const SettingsObjectFieldEdit = lazy(() => ), ); +const PageLayoutEdition = lazy(() => + import('~/pages/settings/page-layout/PageLayoutEdition').then((module) => ({ + default: module.PageLayoutEdition, + })), +); + const SettingsSecurity = lazy(() => import('~/pages/settings/security/SettingsSecurity').then((module) => ({ default: module.SettingsSecurity, @@ -605,6 +611,10 @@ export const SettingsRoutes = ({ /> )} + } + /> setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} - radialAxisStart={null} - radialAxisEnd={null} - circularAxisInner={null} - circularAxisOuter={null} layers={['bars', renderValueEndLine]} /> {showValue && ( diff --git a/packages/twenty-front/src/modules/dashboards/graphs/components/GraphWidgetNumberChart.tsx b/packages/twenty-front/src/modules/dashboards/graphs/components/GraphWidgetNumberChart.tsx index 26a747390fd..d977b5cd5b0 100644 --- a/packages/twenty-front/src/modules/dashboards/graphs/components/GraphWidgetNumberChart.tsx +++ b/packages/twenty-front/src/modules/dashboards/graphs/components/GraphWidgetNumberChart.tsx @@ -21,8 +21,9 @@ const StyledTrendPercentageValue = styled.span` `; const StyledContainer = styled.div` - display: flex; align-items: center; + display: flex; + height: 100%; justify-content: space-between; width: 100%; `; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsPageFullWidthContainer.tsx b/packages/twenty-front/src/modules/settings/components/SettingsPageFullWidthContainer.tsx new file mode 100644 index 00000000000..b1e2a77f2e4 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/components/SettingsPageFullWidthContainer.tsx @@ -0,0 +1,25 @@ +import { PageBody } from '@/ui/layout/page/components/PageBody'; +import { PageContainer } from '@/ui/layout/page/components/PageContainer'; +import { PageHeader } from '@/ui/layout/page/components/PageHeader'; +import { + Breadcrumb, + type BreadcrumbProps, +} from '@/ui/navigation/bread-crumb/components/Breadcrumb'; +import { type ReactNode } from 'react'; + +type SettingsPageFullWidthContainerProps = { + children: ReactNode; + links: BreadcrumbProps['links']; +}; + +export const SettingsPageFullWidthContainer = ({ + children, + links, +}: SettingsPageFullWidthContainerProps) => { + return ( + + } /> + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx index fc972ee1219..eef4c749699 100644 --- a/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx +++ b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx @@ -22,6 +22,7 @@ import { IconFunction, IconHierarchy2, IconKey, + IconLayout, IconLock, IconMail, IconRocket, @@ -66,6 +67,9 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => { false; const labPublicFeatureFlags = useRecoilValue(labPublicFeatureFlagsState); const isAIEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); + const isPageLayoutEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_PAGE_LAYOUT_ENABLED, + ); const permissionMap = usePermissionFlagMap(); return [ @@ -137,6 +141,15 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => { Icon: IconHierarchy2, isHidden: !permissionMap[PermissionFlagType.DATA_MODEL], }, + { + label: t`Page Layout`, + path: SettingsPath.PageLayoutEdition, + Icon: IconLayout, + isHidden: + !isPageLayoutEnabled || + !permissionMap[PermissionFlagType.WORKSPACE], + isNew: true, + }, { label: t`Integrations`, path: SettingsPath.Integrations, diff --git a/packages/twenty-front/src/modules/types/SettingsPath.ts b/packages/twenty-front/src/modules/types/SettingsPath.ts index 13dff0e3de9..07d348716da 100644 --- a/packages/twenty-front/src/modules/types/SettingsPath.ts +++ b/packages/twenty-front/src/modules/types/SettingsPath.ts @@ -53,4 +53,5 @@ export enum SettingsPath { RoleDetail = 'roles/:roleId', RoleObjectLevel = 'roles/:roleId/object/:objectMetadataId', RoleAddObjectLevel = 'roles/:roleId/add-object-permission', + PageLayoutEdition = 'page-layout/edition', } diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx index f8a436a53dd..5b925805196 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx @@ -17,7 +17,7 @@ import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { Global, css, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'; -import { Outlet } from 'react-router-dom'; +import { Outlet, useLocation } from 'react-router-dom'; import { useScreenSize } from 'twenty-ui/utilities'; const StyledLayout = styled.div` @@ -59,6 +59,10 @@ const StyledMainContainer = styled.div` export const DefaultLayout = () => { const isMobile = useIsMobile(); const isSettingsPage = useIsSettingsPage(); + const location = useLocation(); + const isPageLayoutEditor = location.pathname.includes( + '/settings/page-layout/edition', + ); const theme = useTheme(); const windowsWidth = useScreenSize().width; const showAuthModal = useShowAuthModal(); @@ -78,7 +82,10 @@ export const DefaultLayout = () => { theme.background.secondary}; + border: 1px solid ${({ theme }) => theme.border.color.light}; + border-radius: ${({ theme }) => theme.border.radius.md}; + box-sizing: border-box; + flex: 1; + overflow-y: auto; + padding: ${({ theme }) => theme.spacing(2)}; + position: relative; + width: 100%; + + .react-grid-placeholder { + background: ${({ theme }) => theme.adaptiveColors.blue3} !important; + + border-radius: ${({ theme }) => theme.border.radius.sm}; + } +`; + +const StyledGridOverlay = styled.div` + position: absolute; + top: ${({ theme }) => theme.spacing(2)}; + left: ${({ theme }) => theme.spacing(2)}; + right: ${({ theme }) => theme.spacing(2)}; + bottom: ${({ theme }) => theme.spacing(2)}; + display: grid; + grid-template-columns: repeat(12, 1fr); + grid-auto-rows: 50px; + gap: ${({ theme }) => theme.spacing(2)}; + pointer-events: none; + z-index: 0; + + @media (max-width: 480px) { + grid-template-columns: 1fr; + } + + @media (max-width: 768px) and (min-width: 481px) { + grid-template-columns: repeat(12, 1fr); + } +`; + +const StyledGridCell = styled.div` + background: 'transparent'; + border: 1px solid ${({ theme }) => theme.border.color.light}; + border-radius: ${({ theme }) => theme.border.radius.sm}; +`; + +type ExtendedResponsiveProps = ResponsiveProps & { + maxCols?: number; + preventCollision?: boolean; +}; + +const ResponsiveGridLayout = WidthProvider( + Responsive, +) as React.ComponentType; + +export const PageLayoutEdition = () => { + const { t } = useLingui(); + const [pageLayoutWidgets, setPageLayoutWidgets] = useRecoilState( + pageLayoutWidgetsState, + ); + const [pageLayoutLayouts, setPageLayoutLayouts] = useRecoilState( + pageLayoutLayoutsState, + ); + + const handleLayoutChange = (_: Layout[], allLayouts: Layouts) => { + setPageLayoutLayouts(allLayouts); + }; + + const handleAddWidget = () => { + // temp: Populate with mock data when empty placeholder is clicked + setPageLayoutWidgets(mockWidgets); + setPageLayoutLayouts(mockLayouts); + }; + + const isEmptyState = pageLayoutWidgets.length === 0; + + const emptyLayout: Layouts = { + lg: [{ i: 'empty-placeholder', x: 0, y: 0, w: 4, h: 4, static: true }], + md: [{ i: 'empty-placeholder', x: 0, y: 0, w: 4, h: 4, static: true }], + sm: [{ i: 'empty-placeholder', x: 0, y: 0, w: 1, h: 4, static: true }], + }; + + return ( + + + + {Array.from({ length: 12 * 20 }).map((_, i) => ( + + ))} + + + {isEmptyState ? ( +
+ +
+ ) : ( + pageLayoutWidgets.map((widget) => ( +
+ + {widget.type === 'number' && ( + + )} + {widget.type === 'gauge' && ( + + )} + {widget.type === 'pie' && ( + + )} + +
+ )) + )} +
+
+
+ ); +}; diff --git a/packages/twenty-front/src/pages/settings/page-layout/components/PageLayoutWidgetPlaceholder.tsx b/packages/twenty-front/src/pages/settings/page-layout/components/PageLayoutWidgetPlaceholder.tsx new file mode 100644 index 00000000000..ee2583e6de7 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/page-layout/components/PageLayoutWidgetPlaceholder.tsx @@ -0,0 +1,136 @@ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { type ReactNode } from 'react'; +import { IconGripVertical, IconX } from 'twenty-ui/display'; + +const StyledPlaceholderContainer = styled.div<{ $isEmpty?: boolean }>` + background: ${({ theme }) => theme.background.secondary}; + border: 1px solid + ${({ theme, $isEmpty }) => + $isEmpty ? theme.border.color.light : theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + box-sizing: border-box; + display: flex; + flex-direction: column; + height: 100%; + position: relative; + width: 100%; + padding: ${({ theme }) => theme.spacing(4)}; + ${({ $isEmpty, theme }) => + $isEmpty && + ` + border-style: solid; + cursor: pointer; + + &:hover { + background: ${theme.background.tertiary}; + border-color: ${theme.border.color.medium}; + } + `} +`; + +const StyledHeader = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledDragHandle = styled.div` + cursor: grab; + display: flex; + align-items: center; + + &:active { + cursor: grabbing; + } +`; + +const StyledTitle = styled.span` + color: ${({ theme }) => theme.font.color.secondary}; + flex: 1; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; +`; + +const StyledCloseButton = styled.button` + background: transparent; + border: none; + color: ${({ theme }) => theme.font.color.tertiary}; + cursor: pointer; + + &:hover { + color: ${({ theme }) => theme.font.color.secondary}; + } +`; + +const StyledContent = styled.div` + align-items: center; + display: flex; + height: 100%; + width: 100%; + justify-content: center; +`; + +const StyledEmptyState = styled.div` + align-items: center; + color: ${({ theme }) => theme.font.color.tertiary}; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(1)}; + padding: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledEmptyText = styled.span` + font-size: ${({ theme }) => theme.font.size.sm}; +`; + +type PageLayoutWidgetPlaceholderProps = { + title?: string; + onRemove?: () => void; + children?: ReactNode; + isEmpty?: boolean; +}; + +export const PageLayoutWidgetPlaceholder = ({ + title = 'Graph Title', + onRemove, + children, + isEmpty = false, +}: PageLayoutWidgetPlaceholderProps) => { + const theme = useTheme(); + + if (isEmpty) { + return ( + + + + + + Add Widget + + + + Click to add a widget + + + + ); + } + + return ( + + + + + + {title} + {onRemove && ( + + + + )} + + {children} + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/page-layout/components/__stories__/PageLayoutWidgetPlaceholder.stories.tsx b/packages/twenty-front/src/pages/settings/page-layout/components/__stories__/PageLayoutWidgetPlaceholder.stories.tsx new file mode 100644 index 00000000000..027a4c96103 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/page-layout/components/__stories__/PageLayoutWidgetPlaceholder.stories.tsx @@ -0,0 +1,156 @@ +import { GraphWidgetGaugeChart } from '@/dashboards/graphs/components/GraphWidgetGaugeChart'; +import { GraphWidgetNumberChart } from '@/dashboards/graphs/components/GraphWidgetNumberChart'; +import { GraphWidgetPieChart } from '@/dashboards/graphs/components/GraphWidgetPieChart'; +import { type Meta, type StoryObj } from '@storybook/react'; +import { ComponentDecorator } from 'twenty-ui/testing'; +import { PageLayoutWidgetPlaceholder } from '../PageLayoutWidgetPlaceholder'; + +const meta: Meta = { + title: 'Pages/Settings/PageLayout/PageLayoutWidgetPlaceholder', + component: PageLayoutWidgetPlaceholder, + decorators: [ComponentDecorator], + parameters: { + layout: 'centered', + }, + argTypes: { + title: { + control: 'text', + description: 'Widget title', + }, + isEmpty: { + control: 'boolean', + description: 'Show empty state', + }, + onRemove: { + action: 'onRemove', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + args: { + isEmpty: true, + }, + parameters: { + docs: { + description: { + story: 'Empty widget placeholder state with dashed border', + }, + }, + }, +}; + +export const WithNumberChart: Story = { + args: { + title: 'Sales Pipeline', + children: , + }, +}; + +export const WithGaugeChart: Story = { + args: { + title: 'Conversion Rate', + children: ( + + ), + }, +}; + +export const EmptyState: Story = { + args: { + isEmpty: true, + }, + parameters: { + docs: { + description: { + story: 'Empty widget placeholder state with dashed border', + }, + }, + }, +}; + +export const WithPieChart: Story = { + args: { + title: 'Lead Distribution', + children: ( + + ), + }, +}; + +export const WithCloseButton: Story = { + args: { + title: 'Removable Widget', + onRemove: () => {}, + children: , + }, +}; + +export const NoContent: Story = { + args: { + title: 'Empty Content Widget', + }, + parameters: { + docs: { + description: { + story: 'Widget with title but no content', + }, + }, + }, +}; + +export const LongTitle: Story = { + args: { + title: 'This is a very long widget title that should be handled gracefully', + children: , + }, +}; diff --git a/packages/twenty-front/src/pages/settings/page-layout/mocks/mockWidgets.ts b/packages/twenty-front/src/pages/settings/page-layout/mocks/mockWidgets.ts new file mode 100644 index 00000000000..17059ebbb7e --- /dev/null +++ b/packages/twenty-front/src/pages/settings/page-layout/mocks/mockWidgets.ts @@ -0,0 +1,144 @@ +import { type Layouts } from 'react-grid-layout'; + +export type WidgetType = 'number' | 'gauge' | 'pie' | 'view' | 'iframe'; + +export type Widget = { + id: string; + type: WidgetType; + title: string; + data?: any; +}; + +export const mockWidgets: Widget[] = [ + { + id: 'widget-1', + type: 'number', + title: 'Sales Pipeline', + data: { + value: '1,234', + trendPercentage: 12.5, + }, + }, + { + id: 'widget-2', + type: 'gauge', + title: 'Conversion Rate', + data: { + value: 0.5, + min: 0, + max: 1, + label: 'Conversion rate', + }, + }, + { + id: 'widget-3', + type: 'pie', + title: 'Lead Distribution', + data: { + items: [ + { + id: 'qualified', + value: 35, + label: 'Qualified', + to: '/leads/qualified', + }, + { + id: 'contacted', + value: 25, + label: 'Contacted', + to: '/leads/contacted', + }, + { + id: 'unqualified', + value: 20, + label: 'Unqualified', + to: '/leads/unqualified', + }, + { + id: 'proposal', + value: 15, + label: 'Proposal', + to: '/leads/proposal', + }, + { + id: 'negotiation', + value: 5, + label: 'Negotiation', + to: '/leads/negotiation', + }, + ], + }, + }, +]; + +export const mockLayouts: Layouts = { + lg: [ + { + i: 'widget-1', + x: 0, + y: 0, + w: 3, + h: 2, + }, + { + i: 'widget-2', + x: 6, + y: 0, + w: 3, + h: 5, + }, + { + i: 'widget-3', + x: 0, + y: 2, + w: 6, + h: 5, + }, + ], + md: [ + { + i: 'widget-1', + x: 0, + y: 0, + w: 3, + h: 2, + }, + { + i: 'widget-2', + x: 3, + y: 0, + w: 3, + h: 5, + }, + { + i: 'widget-3', + x: 6, + y: 0, + w: 6, + h: 5, + }, + ], + sm: [ + { + i: 'widget-1', + x: 0, + y: 0, + w: 1, + h: 2, + }, + { + i: 'widget-2', + x: 0, + y: 2, + w: 1, + h: 5, + }, + { + i: 'widget-3', + x: 0, + y: 7, + w: 1, + h: 5, + }, + ], +}; diff --git a/packages/twenty-front/src/pages/settings/page-layout/states/pageLayoutState.ts b/packages/twenty-front/src/pages/settings/page-layout/states/pageLayoutState.ts new file mode 100644 index 00000000000..f1ca2290966 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/page-layout/states/pageLayoutState.ts @@ -0,0 +1,17 @@ +import { type Layouts } from 'react-grid-layout'; +import { atom } from 'recoil'; +import { type Widget } from '../mocks/mockWidgets'; + +export const pageLayoutWidgetsState = atom({ + key: 'pageLayoutWidgetsState', + default: [], +}); + +export const pageLayoutLayoutsState = atom({ + key: 'pageLayoutLayoutsState', + default: { + lg: [], + md: [], + sm: [], + }, +}); diff --git a/yarn.lock b/yarn.lock index ccb82e6d22f..8ae6807baec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20631,6 +20631,15 @@ __metadata: languageName: node linkType: hard +"@types/react-grid-layout@npm:^1": + version: 1.3.5 + resolution: "@types/react-grid-layout@npm:1.3.5" + dependencies: + "@types/react": "npm:*" + checksum: 10c0/abd2a1dda9625c753ff2571a10b69740b2fb9ed1d3141755d54d5814cc12a9701c7c5cd78e8797e945486b441303b82543be71043a32d6a988b57a14237f93c6 + languageName: node + linkType: hard + "@types/react@npm:*, @types/react@npm:>=16, @types/react@npm:^19, @types/react@npm:^19.0.8": version: 19.1.0 resolution: "@types/react@npm:19.1.0" @@ -31538,6 +31547,13 @@ __metadata: languageName: node linkType: hard +"fast-equals@npm:^4.0.3": + version: 4.0.3 + resolution: "fast-equals@npm:4.0.3" + checksum: 10c0/87fd2609c945ee61e9ed4d041eb2a8f92723fc02884115f67e429dd858d880279e962334894f116b3e9b223f387d246e3db5424ae779287849015ddadbf5ff27 + languageName: node + linkType: hard + "fast-fifo@npm:^1.2.0, fast-fifo@npm:^1.3.2": version: 1.3.2 resolution: "fast-fifo@npm:1.3.2" @@ -44805,7 +44821,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.6.1, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": +"prop-types@npm:15.x, prop-types@npm:^15.6.1, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -45561,6 +45577,19 @@ __metadata: languageName: node linkType: hard +"react-draggable@npm:^4.0.3, react-draggable@npm:^4.4.6": + version: 4.5.0 + resolution: "react-draggable@npm:4.5.0" + dependencies: + clsx: "npm:^2.1.1" + prop-types: "npm:^15.8.1" + peerDependencies: + react: ">= 16.3.0" + react-dom: ">= 16.3.0" + checksum: 10c0/6f7591fe450555218bf0d9e31984be02451bf3f678fb121f51ac0a0a645d01a1b5ea8248ef9afddcd24239028911fd88032194b9c00b30ad5ece76ea13397fc3 + languageName: node + linkType: hard + "react-dropzone@npm:^14.2.3": version: 14.2.3 resolution: "react-dropzone@npm:14.2.3" @@ -45616,6 +45645,23 @@ __metadata: languageName: node linkType: hard +"react-grid-layout@npm:^1.5.2": + version: 1.5.2 + resolution: "react-grid-layout@npm:1.5.2" + dependencies: + clsx: "npm:^2.1.1" + fast-equals: "npm:^4.0.3" + prop-types: "npm:^15.8.1" + react-draggable: "npm:^4.4.6" + react-resizable: "npm:^3.0.5" + resize-observer-polyfill: "npm:^1.5.1" + peerDependencies: + react: ">= 16.3.0" + react-dom: ">= 16.3.0" + checksum: 10c0/b6605d1435fe116c3720d168100a5a08da924c6905686fe8a486c33b82abbde8ccacbb59e5c6243fa52f5e808ad393a7bdf0c09a3446ebf76efe43f29d9f13ee + languageName: node + linkType: hard + "react-helmet-async@npm:^1.3.0": version: 1.3.0 resolution: "react-helmet-async@npm:1.3.0" @@ -45930,6 +45976,18 @@ __metadata: languageName: node linkType: hard +"react-resizable@npm:^3.0.5": + version: 3.0.5 + resolution: "react-resizable@npm:3.0.5" + dependencies: + prop-types: "npm:15.x" + react-draggable: "npm:^4.0.3" + peerDependencies: + react: ">= 16.3" + checksum: 10c0/cfe50aa6efb79e0aa09bd681a5beab2fcd1186737c4952eb4c3974ed9395d5d263ccd1130961d06b8f5e24c8f544dd2967b5c740ce68719962d1771de7bdb350 + languageName: node + linkType: hard + "react-responsive@npm:^9.0.2": version: 9.0.2 resolution: "react-responsive@npm:9.0.2" @@ -46955,6 +47013,13 @@ __metadata: languageName: node linkType: hard +"resize-observer-polyfill@npm:^1.5.1": + version: 1.5.1 + resolution: "resize-observer-polyfill@npm:1.5.1" + checksum: 10c0/5e882475067f0b97dc07e0f37c3e335ac5bc3520d463f777cec7e894bb273eddbfecb857ae668e6fb6881fd6f6bb7148246967172139302da50fa12ea3a15d95 + languageName: node + linkType: hard + "resolve-alpn@npm:^1.0.0": version: 1.2.1 resolution: "resolve-alpn@npm:1.2.1" @@ -50899,6 +50964,7 @@ __metadata: "@types/apollo-upload-client": "npm:^17.0.2" "@types/file-saver": "npm:^2.0.7" "@types/js-cookie": "npm:^3.0.3" + "@types/react-grid-layout": "npm:^1" "@typescript-eslint/eslint-plugin": "npm:^8.39.0" "@typescript-eslint/parser": "npm:^8.39.0" "@typescript-eslint/utils": "npm:^8.39.0" @@ -50936,6 +51002,7 @@ __metadata: react-datepicker: "npm:^6.7.1" react-dropzone: "npm:^14.2.3" react-error-boundary: "npm:^4.0.11" + react-grid-layout: "npm:^1.5.2" react-helmet-async: "npm:^1.3.0" react-hook-form: "npm:^7.45.1" react-hotkeys-hook: "npm:^4.4.4"