From 4475d765c98fc386bc3618647a060deecce421e5 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 29 Aug 2024 16:39:54 +0200 Subject: [PATCH 01/20] feat(cubejs-client/playground): new query builder --- packages/cubejs-playground/package.json | 34 +- packages/cubejs-playground/src/App.tsx | 40 +- .../src/QueryBuilderV2/Pivot/Axes.tsx | 39 + .../QueryBuilderV2/Pivot/DroppableArea.tsx | 63 + .../src/QueryBuilderV2/Pivot/Item.tsx | 85 + .../src/QueryBuilderV2/Pivot/Options.tsx | 24 + .../src/QueryBuilderV2/Pivot/index.ts | 4 + .../src/QueryBuilderV2/QueryBuilder.tsx | 143 + .../src/QueryBuilderV2/QueryBuilderChart.tsx | 230 ++ .../QueryBuilderChartResults.tsx | 87 + .../QueryBuilderDevSidePanel.tsx | 503 +++ .../src/QueryBuilderV2/QueryBuilderError.tsx | 28 + .../src/QueryBuilderV2/QueryBuilderExtras.tsx | 496 +++ .../QueryBuilderV2/QueryBuilderFilters.tsx | 307 ++ .../QueryBuilderGeneratedSQL.tsx | 77 + .../QueryBuilderV2/QueryBuilderGraphQL.tsx | 140 + .../QueryBuilderV2/QueryBuilderInternals.tsx | 181 + .../src/QueryBuilderV2/QueryBuilderRest.tsx | 31 + .../QueryBuilderV2/QueryBuilderResults.tsx | 1215 +++++++ .../src/QueryBuilderV2/QueryBuilderSQL.tsx | 66 + .../QueryBuilderV2/QueryBuilderSidePanel.tsx | 412 +++ .../QueryBuilderV2/QueryBuilderToolBar.tsx | 117 + .../Accordion/Accordion.stories.tsx | 179 + .../components/Accordion/Accordion.tsx | 27 + .../components/Accordion/AccordionDetails.tsx | 127 + .../components/Accordion/AccordionItem.tsx | 139 + .../Accordion/AccordionItemTitle.tsx | 180 + .../Accordion/AccordionNestedContext.tsx | 7 + .../Accordion/AccordionProvider.tsx | 25 + .../components/Accordion/index.ts | 8 + .../components/Accordion/types.ts | 44 + .../AccordionCard/AccordionCard.tsx | 35 + .../components/AccordionCard/index.ts | 1 + .../src/QueryBuilderV2/components/Arrow.tsx | 35 + .../src/QueryBuilderV2/components/Badge.tsx | 58 + .../components/ChartRenderer.tsx | 558 ++++ .../QueryBuilderV2/components/CopyButton.tsx | 61 + .../QueryBuilderV2/components/CopyIcon.tsx | 90 + .../components/DateRangeFilter.tsx | 117 + .../components/DeleteFilterButton.tsx | 9 + .../components/EditQueryDialogForm.tsx | 260 ++ .../components/FilterByMemberButton.tsx | 52 + .../QueryBuilderV2/components/FilterLabel.tsx | 49 + .../components/FilteredLabel.tsx | 37 + .../src/QueryBuilderV2/components/Icon.tsx | 34 + .../QueryBuilderV2/components/ListButton.tsx | 21 + .../QueryBuilderV2/components/ListCube.tsx | 112 + .../QueryBuilderV2/components/ListMember.tsx | 140 + .../components/ListMemberButton.tsx | 40 + .../components/ListMemberOptionButton.tsx | 73 + .../QueryBuilderV2/components/LocalError.tsx | 20 + .../components/MemberFilter.tsx | 272 ++ .../QueryBuilderV2/components/MemberLabel.tsx | 60 + .../components/MemberLabelText.tsx | 45 + .../components/MemberSection.tsx | 45 + .../src/QueryBuilderV2/components/Panel.tsx | 137 + .../components/PreAggregationAlerts.tsx | 46 + .../components/QueryVisualization.tsx | 252 ++ .../components/ScrollableArea.tsx | 8 + .../components/ScrollableCodeContainer.tsx | 28 + .../components/SegmentFilter.tsx | 31 + .../components/SidePanelCubeItem.tsx | 490 +++ .../components/TabPaneWithToolbar.tsx | 63 + .../QueryBuilderV2/components/Tabs/Tabs.tsx | 209 ++ .../QueryBuilderV2/components/Tabs/index.ts | 1 + .../components/TimeDateRangeSelector.tsx | 46 + .../components/TimeDateSelector.tsx | 33 + .../components/TimeListMember.tsx | 185 ++ .../QueryBuilderV2/components/ValuesInput.tsx | 232 ++ .../src/QueryBuilderV2/context.tsx | 15 + .../src/QueryBuilderV2/hooks/auto-size.ts | 121 + .../hooks/debounced-callback.ts | 84 + .../QueryBuilderV2/hooks/debounced-state.ts | 22 + .../QueryBuilderV2/hooks/debounced-value.ts | 11 + .../QueryBuilderV2/hooks/deep-dependencies.ts | 12 + .../src/QueryBuilderV2/hooks/deep-memo.ts | 9 + .../src/QueryBuilderV2/hooks/event.ts | 19 + .../QueryBuilderV2/hooks/filtered-cubes.ts | 46 + .../QueryBuilderV2/hooks/filtered-members.ts | 48 + .../src/QueryBuilderV2/hooks/has-overflow.tsx | 22 + .../src/QueryBuilderV2/hooks/index.ts | 23 + .../QueryBuilderV2/hooks/interval-effect.ts | 40 + .../QueryBuilderV2/hooks/is-first-render.ts | 13 + .../src/QueryBuilderV2/hooks/list-mode.ts | 13 + .../src/QueryBuilderV2/hooks/local-storage.ts | 128 + .../src/QueryBuilderV2/hooks/outside-focus.ts | 27 + .../src/QueryBuilderV2/hooks/previous.ts | 18 + .../src/QueryBuilderV2/hooks/query-builder.ts | 1151 +++++++ .../src/QueryBuilderV2/hooks/raw-filter.ts | 26 + .../hooks/server-core-version-gte.ts | 18 + .../QueryBuilderV2/hooks/stored-timezones.ts | 29 + .../src/QueryBuilderV2/hooks/sync-ref.ts | 8 + .../src/QueryBuilderV2/hooks/uniq-id.ts | 9 + .../QueryBuilderV2/hooks/unmount-effect.ts | 13 + .../src/QueryBuilderV2/hooks/window-size.ts | 35 + .../src/QueryBuilderV2/icons/ArrowIcon.tsx | 59 + .../src/QueryBuilderV2/icons/Icon.tsx | 34 + .../src/QueryBuilderV2/icons/ItemInfoIcon.tsx | 33 + .../QueryBuilderV2/icons/NonPublicIcon.tsx | 23 + .../QueryBuilderV2/icons/PrimaryKeyIcon.tsx | 24 + .../src/QueryBuilderV2/icons/timestamp.svg | 4 + .../src/QueryBuilderV2/index.ts | 4 + .../src/QueryBuilderV2/types.ts | 64 + .../utils/are-queries-equal.tsx | 7 + .../src/QueryBuilderV2/utils/capitalize.ts | 10 + .../src/QueryBuilderV2/utils/chart-colors.ts | 104 + .../src/QueryBuilderV2/utils/contains.ts | 25 + .../utils/cube-sql-converter.ts | 401 +++ .../utils/format-date-by-granularity.tsx | 21 + .../src/QueryBuilderV2/utils/formatters.ts | 34 + .../utils/get-joined-cubes-and-members.ts | 64 + .../QueryBuilderV2/utils/get-query-hash.tsx | 37 + .../QueryBuilderV2/utils/get-type-icon.tsx | 15 + .../utils/graphql-converters.ts | 69 + .../src/QueryBuilderV2/utils/index.ts | 10 + .../src/QueryBuilderV2/utils/labels.ts | 6 + .../src/QueryBuilderV2/utils/loadable.ts | 79 + .../QueryBuilderV2/utils/move-pivot-config.ts | 35 + .../QueryBuilderV2/utils/prepare-query.tsx | 14 + .../QueryBuilderV2/utils/query-helpers.tsx | 16 + .../src/QueryBuilderV2/utils/timezones.js | 2546 ++++++++++++++ .../QueryBuilderV2/utils/use-commit-press.ts | 25 + .../utils/use-is-first-render.tsx | 13 + .../src/QueryBuilderV2/values.ts | 102 + .../QueryBuilderContainer.tsx | 92 +- .../src/pages/Explore/ExplorePage.tsx | 5 +- packages/cubejs-playground/vite.config.ts | 11 +- yarn.lock | 2910 +++++++++++------ 128 files changed, 16809 insertions(+), 1090 deletions(-) create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/Pivot/Axes.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/Pivot/DroppableArea.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/Pivot/Item.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/Pivot/Options.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/Pivot/index.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/QueryBuilder.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderChart.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderChartResults.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderDevSidePanel.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderError.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderExtras.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderFilters.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderGeneratedSQL.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderGraphQL.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderInternals.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderRest.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderResults.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderSQL.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderSidePanel.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderToolBar.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/Accordion.stories.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/Accordion.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionDetails.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionItem.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionItemTitle.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionNestedContext.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionProvider.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/index.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/types.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/AccordionCard/AccordionCard.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/AccordionCard/index.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/Arrow.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/Badge.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/ChartRenderer.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/CopyButton.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/CopyIcon.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/DateRangeFilter.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/DeleteFilterButton.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/EditQueryDialogForm.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/FilterByMemberButton.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/FilterLabel.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/FilteredLabel.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/Icon.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/ListButton.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/ListCube.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/ListMember.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/ListMemberButton.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/ListMemberOptionButton.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/LocalError.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/MemberFilter.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/MemberLabel.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/MemberLabelText.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/MemberSection.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/Panel.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/PreAggregationAlerts.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/QueryVisualization.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/ScrollableArea.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/ScrollableCodeContainer.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/SegmentFilter.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/SidePanelCubeItem.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/TabPaneWithToolbar.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/Tabs/Tabs.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/Tabs/index.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/TimeDateRangeSelector.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/TimeDateSelector.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/TimeListMember.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/components/ValuesInput.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/context.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/auto-size.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/debounced-callback.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/debounced-state.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/debounced-value.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/deep-dependencies.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/deep-memo.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/event.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/filtered-cubes.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/filtered-members.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/has-overflow.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/index.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/interval-effect.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/is-first-render.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/list-mode.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/local-storage.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/outside-focus.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/previous.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/query-builder.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/raw-filter.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/server-core-version-gte.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/stored-timezones.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/sync-ref.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/uniq-id.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/unmount-effect.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/hooks/window-size.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/icons/ArrowIcon.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/icons/Icon.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/icons/ItemInfoIcon.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/icons/NonPublicIcon.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/icons/PrimaryKeyIcon.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/icons/timestamp.svg create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/index.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/types.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/are-queries-equal.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/capitalize.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/chart-colors.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/contains.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/cube-sql-converter.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/format-date-by-granularity.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/formatters.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/get-joined-cubes-and-members.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/get-query-hash.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/get-type-icon.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/graphql-converters.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/index.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/labels.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/loadable.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/move-pivot-config.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/prepare-query.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/query-helpers.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/timezones.js create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/use-commit-press.ts create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/utils/use-is-first-render.tsx create mode 100644 packages/cubejs-playground/src/QueryBuilderV2/values.ts diff --git a/packages/cubejs-playground/package.json b/packages/cubejs-playground/package.json index 1f87bf7064e81..ee708e466c7d6 100644 --- a/packages/cubejs-playground/package.json +++ b/packages/cubejs-playground/package.json @@ -29,12 +29,14 @@ "homepage": ".", "license": "MIT", "dependencies": { + "@apollo/client": "^3.11.4", "@graphiql/toolkit": "^0.4.3", "anser": "^2.1.1", "camel-case": "^4.1.2", "codesandbox-import-utils": "^2.1.1", "cron-validator": "^1.2.1", "customize-cra": "^1.0.0", + "date-fns": "^3.6.0", "fast-deep-equal": "^3.1.3", "flexsearch": "^0.7.21", "graphiql": "^1.8.6", @@ -53,24 +55,26 @@ "react-is": "^16.8.4", "react-responsive": "^8.0.1", "react-router-dom": "^5.1.2", + "recharts": "^2.12.7", "sql-formatter": "^3.1.0", "throttle-debounce": "^3.0.1", - "uuid": "^8.3.2" + "uuid": "^8.3.2", + "vite-plugin-environment": "^1.1.3" }, "devDependencies": { "@ant-design/compatible": "^1.0.2", "@ant-design/icons": "^4.7.0", - "@cube-dev/ui-kit": "0.31.2", + "@cube-dev/ui-kit": "0.37.2", "@cubejs-client/core": "^0.35.23", "@cubejs-client/react": "^0.35.48", "@types/flexsearch": "^0.7.3", "@types/node": "^16", - "@types/react": "^17.0.3", + "@types/react": "^18.3.4", "@types/react-beautiful-dnd": "^13.0.0", - "@types/react-dom": "^17.0.2", - "@types/react-router": "^5.1.12", - "@types/react-router-dom": "^5.1.7", - "@types/styled-components": "^5.1.9", + "@types/react-dom": "^18.3.0", + "@types/react-router": "^5.1.20", + "@types/react-router-dom": "^5.3.3", + "@types/styled-components": "^5.1.34", "@types/uuid": "^8.3.1", "@vitejs/plugin-react": "^2.1.0", "antd": "4.16.13", @@ -81,25 +85,25 @@ "graphql": "^15.8.0", "jsdom": "^16.7.0", "prismjs": "^1.25.0", - "react": "^17.0.1", - "react-dom": "^17.0.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", "recursive-readdir": "^2.2.2", - "styled-components": "5.2.0", + "styled-components": "6.1.12", "tslib": "^2.3.0", "typescript": "~5.2.2", - "vite": "^4.5.0", + "vite": "^5.4.2", "vitest": "^0.34.6" }, "peerDependencies": { "@ant-design/icons": ">=4.7.0", - "@cube-dev/ui-kit": ">=0.30.0", + "@cube-dev/ui-kit": ">=0.37.2", "@cubejs-client/core": ">=0.30.0", "@cubejs-client/react": ">=0.30.0", "antd": ">=4.16.13", "graphql": ">=15.8.0", "prismjs": ">=1.25.0", - "react": ">=17.0.1", - "react-dom": ">=17.0.1", - "styled-components": ">=5.2.0" + "react": ">=18.0.0", + "react-dom": ">=18.0.0", + "styled-components": ">=6.0.0" } } diff --git a/packages/cubejs-playground/src/App.tsx b/packages/cubejs-playground/src/App.tsx index a48a5910f7efb..bcf93315b70d4 100755 --- a/packages/cubejs-playground/src/App.tsx +++ b/packages/cubejs-playground/src/App.tsx @@ -103,24 +103,28 @@ class App extends Component { - - - - -
- - - {fatalError ? ( - - ) : ( - children - )} - - + + + +
+ + + {fatalError ? ( + + ) : ( + children + )} + ); diff --git a/packages/cubejs-playground/src/QueryBuilderV2/Pivot/Axes.tsx b/packages/cubejs-playground/src/QueryBuilderV2/Pivot/Axes.tsx new file mode 100644 index 0000000000000..d3f12ed04bd65 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/Pivot/Axes.tsx @@ -0,0 +1,39 @@ +import { DragDropContext } from 'react-beautiful-dnd'; +import { Grid } from '@cube-dev/ui-kit'; + +import { QueryBuilderContextProps } from '../types'; + +import { PivotDroppableArea } from './DroppableArea'; + +export function PivotAxes({ + pivotConfig, + onMove, +}: { + pivotConfig: QueryBuilderContextProps['pivotConfig']; + onMove: QueryBuilderContextProps['updatePivotConfig']['moveItem']; +}) { + return ( + { + if (!destination) { + return; + } + onMove({ + sourceIndex: source.index, + destinationIndex: destination.index, + sourceAxis: source.droppableId, + destinationAxis: destination.droppableId, + }); + }} + > + +
+ +
+
+ +
+
+
+ ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/Pivot/DroppableArea.tsx b/packages/cubejs-playground/src/QueryBuilderV2/Pivot/DroppableArea.tsx new file mode 100644 index 0000000000000..117b136874431 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/Pivot/DroppableArea.tsx @@ -0,0 +1,63 @@ +import { memo } from 'react'; +import { Flow, tasty } from '@cube-dev/ui-kit'; +import { Droppable } from 'react-beautiful-dnd'; + +import { QueryBuilderContextProps } from '../types'; + +import { PivotItem } from './Item'; + +const HeaderElement = tasty({ + styles: { + display: 'grid', + preset: 'h6', + color: '#dark', + placeContent: 'center', + fill: '#light', + padding: '8px 16px', + border: 'bottom', + }, +}); + +const Header = memo(({ axis }: { axis: string }) => { + return {axis.toUpperCase()} axis; +}); + +export function PivotDroppableArea({ + pivotConfig, + axis, +}: { + pivotConfig: QueryBuilderContextProps['pivotConfig']; + axis: string; +}) { + return ( + <> +
+ +
+ + {(provided) => ( + + {/* @ts-ignore */} + {pivotConfig[axis].map((id, index) => { + let type: 'timeDimension' | 'dimension' | 'measure' = id.includes('.') + ? id.split('.').length === 3 + ? 'timeDimension' + : 'dimension' + : 'measure'; + + return ; + })} + + {provided.placeholder} + + )} + +
+ + ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/Pivot/Item.tsx b/packages/cubejs-playground/src/QueryBuilderV2/Pivot/Item.tsx new file mode 100644 index 0000000000000..3bda12a9f04d9 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/Pivot/Item.tsx @@ -0,0 +1,85 @@ +import { Draggable } from 'react-beautiful-dnd'; +import { DragOutlined } from '@ant-design/icons'; +import { tasty } from '@cube-dev/ui-kit'; + +import { MemberLabelText } from '../components/MemberLabelText'; +import { MemberBadge } from '../components/Badge'; + +const PivotItemElement = tasty({ + styles: { + display: 'grid', + flow: 'row', + gridColumns: 'min-content 1fr', + gap: '1x', + placeContent: 'center start', + placeItems: 'center stretch', + radius: true, + padding: '.5x 1x', + preset: 't3m', + + color: { + '': '#dark', + '[data-type="dimension"]': '#dimension-text', + '[data-type="measure"]': '#measure-text', + '[data-type="time-dimension"]': '#time-dimension-text', + }, + fill: { + '': '#dark.15', + '[data-type="dimension"]': '#dimension-hover', + '[data-type="measure"]': '#measure-hover', + '[data-type="timeDimension"]': '#time-dimension-hover', + }, + }, +}); + +export function PivotItem({ + id, + index, + type, +}: { + id: string; + index: number; + type: 'timeDimension' | 'dimension' | 'measure'; +}) { + return ( + + {({ draggableProps, dragHandleProps, innerRef }) => { + const arr = id.split('.'); + + return ( + // + + + + {arr.length > 1 ? ( + <> + + {arr[0]} + . + {arr[1]} + + {arr[2] ? ( + + + {arr[2]} + + + ) : null} + + ) : ( + {id} + )} + + + // + ); + }} + + ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/Pivot/Options.tsx b/packages/cubejs-playground/src/QueryBuilderV2/Pivot/Options.tsx new file mode 100644 index 0000000000000..58647f710f364 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/Pivot/Options.tsx @@ -0,0 +1,24 @@ +import { Checkbox } from '@cube-dev/ui-kit'; + +import { QueryBuilderContextProps } from '../types'; + +export function PivotOptions({ + pivotConfig, + onUpdate, +}: { + pivotConfig: QueryBuilderContextProps['pivotConfig']; + onUpdate: QueryBuilderContextProps['updatePivotConfig']['update']; +}) { + return pivotConfig ? ( + + onUpdate({ + fillMissingDates: !pivotConfig.fillMissingDates, + }) + } + > + Fill Missing Dates + + ) : null; +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/Pivot/index.ts b/packages/cubejs-playground/src/QueryBuilderV2/Pivot/index.ts new file mode 100644 index 0000000000000..b1a31ae3b07d7 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/Pivot/index.ts @@ -0,0 +1,4 @@ +export * from './Axes'; +export * from './DroppableArea'; +export * from './Item'; +export * from './Options'; diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilder.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilder.tsx new file mode 100644 index 0000000000000..51967f3448241 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilder.tsx @@ -0,0 +1,143 @@ +import { useEffect, useMemo } from 'react'; +import cube, { Query } from '@cubejs-client/core'; +import { Alert, Block, Card, PrismCode, Title } from '@cube-dev/ui-kit'; + +import { useLocalStorage } from './hooks'; +import { QueryBuilderProps } from './types'; +import { QueryBuilderContext } from './context'; +import { useQueryBuilder } from './hooks/query-builder'; +import { QueryBuilderInternals } from './QueryBuilderInternals'; +import { useCommitPress } from './utils/use-commit-press'; + +export function QueryBuilder( + props: Omit & { apiUrl: string | null } +) { + const { + apiUrl, + apiToken, + defaultChartType, + defaultPivotConfig, + onQueryChange, + defaultQuery, + shouldRunDefaultQuery, + schemaVersion, + tracking, + isApiBlocked, + apiVersion, + VizardComponent, + RequestStatusComponent, + openSqlRunner, + } = props; + + const cubeApi = useMemo(() => { + return apiUrl && apiToken && apiToken !== 'undefined' + ? cube(apiToken, { + apiUrl, + }) + : undefined; + }, [apiUrl, apiToken]); + + const [storedTimezones] = useLocalStorage( + 'QueryBuilder:timezones', + [] + ); + + function queryValidator(query: Query) { + const queryCopy = JSON.parse(JSON.stringify(query)); + + if ( + typeof queryCopy.limit !== 'number' || + queryCopy.limit < 1 || + queryCopy.limit > 50_000 + ) { + queryCopy.limit = 5_000; + } + + /** + * @TODO: Add support for offset + */ + delete queryCopy.offset; + + if (!queryCopy.timezone && storedTimezones[0]) { + queryCopy.timezone = storedTimezones[0]; + } + + return queryCopy; + } + + const { + runQuery, + cubes, + isCubeJoined, + joinedCubes, + getCubeByName, + meta, + loadMeta, + metaError, + richMetaError, + selectCube, + selectedCube, + ...otherProps + } = useQueryBuilder({ + cubeApi, + defaultQuery, + defaultChartType, + defaultPivotConfig, + schemaVersion, + onQueryChange, + tracking, + queryValidator, + }); + + useEffect(() => { + if (defaultQuery && shouldRunDefaultQuery && meta) { + void runQuery(); + } + }, [shouldRunDefaultQuery, meta]); + + useCommitPress(() => { + return runQuery(); + }, true); + + return apiToken && cubeApi && apiUrl ? ( + + {!meta ? ( + + {!metaError ? ( + Loading meta information... + ) : ( + + Unable to load meta data. + + + )} + + ) : ( + + )} + + ) : null; +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderChart.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderChart.tsx new file mode 100644 index 0000000000000..fc7369ee262bf --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderChart.tsx @@ -0,0 +1,230 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Badge, + Button, + Dialog, + DialogTrigger, + Divider, + Header, + Radio, + Skeleton, + Space, + Title, +} from '@cube-dev/ui-kit'; +import { + AreaChartOutlined, + BarChartOutlined, + CodeOutlined, + LineChartOutlined, + LoadingOutlined, + TableOutlined, +} from '@ant-design/icons'; +import { ChartType } from '@cubejs-client/core'; + +import { useLocalStorage } from './hooks'; +import { useQueryBuilderContext } from './context'; +import { PivotAxes, PivotOptions } from './Pivot'; +import { ArrowIcon } from './icons/ArrowIcon'; +import { AccordionCard } from './components/AccordionCard'; +import { QueryBuilderChartResults } from './QueryBuilderChartResults'; + +const CHART_HEIGHT = 400; +const MAX_SERIES_LIMIT = 25; + +interface QueryBuilderChartProps { + maxHeight?: number; + onToggle?: (isExpanded: boolean) => void; +} + +const ALLOWED_CHART_TYPES = ['table', 'line', 'bar', 'area']; + +export function QueryBuilderChart(props: QueryBuilderChartProps) { + const [isVizardLoaded, setIsVizardLoaded] = useState(false); + const [isExpanded, setIsExpanded] = useLocalStorage('QueryBuilder:Chart:expanded', false); + const { maxHeight = CHART_HEIGHT, onToggle } = props; + let { + query, + isLoading, + isQueryTouched, + executedQuery, + chartType, + setChartType, + pivotConfig, + updatePivotConfig, + resultSet, + apiToken, + apiUrl, + VizardComponent, + } = useQueryBuilderContext(); + const isOutdated = executedQuery && isQueryTouched; + const containerRef = useRef(null); + + if (!ALLOWED_CHART_TYPES.includes(chartType || '')) { + chartType = 'line'; + } + + useEffect(() => { + const element = containerRef.current; + + if (!element) { + return; + } + + const onScroll = () => { + if (chartType !== 'table') { + element.scrollTop = 0; + + setTimeout(() => { + element.scrollTop = 0; + }); + } + }; + + element.addEventListener('scroll', onScroll); + + return () => { + element.removeEventListener('scroll', onScroll); + }; + }, [containerRef.current]); + + const chart = useMemo( + () => ( + + ), + [resultSet, chartType, isLoading, pivotConfig, isExpanded] + ); + + const onMove = useCallback( + (arg) => { + return updatePivotConfig.moveItem(arg); + }, + [updatePivotConfig] + ); + + const onUpdate = useCallback( + (arg) => { + return updatePivotConfig.update(arg); + }, + [updatePivotConfig] + ); + + const pivotConfigurator = useMemo(() => { + return pivotConfig ? ( + + + + + +
+ +
+
+
+ ) : undefined; + }, [pivotConfig, onMove, onUpdate]); + + return ( + + ) : undefined + ) : isOutdated ? ( + OUTDATED + ) : undefined + } + extra={ + isExpanded ? ( + + { + setChartType(val as ChartType); + }} + > + + + + Line + + + + + + Bar + + + + + + Area + + + + + + Table + + + + {pivotConfigurator} + {VizardComponent ? ( + + + {/**/} + {/**/} + +
+ Chart Prototyping +
+ {isVizardLoaded ? ( + + ) : null} +
+
+ ) : null} +
+ ) : ( +
+ ) + } + onToggle={(open) => { + setIsExpanded(open); + onToggle?.(open); + }} + > + <> + {isLoading ? : undefined} + {chart} + + + ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderChartResults.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderChartResults.tsx new file mode 100644 index 0000000000000..8fb2ad08bbddd --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderChartResults.tsx @@ -0,0 +1,87 @@ +import { Grid, Title, Paragraph, tasty } from '@cube-dev/ui-kit'; +import { ChartType, PivotConfig, Query, ResultSet } from '@cubejs-client/core'; +import { RefObject } from 'react'; + +import { PlaygroundChartRenderer } from './components/ChartRenderer'; + +interface QueryBuilderChartResultsProps { + resultSet: ResultSet | null; + isLoading: boolean; + query: Query; + pivotConfig: PivotConfig; + chartType: ChartType; + isExpanded: boolean; + overflow?: string; + containerRef?: RefObject; +} + +const MAX_HEIGHT = 400; +const MAX_SERIES_LIMIT = 25; + +const ChartContainer = tasty({ + qa: 'QueryBuilderChart', + styles: { + overflow: 'hidden', + padding: '1x 1x 0 0', + styledScrollbar: true, + }, +}); + +export function QueryBuilderChartResults({ + resultSet, + isLoading, + query, + pivotConfig, + chartType, + isExpanded, + containerRef, + overflow = 'clip', +}: QueryBuilderChartResultsProps) { + const isChartTooBig = resultSet && resultSet?.seriesNames(pivotConfig).length > MAX_SERIES_LIMIT; + + if (resultSet && !isLoading && isExpanded) { + if (isChartTooBig) { + return ( + + + The chart is too big to display + + + There are too many sets of data to display on a single chart. Try to reduce the number + of dimensions. + + + ); + } else { + return ( + + + + ); + } + } else if (!isLoading && isExpanded) { + return ( + + + No data available + + Query metrics and dimensions with results to see the chart. + + ); + } + + return null; +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderDevSidePanel.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderDevSidePanel.tsx new file mode 100644 index 0000000000000..2a49f32074e52 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderDevSidePanel.tsx @@ -0,0 +1,503 @@ +import { + Badge, + Block, + Button, + DialogContainer, + Divider, + Flex, + Grid, + Radio, + SearchInput, + Space, + tasty, + Text, + Title, +} from '@cube-dev/ui-kit'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { EditOutlined } from '@ant-design/icons'; +import { TCubeDimension, validateQuery } from '@cubejs-client/core'; + +import { useDeepMemo, useEvent, usePrevious } from './hooks'; +import { Panel } from './components/Panel'; +import { QueryVisualization } from './components/QueryVisualization'; +import { ListCube } from './components/ListCube'; +import { ListMember } from './components/ListMember'; +import { useQueryBuilderContext } from './context'; +import { TimeListMember } from './components/TimeListMember'; +import { EditQueryDialogForm } from './components/EditQueryDialogForm'; +import { useFilteredMembers } from './hooks/filtered-members'; +import { useFilteredCubes } from './hooks/filtered-cubes'; +import { MemberSection } from './components/MemberSection'; + +const RadioButton = tasty(Radio.Button, { + styles: { flexGrow: 1, placeItems: 'stretch' }, + inputStyles: { textAlign: 'center' }, +}); + +const CountBadge = tasty(Badge, { + styles: { + fill: '#purple', + border: '#purple', + color: '#white', + padding: '0 1ow', + }, +}); + +const StyledDivider = tasty(Divider, { + styles: { + gridArea: 'initial', + margin: '0 -1x', + }, +}); + +export function QueryBuilderDevSidePanel() { + const { + query, + queryHash, + cubes: items = [], + selectCube, + isQueryEmpty, + dateRanges, + measures: measuresUpdater, + dimensions: dimensionsUpdater, + segments: segmentsUpdater, + grouping, + filters, + joinableCubes, + isCubeJoined, + selectedCube, + meta, + apiVersion, + setQuery, + } = useQueryBuilderContext(); + const [isPasteDialogOpen, setIsPasteDialogOpen] = useState(false); + const [filterString, setFilterString] = useState(''); + const previousFilterString = usePrevious(filterString); + const isMemberFilterOnly = useRef(false); + + const contentRef = useRef(null); + const [selectedType, setSelectedType] = useState<'cubes' | 'views'>('cubes'); + + items.sort((a, b) => a.name.localeCompare(b.name)); + + const cubes = items + // @ts-ignore + .filter((item) => item.type === 'cube') + .filter((cube) => joinableCubes.includes(cube)); + // @ts-ignore + const views = items.filter((item) => item.type === 'view'); + + const preparedFilterString = filterString.trim().replaceAll('_', ' '); + + // Filtered members + const measures = selectedCube?.measures || []; + const dimensions = selectedCube?.dimensions || []; + const segments = selectedCube?.segments || []; + + const { + measures: shownMeasures, + dimensions: shownDimensions, + segments: shownSegments, + } = useFilteredMembers(preparedFilterString, { + measures, + dimensions, + segments, + }); + + // Filtered cubes + const { + cubes: shownCubes, + membersByCube: filteredMembersByCube, + isFiltered: areCubesFiltered, + } = useFilteredCubes(preparedFilterString, selectedType === 'cubes' ? cubes : views); + const totalCubes = (selectedType === 'cubes' ? cubes : views).length; + const connectedCubes = joinableCubes.filter((cube) => !isCubeJoined(cube.name)); + + const onItemSelect = useEvent((cubeName: string) => { + selectCube(cubeName); + }); + + const resetScrollAndContentSize = useCallback(() => { + if (contentRef?.current) { + const element = contentRef.current; + + element.scrollTop = 0; + + setTimeout(() => { + element.scrollTop = 0; + }, 0); + } + }, [contentRef?.current]); + + const editQueryButton = useMemo( + () => ( + + + + + + {(provided) => ( + + {allFields.map((name, index) => { + const memberType = getMemberType(name); + + return ( + + {({ draggableProps, dragHandleProps, innerRef }) => ( + + )} + + ); + })} + + {provided.placeholder} + + )} + + + + + + ); + }, [JSON.stringify(order.map), JSON.stringify(allFields), showOrder]); + + const limitSelector = useMemo(() => { + const limit = query.limit || 5_000; + const options = limitOptionValues.includes(limit) + ? limitOptions + : [ + { key: query?.limit, label: formatNumber(limit) }, + ...limitOptions, + ].sort((a, b) => (a.key as number) - (b.key as number)); + + return ( + + ); + }, [query.limit]); + + const timezoneSelector = useMemo(() => { + const timezone = query?.timezone || ''; + const optionsWithStored = [...allTimeZones]; + + [...storedTimezones].reverse().forEach((name) => { + if (!availableTimeZones.includes(name)) { + optionsWithStored.unshift(timezoneByName(name)); + } else { + const option = optionsWithStored.find((tz) => tz.tzCode === name); + + if (option) { + optionsWithStored.splice(optionsWithStored.indexOf(option), 1); + optionsWithStored.unshift(option); + } + } + }); + + const options = optionsWithStored.map((tz) => tz.tzCode).includes(timezone) + ? optionsWithStored + : [timezoneByName(timezone), ...optionsWithStored]; + + return ( + { + const timezone = val as string; + + updateQuery(() => ({ + timezone: timezone === '' ? undefined : timezone, + })); + }} + > + {options.map((tz) => { + const name = tz.tzCode; + const zone = tz.utc; + + return ( + + + + {name || 'UTC (Default)'} + + {zone ? ( + + GMT{zone} + + ) : undefined} + + + ); + })} + + ); + }, [query?.timezone, storedTimezones.join('::')]); + + return ( + + {timezoneSelector} + + {orderSelector} + {limitSelector} + + + ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderFilters.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderFilters.tsx new file mode 100644 index 0000000000000..4e018fb845b2f --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderFilters.tsx @@ -0,0 +1,307 @@ +import { useEffect, useRef, useState } from 'react'; +import { Block, Button, Divider, Flow, Menu, MenuTrigger, Space, tasty } from '@cube-dev/ui-kit'; +import { PlusOutlined } from '@ant-design/icons'; +import { TCubeDimension, TCubeMeasure } from '@cubejs-client/core'; + +import { useQueryBuilderContext } from './context'; +import { getTypeIcon } from './utils'; +import { useListMode } from './hooks/list-mode'; +import { AccordionCard } from './components/AccordionCard'; +import { ScrollableArea } from './components/ScrollableArea'; +import { DateRangeFilter } from './components/DateRangeFilter'; +import { MemberBadge } from './components/Badge'; +import { MemberFilter } from './components/MemberFilter'; +import { SegmentFilter } from './components/SegmentFilter'; + +const BadgeContainer = tasty(Space, { + styles: { + gap: '.5x', + transition: 'opacity', + opacity: { + '': 1, + hidden: 0, + }, + }, +}); + +export function QueryBuilderFilters({ onToggle }: { onToggle?: (isExpanded: boolean) => void }) { + const [listMode] = useListMode(); + const filtersRef = useRef(null); + const { + selectedCube, + segments: segmentsUpdater, + dateRanges, + members, + filters: filtersUpdater, + query, + queryStats, + } = useQueryBuilderContext(); + + const isCompact = + Object.keys(queryStats).length === 1 && + ((selectedCube && selectedCube === queryStats[selectedCube?.name]?.instance) || !selectedCube); + const timeDimensions = query.timeDimensions || []; + const filters = query.filters || []; + const segments = query.segments || []; + const timeCounter = dateRanges.list.length; + const segmentsCounter = segments.length; + + const measureCounter = filters.filter((filter) => { + if (!('member' in filter) || !filter.member) { + return false; + } + + return !!members.measures[filter.member]; + }).length; + + const dimensionCounter = filters.filter((filter) => { + if (!('member' in filter) || !filter.member) { + return false; + } + + return !!members.dimensions[filter.member]; + }).length; + + const availableTimeDimensions = + selectedCube?.dimensions.filter((member) => { + return member.type === 'time' && !dateRanges.list.includes(member.name); + }) || []; + + const isFiltered = filters.length > 0 || segments.length > 0 || dateRanges.list.length > 0; + + const [isExpanded, setIsExpanded] = useState(isFiltered); + + useEffect(() => { + setIsExpanded(isFiltered); + }, [isFiltered]); + + const availableMeasuresAndDimensions = [ + ...(selectedCube?.dimensions || []), + ...(selectedCube?.measures || []), + // ...(selectedCube?.timeDimensions || []), + ]; + + const availableSegments = + selectedCube?.segments.filter((member) => { + return !segments.includes(member.name); + }) || []; + + function getMemberType(member: TCubeMeasure | TCubeDimension) { + if (!member?.name) { + return undefined; + } + + if (members.measures[member.name]) { + return 'measure'; + } + if (members.dimensions[member.name]) { + return 'dimension'; + } + + return undefined; + } + + function addDateRange(name: string) { + dateRanges.set(name); + } + + function addSegment(name: string) { + segmentsUpdater?.add(name); + } + + function addFilter(name: string) { + filtersUpdater.add({ member: name, operator: 'set' }); + } + + useEffect(() => { + ( + filtersRef?.current?.querySelector('button[data-is-invalid]') as HTMLButtonElement | undefined + )?.click(); + }, [dateRanges.list.length]); + + useEffect(() => { + const invalidTime = filtersRef?.current?.querySelector('button[data-is-invalid]') as + | HTMLButtonElement + | undefined; + + if (invalidTime) { + return; + } + + ( + [...(filtersRef?.current?.querySelectorAll('button') ?? [])].slice(-1)[0] as + | HTMLButtonElement + | undefined + )?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, [query?.filters?.length, dateRanges.list.length, segments?.length]); + + return ( + + {timeCounter ? ( + {timeCounter} + ) : undefined} + {dimensionCounter ? ( + {dimensionCounter} + ) : undefined} + {measureCounter ? ( + {measureCounter} + ) : undefined} + {segmentsCounter ? ( + {segmentsCounter} + ) : undefined} + + ) : undefined + } + contentStyles={{ border: 'top' }} + onToggle={(isExpanded) => { + setIsExpanded(isExpanded); + onToggle?.(isExpanded); + }} + > + + + {!isFiltered ? No filters set : null} + {dateRanges.list.map((dimensionName, i) => { + const timeDimension = timeDimensions.find( + (timeDimension) => timeDimension.dimension === dimensionName + ); + + const dimension = members.dimensions[dimensionName]; + + return ( + { + dateRanges.remove(dimensionName); + }} + onChange={(dateRange) => { + dateRanges.set(dimensionName, dateRange); + }} + /> + ); + })} + {filters.map((filter, index) => { + if (!('member' in filter) || !filter.member) { + return null; + } + + const member = members.measures[filter.member] || members.dimensions[filter.member]; + + return ( + { + filtersUpdater.remove(index); + }} + onChange={(updatedFilter) => { + filtersUpdater.update(index, updatedFilter); + }} + /> + ); + })} + {segments.map((segment, i) => { + const member = members.segments[segment]; + + return ( + { + segmentsUpdater?.remove(segment); + }} + /> + ); + })} + + {listMode === 'dev' ? ( + <> + {isFiltered ? : undefined} + + + + addFilter(name as string)}> + {availableMeasuresAndDimensions.map((dimension) => { + return ( + + + {getTypeIcon(dimension.type)} + {dimension.name.split('.')[1]} + + + ); + })} + + + + + addDateRange(name as string)}> + {availableTimeDimensions.map((dimension) => { + return ( + + + {getTypeIcon('time')} + {dimension.name.split('.')[1]} + + + ); + })} + + + + + addSegment(name as string)}> + {availableSegments.map((segment) => { + return {segment.name.split('.')[1]}; + })} + + + {!selectedCube && Select a cube or a view to add filters} + + + ) : null} + + + ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderGeneratedSQL.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderGeneratedSQL.tsx new file mode 100644 index 0000000000000..12f34fc803f2d --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderGeneratedSQL.tsx @@ -0,0 +1,77 @@ +import { PlayCircleOutlined } from '@ant-design/icons'; +import { Alert, Block, Button, tasty } from '@cube-dev/ui-kit'; +import { QueryRenderer } from '@cubejs-client/react'; +import sqlFormatter from 'sql-formatter'; + +import { CopyButton } from './components/CopyButton'; +import { useDeepMemo } from './hooks'; +import { useQueryBuilderContext } from './context'; +import { ScrollableCodeContainer } from './components/ScrollableCodeContainer'; +import { TabPaneWithToolbar } from './components/TabPaneWithToolbar'; + +const EditSQLQueryButton = tasty(Button, { + size: 'small', + icon: , + children: 'Open in SQL Runner', +}); + +export function QueryBuilderGeneratedSQL() { + let { query, queryHash, cubeApi, isQueryEmpty, verificationError, openSqlRunner } = + useQueryBuilderContext(); + + return useDeepMemo(() => { + if (!isQueryEmpty) { + if (verificationError) { + return ( + + {verificationError.toString()} + + ); + } + + return ( + { + if (error) { + return ( + + {error.toString()} + + ); + } + + // in the case of a compareDateRange query the SQL will be the same + const [query] = Array.isArray(sqlQuery) ? sqlQuery : [sqlQuery]; + const value = query && sqlFormatter.format(query.sql()); + + return ( + + + Copy + + {openSqlRunner ? ( + openSqlRunner(value)} /> + ) : undefined} + + } + > + + + ); + }} + /> + ); + } else { + return ( + + Compose a query to see a generated SQL. + + ); + } + }, [queryHash, verificationError]); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderGraphQL.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderGraphQL.tsx new file mode 100644 index 0000000000000..e9882cedfea2c --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderGraphQL.tsx @@ -0,0 +1,140 @@ +import { PlayCircleOutlined } from '@ant-design/icons'; +import { ApolloClient, ApolloLink, gql, HttpLink, InMemoryCache, useQuery } from '@apollo/client'; +import { RetryLink } from '@apollo/client/link/retry'; +import { Alert, Block, Button, Grid, tasty } from '@cube-dev/ui-kit'; +import { useEffect, useMemo, useState } from 'react'; + +import { useDeepMemo } from './hooks'; +import { useQueryBuilderContext } from './context'; +import { convertJsonQueryToGraphQL } from './utils/graphql-converters'; +import { CopyButton } from './components/CopyButton'; +import { TabPaneWithToolbar } from './components/TabPaneWithToolbar'; +import { ScrollableCodeContainer } from './components/ScrollableCodeContainer'; + +const retryLink = new RetryLink({ + delay: { + initial: 500, + max: Infinity, + jitter: true, + }, + attempts: { + max: 5, + retryIf: (error) => !!error, + }, +}); + +const Container = tasty({ + styles: { + position: 'relative', + placeSelf: 'stretch', + }, +}); + +// Remove all keys that starts with "__" from the object recursively +function cleanServiceKeys(obj: any) { + if (obj && typeof obj === 'object') { + Object.keys(obj).forEach((key) => { + if ((key as string).startsWith('__')) { + delete obj[key]; + } else { + cleanServiceKeys(obj[key]); + } + }); + } + + return obj; +} + +export function QueryBuilderGraphQL() { + const { query, isQueryTouched, joinedMembers, queryHash, isQueryEmpty, apiUrl, apiToken, meta } = + useQueryBuilderContext(); + const [isFetching, setIsFetching] = useState(false); + + const gqlQuery = useDeepMemo(() => { + if (isQueryEmpty || !meta) { + return 'query { __typename }'; // Empty query + } + + return convertJsonQueryToGraphQL({ meta, query }); + }, [queryHash, isQueryEmpty, meta]); + + const gqlClient = useMemo(() => { + const httpLink = new HttpLink({ + uri: apiUrl.replace('/v1', '/graphql'), + headers: { + Authorization: apiToken || '', + }, + }); + + return new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.from([retryLink, httpLink]), + }); + }, [apiUrl, apiToken]); + + const { + data: rawData, + loading: isLoading, + error: queryError, + } = useQuery(gql(gqlQuery), { + client: gqlClient, + skip: !isFetching, + fetchPolicy: 'network-only', + }); + + if (rawData) { + cleanServiceKeys(rawData); + } + + useEffect(() => { + if (isQueryTouched) { + setIsFetching(false); + } + }, [queryHash]); + + return useMemo(() => { + return !query || isQueryEmpty ? ( + + Compose a query to see a GraphQL query. + + ) : ( + + Copy + + } + extraActions={ + !rawData && !queryError ? ( + + ) : null + } + > + + + + + {rawData || queryError ? ( + + + + ) : null} + + + ); + }, [rawData, gqlQuery, isFetching, isQueryTouched]); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderInternals.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderInternals.tsx new file mode 100644 index 0000000000000..3b4d9aa0bf275 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderInternals.tsx @@ -0,0 +1,181 @@ +import { Block, Flow, tasty } from '@cube-dev/ui-kit'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; + +import { useAutoSize, useEvent, useListMode, useLocalStorage } from './hooks'; +import { useQueryBuilderContext } from './context'; +import { Panel } from './components/Panel'; +import { Tabs, Tab } from './components/Tabs'; +import { QueryBuilderFilters } from './QueryBuilderFilters'; +import { QueryBuilderChart } from './QueryBuilderChart'; +import { QueryBuilderResults } from './QueryBuilderResults'; +import { QueryBuilderToolBar } from './QueryBuilderToolBar'; +import { QueryBuilderGeneratedSQL } from './QueryBuilderGeneratedSQL'; +import { QueryBuilderSQL } from './QueryBuilderSQL'; +import { QueryBuilderRest } from './QueryBuilderRest'; +import { QueryBuilderGraphQL } from './QueryBuilderGraphQL'; +import { QueryBuilderSidePanel } from './QueryBuilderSidePanel'; +import { QueryBuilderDevSidePanel } from './QueryBuilderDevSidePanel'; +import { QueryBuilderExtras } from './QueryBuilderExtras'; + +// The minimum size of the area below the top edge of the chart +// when we can show both results and the chart at the same time. +const CHART_THRESHOLD = 450; + +const Divider = tasty({ + styles: { + width: '100%', + height: '1ow 1ow', + fill: '#border', + }, +}); + +type Tab = 'results' | 'generated-sql' | 'json' | 'graphql' | 'sql'; + +const QueryBuilderPanel = tasty(Panel, { + isStretched: true, + qa: 'QueryBuilder', + gridColumns: '42x 1ow minmax(0, 1fr)', + styles: { + fill: '#white', + + '@time-dimension-strong-color': 'rgb(23, 70, 13)', // 35 / 0.1 + '@time-dimension-text-color': 'rgb(65, 113, 57)', // 50 / 0.1 + '@time-dimension-active-color': 'rgb(199, 219, 195)', // 87 / 0.4 + '@time-dimension-hover-color': 'rgb(228, 244, 225)', // 95 / 0.3 + + '@measure-strong-color': 'rgb(76, 55, 0)', // 35 / 0.1 + '@measure-text-color': 'rgb(126, 94, 7)', // 50 / 0.1 + '@measure-active-color': 'rgb(225, 210, 183)', // 87 / 0.4 + '@measure-hover-color': 'rgb(248, 241, 227)', // 95 / 0.3 + + '@dimension-strong-color': 'rgb(35, 54, 110)', // 35 / 0.1 + '@dimension-text-color': 'rgb(74, 96, 156)', // 50 / 0.1 + '@dimension-active-color': 'rgb(200, 212, 239)', // 87 / 0.4 + '@dimension-hover-color': 'rgb(231, 238, 255)', // 95 / 0.3 + + '@segment-strong-color': 'rgb(72, 41, 98)', // 35 / 0.1 + '@segment-text-color': 'rgb(114, 83, 144)', // 50 / 0.1 + '@segment-active-color': 'rgb(219, 206, 233)', // 87 / 0.4 + '@segment-hover-color': 'rgb(244, 234, 255)', // 95 / 0.3 + + '@filter-strong-color': 'rgb(95, 31, 64)', // 35 / 0.1 + '@filter-text-color': 'rgb(142, 73, 106)', // 50 / 0.1 + '@filter-active-color': 'rgb(255, 191, 218)', // 87 / 0.4 + '@filter-hover-color': 'rgb(255, 231, 240)', // 95 / 0.3 + + '@missing-strong-color': 'rgb(58, 58, 58)', // 35 / 0 + '@missing-text-color': 'rgb(99, 99, 99)', // 50 / 0 + '@missing-active-color': 'rgb(212, 212, 212)', // 87 / 0 + '@missing-hover-color': 'rgb(238, 238, 238)', // 95 / 0 + }, +}); + +const QueryBuilderInternals = memo(function QueryBuilderInternals() { + const [listMode] = useListMode(); + const { error, resultSet, queryHash, dateRanges } = useQueryBuilderContext(); + const [isChartExpanded, setIsChartExpanded] = useLocalStorage( + 'QueryBuilder:Chart:expanded', + false + ); + const [tab, setTab] = useState('results'); + const ref = useRef(null); + const chartRef = useRef(null); + const [isFiltersExpanded, setIsFiltersExpanded] = useState(true); + const [chartSize, updateChartSize] = useAutoSize(chartRef, -48); + + const ResultsAndSQL = useMemo(() => { + return ( + <> + + + } + styles={{ padding: '0 1x' }} + onChange={(tab: string) => setTab(tab as Tab)} + > + + + + + + + {tab === 'results' && } + {tab === 'generated-sql' && } + {tab === 'json' && } + {tab === 'sql' && } + {tab === 'graphql' && } + + ); + }, [tab, isChartExpanded]); + + const onToggle = useEvent((isExpanded: boolean) => { + setIsFiltersExpanded(isExpanded); + }); + + useEffect(() => { + updateChartSize(); + + setTimeout(() => { + updateChartSize(); + }, 200); + }, [isChartExpanded, isFiltersExpanded, error, queryHash, dateRanges.list.length, resultSet]); + + return ( + + {useMemo( + () => (listMode === 'bi' ? : ), + [listMode] + )} + + + + + {useMemo( + () => ( + <> + + + + ), + [] + )} + + + {useMemo( + () => ( + <> + + + + + ), + [] + )} + + {useMemo(() => { + return ( + <> +
+ +
+ {!isChartExpanded || chartSize > CHART_THRESHOLD ? ( + ResultsAndSQL + ) : ( + + + + + + + )} + + ); + }, [isChartExpanded, chartSize, ResultsAndSQL])} +
+
+
+ ); +}); + +export { QueryBuilderInternals }; diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderRest.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderRest.tsx new file mode 100644 index 0000000000000..92e5866cbb97d --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderRest.tsx @@ -0,0 +1,31 @@ +import { Alert, Block } from '@cube-dev/ui-kit'; +import { useMemo } from 'react'; + +import { useQueryBuilderContext } from './context'; +import { CopyButton } from './components/CopyButton'; +import { TabPaneWithToolbar } from './components/TabPaneWithToolbar'; +import { ScrollableCodeContainer } from './components/ScrollableCodeContainer'; + +export function QueryBuilderRest() { + const { query, isQueryEmpty, queryHash } = useQueryBuilderContext(); + + return useMemo(() => { + const stringifiedQuery = JSON.stringify(query, null, 2); + + return !query || isQueryEmpty ? ( + + Compose a query to see a JSON query. + + ) : ( + + Copy + + } + > + + + ); + }, [queryHash, isQueryEmpty]); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderResults.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderResults.tsx new file mode 100644 index 0000000000000..b07df0096a441 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderResults.tsx @@ -0,0 +1,1215 @@ +import { + Badge, + Button, + CubeButtonProps, + Grid, + Item, + Menu, + MenuTrigger, + mergeProps, + Paragraph, + Select, + Space, + Styles, + Tag, + tasty, + Text, + Title, + CloseIcon, +} from '@cube-dev/ui-kit'; +import { Key, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { formatDistance } from 'date-fns'; +import { + ArrowDownOutlined, + ArrowUpOutlined, + ClearOutlined, + FilterOutlined, + LeftOutlined, + LoadingOutlined, + MoreOutlined, + RightOutlined, +} from '@ant-design/icons'; +import { QueryOrder, TimeDimensionGranularity } from '@cubejs-client/core'; +import { + AriaOptionProps, + DroppableCollectionReorderEvent, + ListDropTargetDelegate, + ListKeyboardDelegate, + useDraggableCollection, + useDraggableItem, + useDropIndicator, + useDroppableCollection, + useFocusRing, + useListBox, + useOption, +} from 'react-aria'; +import { + DraggableCollectionState, + DroppableCollectionState, + ListProps, + ListState, + useDraggableCollectionState, + useDroppableCollectionState, + useListState, +} from 'react-stately'; + +import { formatCurrency, formatNumber } from './utils/formatters'; +import { useDeepMemo, useIntervalEffect } from './hooks'; +import { CopyButton } from './components/CopyButton'; +import { Panel } from './components/Panel'; +import { ListMemberButton } from './components/ListMemberButton'; +import { useQueryBuilderContext } from './context'; +import { getTypeIcon } from './utils'; +import { formatDateByGranularity } from './utils/format-date-by-granularity'; +import { MemberBadge } from './components/Badge'; +import { MemberLabelText } from './components/MemberLabelText'; +import { areQueriesRelated } from './utils/query-helpers'; +import { ORDER_LABEL_BY_TYPE } from './utils/labels'; + +const StyledTag = tasty(Tag, { + styles: { + position: 'static', + }, +}); + +function StyledTypeIcon(props: { + member: 'measure' | 'dimension' | 'timeDimension'; + type: 'number' | 'string' | 'time' | 'boolean' | 'filter'; +}) { + const { type, member } = props; + + return ( + + {getTypeIcon(type || 'number')} + + ); +} + +const StyledCopyButton = tasty(CopyButton, { + dontShowToast: true, + styles: { + radius: 0, + placeSelf: 'stretch', + height: 'auto', + }, +}); + +const TableContainer = tasty({ + qa: 'ResultsTableContainer', + styles: { + styledScrollbar: true, + maxWidth: '100%', + overflow: 'auto', + }, +}); + +const TableFooter = tasty(Space, { + qa: 'ResultsTableFooter', + styles: { + padding: '1x', + width: '100%', + placeContent: 'center space-between', + height: '5x', + border: 'top', + }, +}); + +const OptionsButtonElement = tasty(ListMemberButton, { + 'aria-label': 'Options', + icon: , + styles: { + color: '#dark', + gridColumns: 'auto', + placeContent: 'center', + margin: '-.5x -.5x -.5x .5x', + ButtonIcon: { fontSize: '20px' }, + }, +}); + +const DisclaimerContainer = tasty({ + styles: { + display: 'grid', + gridColumns: 'auto', + placeContent: 'center', + placeItems: 'center', + gap: '2x', + height: 'min 20x', + padding: '1x', + }, +}); + +function getOrderIcon(direction?: QueryOrder) { + if (direction === 'asc') { + return ; + } else if (direction === 'desc') { + return ; + } else { + return null; + } +} + +interface PaginationProps { + page: number; + perPage?: number; + total: number; + onChange: (page: number) => void; +} + +interface GetPaginationOptionLabelProps { + page: number; + perPage: number; + total: number; +} + +function getPaginationOptionLabel({ page, perPage, total }: GetPaginationOptionLabelProps) { + const firstItem = (page - 1) * perPage + 1; + const lastItem = Math.min(total, page * perPage); + + return `${firstItem}...${lastItem}`; +} + +function renderValue(value: string | number | null | undefined, fallback?: string) { + if (value === undefined || Number.isNaN(value)) { + if (fallback) { + return {fallback}; + } else { + return UNDEFINED; + } + } + + return typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}') ? ( + {value.replaceAll(/[{}]+/g, '')} + ) : fallback ? ( + value || {fallback} + ) : ( + value + ); +} + +function Pagination(props: PaginationProps) { + const { page, perPage = 100, total, onChange } = props; + const numberOfPages = Math.ceil(total / perPage); + + const onSelectionChange = useCallback( + (val) => { + onChange(Number(val as string)); + }, + [onChange] + ); + + return ( + + + + )} + {isVerifying && } + + + + + + + + ); + }, [viewMode, isQueryEmpty, usedMembers.length, appliedFilterString, isVerifying]); + + return ( + + setIsPasteDialogOpen(false)}> + + + + {!usedCubes.length ? <>{customTypeSwitcher ?? typeSwitcher} : topBar} + + {searchInput} + + {appliedFilterString && !filteredCubes.length ? ( + + {!filteredCubes.length ? ( + + No {selectedType === 'cubes' ? 'cubes' : 'views'} or members found + + ) : null} + + ) : undefined} + + + {cubeList} + + + ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderToolBar.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderToolBar.tsx new file mode 100644 index 0000000000000..9a40e6d10d060 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderToolBar.tsx @@ -0,0 +1,117 @@ +import { useMemo } from 'react'; +import { SerializedResult } from '@cubejs-client/core'; +import { Button, Flex, Space, tasty, TooltipProvider } from '@cube-dev/ui-kit'; +import { PlayCircleOutlined, ReloadOutlined } from '@ant-design/icons'; + +import { RequestStatus } from '../RequestStatus'; +import { PreAggregationStatus } from '../PreAggregationStatus'; + +import { QueryBuilderError } from './QueryBuilderError'; +import { useQueryBuilderContext } from './context'; +import { PreAggregationAlerts } from './components/PreAggregationAlerts'; + +const StopIcon = tasty({ + styles: { + position: 'relative', + width: '16px', + height: '16px', + + '&::before': { + content: '""', + display: 'block', + position: 'absolute', + top: '2px', + left: '2px', + width: '12px', + height: '12px', + fill: '#danger', + }, + }, +}); + +export function QueryBuilderToolBar() { + const { + runQuery, + isVerifying, + verificationError, + isLoading, + error, + resultSet, + isQueryTouched, + isQueryEmpty, + isApiBlocked, + stopQuery, + RequestStatusComponent, + } = useQueryBuilderContext(); + + const { + requestId, + // dbType, + usedPreAggregations = {}, + } = useMemo(() => { + if (resultSet) { + const { loadResponse } = resultSet?.serialize(); + + return loadResponse.results[0] || {}; + } + + return {} as SerializedResult['loadResponse']; + }, [resultSet]); + + const isAggregated = Object.keys(usedPreAggregations).length > 0; + + return ( + + + + + + Enter OR{' '} + Ctrl + Enter + + } + > + + + {isLoading ? ( + + ) : null} + + {requestId && RequestStatusComponent ? ( + + ) : undefined} + + + + + ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/Accordion.stories.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/Accordion.stories.tsx new file mode 100644 index 0000000000000..837964798dbda --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/Accordion.stories.tsx @@ -0,0 +1,179 @@ +import { Badge, Button, Paragraph } from '@cube-dev/ui-kit'; +import { SettingOutlined } from '@ant-design/icons'; +import { Meta, StoryFn } from '@storybook/react'; +import { useLayoutEffect, useRef } from 'react'; + +import { Accordion } from './Accordion'; +import { AccordionProps } from './types'; + +export default { + title: 'Accordion', + component: Accordion, + args: { + children: [ + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + , + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + , + 12}> + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + , + ], + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); + +export const Small = Template.bind({}); +Small.args = { size: 'small' }; + +export const Lazy = Template.bind({}); +Lazy.args = { isLazy: true }; + +export const LazyChildren = Template.bind({}); +LazyChildren.args = { + children: ( + <> + + Text + + + other + + + ), +}; + +export const WithExtraActions = Template.bind({}); +WithExtraActions.args = { + children: [ + + Link + + } + > + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut + labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco + laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat + cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + + , + } label="Settings" />} + > + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut + labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco + laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat + cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + + , + ], +}; + +export const ShowExtraOnHover = Template.bind({}); +ShowExtraOnHover.args = { + children: [ + + Link + + } + showExtra="onHover" + > + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut + labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco + laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat + cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + + , + } label="Settings" />} + showExtra="onHover" + > + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut + labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco + laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat + cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + + , + ], +}; + +export const AutoChangingHeight: StoryFn = (args) => { + const ref = useRef(null); + + useLayoutEffect(() => { + let rafID: number | null = null; + + const id = setInterval(() => { + rafID = requestAnimationFrame(() => { + if (ref.current) { + ref.current.style.height = `${Math.random() * 100}px`; + rafID = null; + } + }); + }, 5000); + + return () => { + clearInterval(id); + + if (rafID) { + cancelAnimationFrame(rafID); + } + }; + }, []); + + return ( + + + + + + ); +}; diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/Accordion.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/Accordion.tsx new file mode 100644 index 0000000000000..7a173570ac9f4 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/Accordion.tsx @@ -0,0 +1,27 @@ +import { tasty } from '@cube-dev/ui-kit'; + +import { AccordionProvider } from './AccordionProvider'; +import { AccordionProps } from './types'; +import { AccordionItem } from './AccordionItem'; + +const StyledAccordion = tasty({ + styles: { display: 'grid', width: '100%', flow: 'row' }, +}); + +export function Accordion(props: AccordionProps) { + const { children, isLazy, size, isSeparated, titleStyles, contentStyles } = props; + + return ( + + {children} + + ); +} + +Accordion.Item = AccordionItem; diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionDetails.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionDetails.tsx new file mode 100644 index 0000000000000..33603371ccff8 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionDetails.tsx @@ -0,0 +1,127 @@ +import { memo, ReactNode, useLayoutEffect, useRef, useState } from 'react'; +import { CSSTransition } from 'react-transition-group'; +import { Flex, Styles, tasty } from '@cube-dev/ui-kit'; +import styled from 'styled-components'; + +import { AccordionItemProps, AccordionProps } from './types'; + +type AccordionDetailsProps = { + children: AccordionItemProps['children']; + isLazy?: boolean; + size?: AccordionProps['size']; + styles?: Styles; + isExpanded?: boolean; + isSeparated?: boolean; +}; + +const ACCORDION_CONTENT_HEIGHT_VARIABLE = '--accordion-content-height'; +const ANIMATION_TIMEOUT = 180; + +const AccordionDetailsContentInnerElement = tasty(Flex, { + styles: { + padding: { + '': '1x 0 3x 3x', + '[data-size="small"]': '0 0 0 3x', + '[data-size="small"] & separated': '0 0 1x 3x', + }, + gap: 0, + flow: 'column', + }, +}); + +const AccordionDetailsContent = memo(styled.div<{ $expanded: boolean }>` + ${ACCORDION_CONTENT_HEIGHT_VARIABLE}: 0; + + height: ${({ $expanded }) => ($expanded ? 'auto' : '0')}; + opacity: ${({ $expanded }) => ($expanded ? 1 : 0)}; + + transition-property: height, opacity; + transition-duration: ${ANIMATION_TIMEOUT}ms; + transition-timing-function: cubic-bezier(0.42, 0.7, 0.82, 1); + + &.cube-accordion-transition { + &-enter { + opacity: 0; + height: 0; + contain: size layout style paint; + will-change: height, opacity; + } + &-enter-active { + opacity: 1; + height: var(${ACCORDION_CONTENT_HEIGHT_VARIABLE}); + contain: size layout style paint; + will-change: height, opacity; + } + &-exit { + opacity: 1; + height: var(${ACCORDION_CONTENT_HEIGHT_VARIABLE}); + contain: size layout style paint; + will-change: height, opacity; + } + &-exit-active { + opacity: 0; + height: 0; + contain: size layout style paint; + will-change: height, opacity; + } + } +`); +export const AccordionDetails = memo(function AccordionDetails( + props: AccordionDetailsProps +): JSX.Element { + const { children, isLazy, size, isSeparated, isExpanded = false, styles } = props; + + const [innerExpandingState, setInnerExpandingState] = useState< + 'expanded' | 'collapsed' | 'collapsing' | 'expanding' + >(isExpanded ? 'expanded' : 'collapsed'); + + const accordionContentRef = useRef(null); + const isLazyChildren = isLazy || typeof children === 'function'; + + const content = (() => { + if (isLazyChildren) { + if (isExpanded || innerExpandingState === 'collapsing') { + return renderChildren(children); + } + + if (innerExpandingState === 'collapsed') { + return null; + } + } + + return renderChildren(children); + })(); + + useLayoutEffect(() => { + accordionContentRef.current?.style.setProperty( + ACCORDION_CONTENT_HEIGHT_VARIABLE, + `${accordionContentRef.current.scrollHeight}px` + ); + }, [isExpanded]); + + return ( + setInnerExpandingState('expanding')} + onEntered={() => setInnerExpandingState('expanded')} + onExiting={() => setInnerExpandingState('collapsing')} + onExited={() => setInnerExpandingState('collapsed')} + > + + + {content} + + + + ); +}); + +function renderChildren(children: AccordionDetailsProps['children']): ReactNode { + return typeof children === 'function' ? children() : children; +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionItem.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionItem.tsx new file mode 100644 index 0000000000000..faa6ff7e0906e --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionItem.tsx @@ -0,0 +1,139 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { mergeStyles, tasty } from '@cube-dev/ui-kit'; + +import { useEvent, useIsFirstRender, useUniqID } from '../../hooks'; + +import { useAccordionNestedContext } from './AccordionNestedContext'; +import { AccordionDetails } from './AccordionDetails'; +import { AccordionItemTitle } from './AccordionItemTitle'; +import { AccordionItemProps } from './types'; +import { useAccordionContext } from './AccordionProvider'; + +const StyledAccordionItemContent = tasty({ + styles: { + border: { + '': false, + separated: '1bw solid #dark-05 bottom', + }, + overflow: 'hidden', + }, +}); + +export function AccordionItem(props: AccordionItemProps) { + let { + isExpanded, + isDefaultExpanded, + onToggle, + onExpand, + onCollapse, + extra, + showExtra = true, + title, + subtitle, + titleStyles, + contentStyles, + children, + } = props; + + const contentRef = useRef(null); + const accordionTreeContext = useAccordionNestedContext(); + + const isFirstRender = useIsFirstRender(); + const { + isLazy, + size, + isSeparated = true, + titleStyles: sharedTitleStyles, + contentStyles: sharedContentStyles, + } = useAccordionContext(); + const isControllable = isExpanded !== undefined; + let [expanded, setExpanded] = useState(isDefaultExpanded ?? false); + + expanded = (isControllable ? isExpanded : expanded) ?? false; + + titleStyles = useMemo( + () => mergeStyles(titleStyles, sharedTitleStyles), + [titleStyles, sharedTitleStyles] + ); + contentStyles = useMemo( + () => mergeStyles(contentStyles, sharedContentStyles), + [contentStyles, sharedContentStyles] + ); + + const onExpandHandler = useEvent(() => { + if (!isControllable) { + setExpanded(!expanded); + } + + onToggle?.(!expanded); + }); + + const contentID = useUniqID(); + const titleID = useUniqID(); + + useEffect(() => { + if (isFirstRender) { + return; + } + + if (!expanded) { + onCollapse?.(); + } else { + onExpand?.(); + } + }, [expanded]); + + useEffect(() => { + const registeredItem = { + title, + subtitle, + expand: () => setExpanded(true), + collapse: () => setExpanded(false), + }; + + if (accordionTreeContext) { + accordionTreeContext.items.add(registeredItem); + } + + return () => { + if (accordionTreeContext) { + accordionTreeContext.items.delete(registeredItem); + } + }; + }, []); + + return ( + <> + + + + + {children} + + + + ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionItemTitle.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionItemTitle.tsx new file mode 100644 index 0000000000000..a423a18ce9c17 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionItemTitle.tsx @@ -0,0 +1,180 @@ +import { memo, PropsWithChildren, ReactText, useState } from 'react'; +import { useFocus, useFocusVisible, useFocusWithin, useHover, usePress } from 'react-aria'; +import { mergeProps, Styles, tasty, Text } from '@cube-dev/ui-kit'; + +import { Arrow } from '../Arrow'; + +import { AccordionItemProps, AccordionProps } from './types'; + +export type AccordionItemTitleProps = { + title: AccordionItemProps['title']; + subtitle: AccordionItemProps['subtitle']; + extra: AccordionItemProps['extra']; + showExtra: AccordionItemProps['showExtra']; + size: AccordionProps['size']; + styles?: Styles; + onExpand: () => void; + isExpanded: boolean; + contentID: string; + titleID: string; +}; + +const StyledAccordionItemTitleWrap = tasty({ + styles: { + display: 'grid', + gridColumns: { + '': '1fr auto', + subtitle: 'auto 1fr auto', + }, + placeItems: 'center start', + gap: '1x', + width: '100%', + borderRadius: { '': 0, focused: '0.5x' }, + outline: { '': '#purple-04.0', focused: '#purple-04' }, + }, +}); + +const StyledAccordionItemTitle = memo( + tasty({ + styles: { + display: 'grid', + width: '100%', + gridTemplateAreas: '"icon . title ."', + gridTemplateColumns: '2x 1x auto 1fr', + alignItems: 'center', + padding: { + '': '1.75x 1x 1.75x 0', + '[data-size="small"]': '0.5x 1x 0.5x 0', + }, + cursor: 'pointer', + userSelect: 'none', + }, + }) +); +const TitleSection = tasty({ + styles: { gridArea: 'title', width: 'max 100%', overflow: 'hidden' }, +}); +const ExtraSection = tasty({ + styles: { + display: 'flex', + alignItems: 'center', + + opacity: { '': 0, show: 1 }, + transition: 'opacity 0.2s ease-out', + }, +}); +const ExpandArrowSection = tasty({ + styles: { + display: 'grid', + placeContent: 'center', + gridArea: 'icon', + width: '2x', + height: '2x', + fontSize: '2x', + transform: { '': 'rotate(0)', expanded: 'rotate(-90deg)' }, + transition: 'transform 0.2s ease-out', + transformOrigin: 'center', + }, +}); + +export function AccordionItemTitle(props: AccordionItemTitleProps) { + const { + title, + subtitle, + extra, + onExpand, + isExpanded, + contentID, + titleID, + size, + showExtra, + styles, + } = props; + + const [isFocusWithin, setIsFocusWithin] = useState(false); + const [isFocused, setIsFocused] = useState(false); + + const { hoverProps, isHovered } = useHover({}); + const { isFocusVisible } = useFocusVisible({}); + const { focusProps } = useFocus({ onFocusChange: setIsFocused }); + const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setIsFocusWithin }); + const { pressProps } = usePress({ onPress: onExpand }); + + const shouldShowFocus = isFocusVisible && isFocused; + const hasUserHovered = isHovered || shouldShowFocus || (isFocusWithin && isFocusVisible); + + return ( + + + + + + {subtitle ?
{subtitle}
: null} + + + {extra} + +
+ ); +} + +const AccordionItemIcon = memo(function StyledAccordionItemIcon(props: { isExpanded: boolean }) { + const { isExpanded } = props; + + return ( + + + + ); +}); + +const AccordionItemContent = memo(function AccordionItemContent(props: { + id: string; + title: ReactText; +}) { + const { id, title } = props; + + return ( + + + {title} + + + ); +}); + +function AccordionItemExtra( + props: PropsWithChildren<{ showExtra: AccordionItemProps['showExtra']; isHovered: boolean }> +) { + const { children, showExtra, isHovered } = props; + + if (!children) { + return null; + } + + const show = shouldShowExtra(showExtra, isHovered); + + return {children}; +} + +function shouldShowExtra(showExtra: AccordionItemProps['showExtra'], isHovered: boolean) { + if (typeof showExtra === 'boolean') { + return showExtra; + } + + return isHovered; +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionNestedContext.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionNestedContext.tsx new file mode 100644 index 0000000000000..e3659e0bf8098 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionNestedContext.tsx @@ -0,0 +1,7 @@ +import { createContext, useContext } from 'react'; + +import { AccordionNestedContextData } from './types'; + +export const AccordionNestedContext = createContext(null); + +export const useAccordionNestedContext = () => useContext(AccordionNestedContext); diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionProvider.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionProvider.tsx new file mode 100644 index 0000000000000..44c8418bcf9e8 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionProvider.tsx @@ -0,0 +1,25 @@ +import { createContext, useContext } from 'react'; + +import { AccordionContextType, AccordionProviderProps } from './types'; + +const AccordionContext = createContext(null); + +export function AccordionProvider(props: AccordionProviderProps) { + const { children, isLazy, size, isSeparated, titleStyles, contentStyles } = props; + + return ( + + {children} + + ); +} + +export function useAccordionContext() { + const context = useContext(AccordionContext); + + if (!context) { + throw new Error('useAccordionContext must be used within a AccordionProvider'); + } + + return context; +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/index.ts b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/index.ts new file mode 100644 index 0000000000000..629eda2619392 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/index.ts @@ -0,0 +1,8 @@ +export * from './Accordion'; +export type { + AccordionProps, + AccordionItemProps, + ShowExtra, + AccordionNestedContextData, +} from './types'; +export * from './AccordionNestedContext'; diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/types.ts b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/types.ts new file mode 100644 index 0000000000000..372ed9ffa7d79 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/types.ts @@ -0,0 +1,44 @@ +import { PropsWithChildren, ReactElement, ReactNode } from 'react'; +import { Styles } from '@cube-dev/ui-kit'; + +export type ShowExtra = 'onHover' | boolean; + +export type AccordionProps = { + children: ReactElement | ReactElement[]; + isLazy?: boolean; + size?: 'small' | 'normal'; + isSeparated?: boolean; + titleStyles?: Styles; + contentStyles?: Styles; +}; +export type AccordionContextType = Pick< + AccordionProps, + 'size' | 'isSeparated' | 'isLazy' | 'titleStyles' | 'contentStyles' +>; +export type AccordionProviderProps = PropsWithChildren< + Pick +>; +export type AccordionItemProps = { + title: string | number; + subtitle?: ReactNode; + children: ReactNode | (() => ReactNode); + isExpanded?: boolean; + isDefaultExpanded?: boolean; + extra?: ReactNode; + showExtra?: ShowExtra; + titleStyles?: Styles; + contentStyles?: Styles; + isSeparated?: AccordionProps['isSeparated']; + onToggle?: (isExpanded: boolean) => void; + onExpand?: () => void; + onCollapse?: () => void; +}; + +export interface AccordionNestedContextData { + items: Set<{ + title: AccordionItemProps['title']; + key?: string; + collapse: () => void; + expand: () => void; + }>; +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/AccordionCard/AccordionCard.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/AccordionCard/AccordionCard.tsx new file mode 100644 index 0000000000000..3335081892657 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/AccordionCard/AccordionCard.tsx @@ -0,0 +1,35 @@ +import { Block, Card } from '@cube-dev/ui-kit'; + +import { Accordion, AccordionItemProps } from '../Accordion'; + +export interface AccordionCardProps extends AccordionItemProps { + noPadding?: boolean; +} + +const TITLE_STYLES = { + padding: '0 1x', + height: '6x', +}; + +const CONTENT_STYLES = { + padding: 0, +}; + +export function AccordionCard(props: AccordionCardProps) { + const { children, noPadding, ...restProps } = props; + + return ( + + + + {typeof children === 'function' ? children() : children} + + + + ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/AccordionCard/index.ts b/packages/cubejs-playground/src/QueryBuilderV2/components/AccordionCard/index.ts new file mode 100644 index 0000000000000..d73c1865ce135 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/AccordionCard/index.ts @@ -0,0 +1 @@ +export * from './AccordionCard'; diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/Arrow.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/Arrow.tsx new file mode 100644 index 0000000000000..c21a7bfe55fb4 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/Arrow.tsx @@ -0,0 +1,35 @@ +import { memo } from 'react'; + +import { Icon, IconProps } from './Icon'; + +export type ArrowProps = { + /** + * @default 'right' + */ + direction?: Direction; +} & IconProps; + +type Direction = 'left' | 'right' | 'top' | 'bottom'; + +export const Arrow = memo(function Arrow(props: ArrowProps) { + const { direction = 'bottom', ...iconProps } = props; + const rotate = rotationByDirection[direction]; + + return ( + + + + + + ); +}); + +const rotationByDirection: Record = { + bottom: -180, + left: -90, + top: 0, + right: 90, +}; diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/Badge.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/Badge.tsx new file mode 100644 index 0000000000000..e753ea7857584 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/Badge.tsx @@ -0,0 +1,58 @@ +import { Badge, tasty } from '@cube-dev/ui-kit'; +import { memo, ReactNode } from 'react'; +import { QuestionCircleOutlined } from '@ant-design/icons'; + +const MemberBadgeElement = tasty(Badge, { + styles: { + display: 'inline-grid', + flow: 'column', + gap: '.5x', + placeContent: 'center', + color: { + '': '#dark', + '![data-member]': '#danger-text', + special: '#white', + }, + fill: { + '': '#danger-text.15', + '[data-member="dimension"]': '#dimension-active', + '[data-member="measure"]': '#measure-active', + '[data-member="timeDimension"]': '#time-dimension-active', + '[data-member="segment"]': '#segment-active', + '[data-member="filter"]': '#filter-active', + '[data-member="dimension"] & special': '#dimension-text', + '[data-member="measure"] & special': '#measure-text', + '[data-member="timeDimension"] & special': '#time-dimension-text', + '[data-member="segment"] & special': '#segment-text', + '[data-member="filter"] & special': '#filter-text', + }, + preset: 't4m', + width: 'max-content', + textOverflow: 'ellipsis', + overflow: 'hidden', + lineHeight: '16px', + }, +}); + +export const MemberBadge = memo( + ({ + type, + isSpecial, + children, + }: { + type?: 'measure' | 'dimension' | 'segment' | 'filter' | 'timeDimension'; + isSpecial?: boolean; + children: ReactNode | number; + }) => { + return ( + + {!type && } + {children} + + ); + } +); diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/ChartRenderer.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/ChartRenderer.tsx new file mode 100644 index 0000000000000..d5547e0781f61 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/ChartRenderer.tsx @@ -0,0 +1,558 @@ +import { ChartType, TimeDimensionGranularity } from '@cubejs-client/core'; +import { UseCubeQueryResult } from '@cubejs-client/react'; +import { Skeleton, Tag, tasty } from '@cube-dev/ui-kit'; +import formatDate from 'date-fns/format'; +import { ComponentType, memo, useCallback, useMemo } from 'react'; +import { Col, Row, Statistic, Table } from 'antd'; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Cell, + Legend, + Line, + LineChart, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import styled from 'styled-components'; + +import { + CHART_COLORS, + getChartColorByIndex, + getChartSolidColorByIndex, +} from '../utils/chart-colors'; + +import { LocalError } from './LocalError'; + +const FORMAT_MAP = { + second: 'HH:mm:ss, yyyy-LL-dd', + minute: 'HH:mm, yyyy-LL-dd', + hour: 'HH:00, yyyy-LL-dd', + day: 'yyyy-LL-dd', + week: "'W'w yyyy-LL-dd", + month: 'LLL yyyy', + quarter: 'QQQ yyyy', + year: 'yyyy', +}; + +export function formatDateByGranularity(timestamp: Date, granularity?: TimeDimensionGranularity) { + return formatDate(timestamp, FORMAT_MAP[granularity ?? 'second']); +} + +export function formatDateByPattern(timestamp: Date, format?: string) { + return formatDate(timestamp, format ?? FORMAT_MAP['second']); +} + +function CustomDot(props: any) { + const { cx, cy, fill } = props; + + return ( + + + + ); +} + +const intlNumberFormatter = Intl.NumberFormat('en', { notation: 'compact' }); +const numberFormatter = (item: any) => + typeof item === 'number' ? intlNumberFormatter.format(item) : item; + +const StyledStatistic = styled(Statistic)` + .ant-statistic-content { + font-size: 40px; + } +`; + +const LegendTextElement = tasty({ + as: 'span', + styles: { + color: '#dark', + preset: 't3', + }, +}); + +function isValidISOTimestamp(timestamp: string) { + try { + return new Date(timestamp + 'Z').toISOString() === timestamp + 'Z'; + } catch (e) { + return false; + } +} + +function CartesianChart({ + dataTransformer, + pivotConfig, + dateFormat, + resultSet, + children, + ChartComponent, + height, + domain, + grid, + syncId, + yAxisFormatter = numberFormatter, + tooltipFormatter = numberFormatter, + tooltipCursor = false, + extra, +}: any) { + const locale = 'en-US'; + const legendFormatter = useCallback( + (value) => {value}, + [] + ); + + const granularityField = Object.keys(resultSet?.loadResponse.results[0].data[0] || {}).find( + (key) => { + return (key as string).split('.').length === 3; + } + ) as string; + const granularity = granularityField?.split('.')[2]; + + const formatDate = useMemo(() => { + if (dateFormat) { + return (item: string) => + isValidISOTimestamp(item) ? formatDateByPattern(new Date(item), dateFormat) : item; + } + + return granularity + ? (item: string) => + isValidISOTimestamp(item) + ? formatDateByGranularity(new Date(item), granularity as TimeDimensionGranularity) + : item + : (item: string) => item; + }, [dateFormat]); + + const dateFormatter = useCallback( + (item) => { + try { + return formatDate(item); + } catch (e) { + return item; + } + }, + [formatDate] + ); + + const chartPivot = useMemo(() => { + let chartPivot = resultSet.chartPivot(pivotConfig); + if (dataTransformer) { + chartPivot = dataTransformer(chartPivot, { granularity }); + } + + return chartPivot.map((series: any) => { + series.x = series.xValues + .map((value: string) => { + return formatDate(value); + }) + .join(','); + + return series; + }); + }, [resultSet, pivotConfig, dataTransformer, formatDate]); + + return ( + + + + + + + {children} + + + {extra ? extra() : null} + + + ); +} + +const TypeToChartComponent = { + line: ({ + resultSet, + height, + fill, + stroke, + dot, + grid, + domain, + nameTransform, + yAxisFormatter, + tooltipFormatter, + pivotConfig, + dateFormat, + dataTransformer, + syncId, + tooltipCursor, + extra, + }: any) => { + let seriesNames = resultSet.seriesNames(pivotConfig); + + if (nameTransform) { + nameTransform(seriesNames); + } + + return ( + + {seriesNames.map((series: any, i: number) => ( + + ))} + + ); + }, + + bar: ({ + resultSet, + domain, + nameTransform, + height, + fill, + grid, + yAxisFormatter, + tooltipFormatter, + pivotConfig, + dateFormat, + dataTransformer, + syncId, + tooltipCursor, + }: any) => { + let seriesNames = resultSet.seriesNames(pivotConfig); + + if (nameTransform) { + nameTransform(seriesNames); + } + + return ( + + {seriesNames.map((series: any, i: number) => ( + + ))} + + ); + }, + + area: ({ + resultSet, + domain, + nameTransform, + height, + stroke, + fill, + grid, + pivotConfig, + yAxisFormatter, + tooltipFormatter, + dateFormat, + syncId, + tooltipCursor, + dataTransformer, + }: any) => { + let seriesNames = resultSet.seriesNames(pivotConfig); + + if (nameTransform) { + nameTransform(seriesNames); + } + + return ( + + {seriesNames.map((series: any, i: number) => ( + + ))} + + ); + }, + + pie: ({ resultSet, nameTransform, pivotConfig, height, fill, stroke }: any) => { + let seriesNames = resultSet.seriesNames(pivotConfig); + + if (nameTransform) { + nameTransform(seriesNames); + } + + return ( + + + + {resultSet.chartPivot(pivotConfig).map((e: any, index: number) => { + const i = index % (stroke?.length ?? CHART_COLORS.length); + + return ( + + ); + })} + + + + + + ); + }, + + table: ({ isLoading, resultSet, height, pivotConfig }: any) => { + const columnData = resultSet?.tableColumns(pivotConfig); + const dataSet = resultSet?.tablePivot(pivotConfig); + const granularityMap: Record = {}; + + let headerSize = 1; + + Object.keys(dataSet[0] || {}).forEach((key) => { + const size = (key as string).split(',').length; + + if (size > headerSize) { + headerSize = size; + } + }); + + columnData.forEach((field: any, i: number) => { + if (field.key) { + granularityMap[field.key] = field.key.split('.')[2]; + } else { + field.key = `key${i}`; // fallback index + } + }); + + return ( + { + const column = { ...c, dataIndex: c.key, title: c.shortTitle }; + const granularity = granularityMap[c.key]; + + return { + width: 90, + ...column, + render: granularity + ? (text: any) => { + try { + return isValidISOTimestamp(text) + ? formatDateByGranularity( + new Date(text), + granularity as TimeDimensionGranularity + ) + : text; + } catch (e) { + return text; + } + } + : (text: any) => { + switch (typeof text) { + case 'boolean': + return text ? 'true' : 'false'; + case 'undefined': + case 'object': + return text === null ? NULL : OBJECT; + default: + if (c.type === 'boolean') { + return text && text !== '0' ? 'true' : 'false'; + } + + return text; + } + }, + }; + })} + dataSource={dataSet} + scroll={{ y: height - headerSize * 60, x: 'max-content' }} + /> + ); + }, + + number: ({ resultSet }: any) => ( + + + {resultSet.seriesNames().map((s: any) => ( + + ))} + + + ), +} as const; + +const TypeToMemoChartComponent = Object.keys(TypeToChartComponent) + .map((key) => ({ + [key]: memo(TypeToChartComponent[key as keyof typeof TypeToChartComponent]), + })) + .reduce((a: any, b: any) => ({ ...a, ...b })); + +const renderChart = (Component: ComponentType) => + function ( + { + resultSet, + error, + ...restParams + }: UseCubeQueryResult & { + height: number; + stroke?: string[]; + fill?: string[]; + }, + chartType: ChartType + ) { + if (error) { + return ; + } + + if (chartType === 'table') { + return ; + } + + return ( + (resultSet && ) || ( + + ) + ); + }; + +export function PlaygroundChartRenderer({ + query, + chartType, + resultSet, + component, + chartHeight, + ...rest +}: any) { + const componentToRender = component || TypeToMemoChartComponent[chartType]; + + if (componentToRender) { + return renderChart(componentToRender)( + { + height: chartHeight, + ...rest, + grid: 'both', + resultSet, + }, + + chartType + ); + } + + return null; +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/CopyButton.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/CopyButton.tsx new file mode 100644 index 0000000000000..c8bf6bf5b6a0a --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/CopyButton.tsx @@ -0,0 +1,61 @@ +import { Button, useToastsApi, copy, tasty, CubeButtonProps } from '@cube-dev/ui-kit'; +import { CopyOutlined } from '@ant-design/icons'; +import { useState } from 'react'; +import { unstable_batchedUpdates } from 'react-dom'; + +import { useDebouncedState, useEvent } from '../hooks'; + +import { CopyIcon } from './CopyIcon'; + +export type CopyButtonProps = { + value: string; + onCopy?: () => void; + toastMessage?: string; + dontShowToast?: boolean; +} & Omit; + +const CopyButtonElement = tasty(Button, { + label: 'Copy value to clipboard', + type: 'clear', + size: 'small', + icon: , +}); + +export function CopyButton(props: CopyButtonProps) { + const { + value, + onCopy, + toastMessage = 'Copied to clipboard', + dontShowToast = false, + ...buttonProps + } = props; + const { toast } = useToastsApi(); + const [coping, setCoping] = useDebouncedState(false, 300); + const [copied, setCopied] = useState(false); + + const onCopyAnimationEnd = useEvent(() => setCopied(false)); + const icon = props.icon ?? ; + + return ( + { + setCoping(true); + await copy(value); + unstable_batchedUpdates(() => { + setCoping(false); + setCopied(true); + }); + + onCopy?.(); + + if (dontShowToast) { + return; + } + toast.success(toastMessage); + }} + /> + ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/CopyIcon.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/CopyIcon.tsx new file mode 100644 index 0000000000000..26381607a317d --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/CopyIcon.tsx @@ -0,0 +1,90 @@ +import { CheckOutlined, CopyOutlined } from '@ant-design/icons'; +import { Styles, tasty } from '@cube-dev/ui-kit'; +import { memo, useRef } from 'react'; + +import { useDebouncedCallback } from '../hooks'; + +export type CopyIconProps = { + isCopied: boolean; + onCopyAnimationEnd: () => void; +}; + +const Wrapper = tasty({ + styles: { + display: 'inline-block', + position: 'relative', + width: '1em', + height: '1em', + }, +}); + +const copyIconStyle: Styles = { + display: 'grid', + position: 'absolute', + left: '50%', + top: '50%', + translate: '-50% -50%', + transitionProperty: 'opacity, rotate', + transitionDuration: '0.25s', + transitionTimingFunction: 'ease-out', +}; + +const CopyIconElement = tasty({ + styles: { + ...copyIconStyle, + opacity: { + '': 1, + copied: 0, + }, + rotate: { + '': '0deg', + copied: '90deg', + }, + transitionDelay: { + '': '0s', + copied: '0.1s', + }, + }, + children: , +}); + +const CopiedIconElement = tasty({ + styles: { + ...copyIconStyle, + opacity: { + '': 0, + copied: 1, + }, + rotate: { + '': '-90deg', + copied: '0deg', + }, + transitionDelay: { + '': '0.1s', + copied: '0s', + }, + }, + children: , +}); + +export const CopyIcon = memo(function CopyIcon(props: CopyIconProps) { + const { isCopied, onCopyAnimationEnd } = props; + const copyIconRef = useRef(null); + const copiedIconRef = useRef(null); + const dOnCopyAnimationEnd = useDebouncedCallback( + () => onCopyAnimationEnd(), + [onCopyAnimationEnd], + 1000 + ); + + return ( + + + + + ); +}); diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/DateRangeFilter.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/DateRangeFilter.tsx new file mode 100644 index 0000000000000..9a3f224a68947 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/DateRangeFilter.tsx @@ -0,0 +1,117 @@ +import { Key, useCallback, useState } from 'react'; +import { Item, Select, Space, Text, TooltipProvider } from '@cube-dev/ui-kit'; +import { DateRange, TimeDimension } from '@cubejs-client/core'; +import formatDate from 'date-fns/format'; + +import { capitalize } from '../utils/capitalize'; +import { DATA_RANGES } from '../values'; + +import { FilterLabel } from './FilterLabel'; +import { TimeDateRangeSelector } from './TimeDateRangeSelector'; +import { DeleteFilterButton } from './DeleteFilterButton'; + +interface TimeDimensionFilterProps { + member: TimeDimension; + isCompact?: boolean; + isMissing?: boolean; + onChange: (dateRange?: DateRange) => void; + onRemove: () => void; +} + +export function DateRangeFilter(props: TimeDimensionFilterProps) { + const { member, isCompact, isMissing, onRemove, onChange } = props; + const [open, setOpen] = useState(false); + + // const onGranularityChange = useCallback( + // (granularity?: Key) => { + // if (granularity === 'w/o grouping') { + // onChange({ ...member, granularity: undefined }); + // } else { + // onChange({ ...member, granularity: granularity as TimeDimensionGranularity }); + // } + // }, + // [onChange] + // ); + + const onDateRangeChange = useCallback( + (dateRange?: Key) => { + if (dateRange === 'custom') { + onChange([formatDate(new Date(), 'yyyy-MM-dd'), formatDate(new Date(), 'yyyy-MM-dd')]); + + return; + } + + if (dateRange === 'all time') { + onChange(undefined); + } else { + onChange(dateRange as string); + } + }, + [onChange] + ); + + const onDataRangeChangeInPicker = useCallback( + (dateRange: [string, string]) => { + onChange(dateRange); + }, + [onChange] + ); + + const onOpenChange = (open: boolean) => { + setOpen(open); + }; + + return ( + + + + + + for + + {Array.isArray(member.dateRange) ? ( + + ) : undefined} + {/*by*/} + {/**/} + {/* {GRANULARITIES.map((key) => {*/} + {/* return (*/} + {/* */} + {/* {capitalize(key)}*/} + {/* */} + {/* );*/} + {/* })}*/} + {/**/} + + ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/DeleteFilterButton.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/DeleteFilterButton.tsx new file mode 100644 index 0000000000000..dca934ddbb121 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/DeleteFilterButton.tsx @@ -0,0 +1,9 @@ +import { Button, CloseIcon, tasty } from '@cube-dev/ui-kit'; + +export const DeleteFilterButton = tasty(Button, { + 'aria-label': 'Delete this filter', + size: 'small', + type: 'secondary', + theme: 'danger', + icon: , +}); diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/EditQueryDialogForm.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/EditQueryDialogForm.tsx new file mode 100644 index 0000000000000..3e8d47c12c0c6 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/EditQueryDialogForm.tsx @@ -0,0 +1,260 @@ +import { DialogForm, LoadingIcon, Radio, Space, TextArea, useForm } from '@cube-dev/ui-kit'; +import { Meta, Query, validateQuery } from '@cubejs-client/core'; +import { ValidationRule } from '@cube-dev/ui-kit/types/shared'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useQueryBuilderContext } from '../context'; +import { useServerCoreVersionGte } from '../hooks'; +import { convertGraphQLToJsonQuery, convertJsonQueryToGraphQL } from '../utils/graphql-converters'; + +interface PasteQueryDialogFormProps { + query?: Query; + defaultType?: QueryType; + apiVersion?: string; + onDismiss?: () => void; + onSubmit: (query: Query) => void; +} + +function validateJsonQuery(json: string) { + try { + return validateQuery(JSON.parse(json)); + } catch (e) { + throw 'Invalid query'; + } +} + +function getGraphQLValidator(apiUrl: string, apiToken: string | null) { + return [ + { + async validator(rule: ValidationRule, query: string) { + return convertGraphQLToJsonQuery({ apiUrl, apiToken, query: query }).then( + (json) => validateJsonQuery(json), + () => { + throw ''; + } + ); + }, + }, + ]; +} + +function getJSONValidator(apiUrl: string, apiToken: string | null, meta?: Meta | null) { + return [ + { + async validator(rule: ValidationRule, query: string) { + const originalQuery = JSON.stringify(JSON.parse(query)); + const graphQLQuery = convertJsonQueryToGraphQL({ meta, query: JSON.parse(query) }); + + return convertGraphQLToJsonQuery({ apiUrl, apiToken, query: graphQLQuery }).then( + (json) => { + return originalQuery === json; + }, + (e) => { + throw ''; + } + ); + }, + }, + ]; +} + +const QUERY_VALIDATOR = { + async validator(rule: ValidationRule, value: string) { + if (!validateJsonQuery) { + throw 'Invalid query'; + } + }, +}; +const JSON_VALIDATOR = { + async validator(rule: ValidationRule, value: string) { + try { + JSON.parse(value); + } catch (e) { + throw ''; // do not show any error message + } + }, +}; + +type QueryType = 'json' | 'graphql'; + +async function pause(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function EditQueryDialogForm(props: PasteQueryDialogFormProps) { + const [form] = useForm(); + const { onSubmit, onDismiss, defaultType = 'json', query, apiVersion } = props; + const [type, setType] = useState(defaultType); + const isGraphQLSupported = apiVersion ? useServerCoreVersionGte('0.35.23', apiVersion) : true; + const isGraphQLSupportedV1 = apiVersion ? useServerCoreVersionGte('0.35.27', apiVersion) : true; + const [isBlocked, setIsBlocked] = useState(false); + + let { apiUrl, apiToken, meta } = useQueryBuilderContext(); + + if (!isGraphQLSupportedV1) { + apiUrl = apiUrl.replace(/\/v1$/, ''); + } + + async function parseAndPrepareQuery(query: string, type: QueryType) { + if (type === 'graphql') { + return validateQuery( + JSON.parse(await convertGraphQLToJsonQuery({ query, apiUrl, apiToken })) || {} + ); + } + + return validateQuery(JSON.parse(query) || {}); + } + + const onJsonBlur = useCallback(async () => { + const type = form.getFieldValue('type'); + + await pause(100); + + // check if onblur was triggered by type switch, skip if so + if (type !== 'json') { + return; + } + + const jsonQuery = form.getFieldValue('jsonQuery'); + + try { + const query = validateQuery(JSON.parse(jsonQuery) || {}); + const graphQLQuery = convertJsonQueryToGraphQL({ meta, query }); + + return convertGraphQLToJsonQuery({ apiUrl, apiToken, query: graphQLQuery }).then( + (jsonQuery) => { + form.setFieldValue('jsonQuery', jsonQuery); + }, + () => { + throw ''; + } + ); + } catch (e) { + // do nothing + } + }, [meta]); + + const onGraphqlBlur = useCallback(async () => { + await pause(100); + + const graphqlQuery = form.getFieldValue('graphqlQuery'); + const type = form.getFieldValue('type'); + + // check if onblur was triggered by type switch, skip if so + if (type !== 'graphql') { + return; + } + + setIsBlocked(true); + + return convertGraphQLToJsonQuery({ query: graphqlQuery, apiUrl, apiToken }) + .then((jsonQuery) => { + const query = validateQuery(JSON.parse(jsonQuery) || {}); + const graphqlQuery = convertJsonQueryToGraphQL({ meta, query }); + + form.setFieldValue('graphqlQuery', graphqlQuery); + }) + .finally(() => { + setIsBlocked(false); + }); + }, [meta]); + + const defaultQueryValue = + type === 'json' + ? JSON.stringify(query || {}, null, 2) + : meta && query + ? convertJsonQueryToGraphQL({ meta, query }) + : ''; + + const onTypeChange = useCallback((type) => { + setType(type); + const originalQuery = form.getFieldValue(type === 'json' ? 'graphqlQuery' : 'jsonQuery'); + setIsBlocked(true); + + void parseAndPrepareQuery(originalQuery, type === 'json' ? 'graphql' : 'json') + .then((query) => { + const value = + type === 'json' + ? JSON.stringify(query || {}, null, 2) + : query + ? convertJsonQueryToGraphQL({ meta, query }) + : ''; + + form.setFieldValue(type === 'json' ? 'jsonQuery' : 'graphqlQuery', value); + }) + .finally(() => { + setIsBlocked(false); + }); + }, []); + + useEffect(() => { + form.setFieldValue(type === 'json' ? 'jsonQuery' : 'graphqlQuery', defaultQueryValue); + }, [JSON.stringify(query)]); + + useEffect(() => { + form.setFieldValue('type', defaultType); + }, [defaultType]); + + const onSubmitLocal = useCallback(async ({ type }) => { + await (type === 'json' ? onJsonBlur() : onGraphqlBlur()); + + const query = + type === 'json' ? form.getFieldValue('jsonQuery') : form.getFieldValue('graphqlQuery'); + + await parseAndPrepareQuery(query, type).then((query) => onSubmit(query)); + }, []); + + const graphqlRules = useMemo(() => [getGraphQLValidator(apiUrl, apiToken)], [apiUrl, apiToken]); + const jsonRules = useMemo(() => [JSON_VALIDATOR, QUERY_VALIDATOR], []); + + return ( + + {isGraphQLSupported ? ( + + + JSON + GraphQL + + {isBlocked ? : null} + + ) : undefined} + +
+