diff --git a/packages/cubejs-playground/package.json b/packages/cubejs-playground/package.json index 83b155efb21fd..fe183687e6632 100644 --- a/packages/cubejs-playground/package.json +++ b/packages/cubejs-playground/package.json @@ -33,6 +33,7 @@ "@apollo/client": "^3.11.4", "@graphiql/toolkit": "^0.4.3", "anser": "^2.1.1", + "best-effort-json-parser": "^1.1.2", "camel-case": "^4.1.2", "codesandbox-import-utils": "^2.1.1", "cron-validator": "^1.2.1", @@ -66,7 +67,7 @@ "devDependencies": { "@ant-design/compatible": "^1.0.1", "@ant-design/icons": "^5.3.5", - "@cube-dev/ui-kit": "0.38.0", + "@cube-dev/ui-kit": "0.52.3", "@cubejs-client/core": "1.1.12", "@cubejs-client/react": "1.1.12", "@types/flexsearch": "^0.7.3", @@ -97,7 +98,7 @@ }, "peerDependencies": { "@ant-design/icons": ">=4.7.0", - "@cube-dev/ui-kit": ">=0.37.2", + "@cube-dev/ui-kit": ">=0.52.3", "@cubejs-client/core": ">=0.30.0", "@cubejs-client/react": ">=0.30.0", "antd": ">=4.16.13", diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilder.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilder.tsx index 6e08734c75a1d..3eb3bfe0a7cf0 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilder.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilder.tsx @@ -1,15 +1,22 @@ -import { useEffect, useMemo } from 'react'; -import cube, { Query } from '@cubejs-client/core'; import { Alert, Block, Card, PrismCode, Title } from '@cube-dev/ui-kit'; +import cube, { Query } from '@cubejs-client/core'; +import { useEffect, useMemo } from 'react'; -import { useLocalStorage } from './hooks'; -import { QueryBuilderProps } from './types'; import { QueryBuilderContext } from './context'; +import { useLocalStorage } from './hooks'; import { useQueryBuilder } from './hooks/query-builder'; import { QueryBuilderInternals } from './QueryBuilderInternals'; +import { QueryBuilderProps } from './types'; import { useCommitPress } from './utils/use-commit-press'; -export function QueryBuilder(props: Omit & { apiUrl: string | null }) { +export function QueryBuilder( + props: Omit & { + displayPrivateItems?: boolean; + apiUrl: string | null; + disableLimitEnforcing?: boolean; + children?: React.ReactNode; + } +) { const { apiUrl, apiToken, @@ -22,9 +29,12 @@ export function QueryBuilder(props: Omit & { apiUrl tracking, isApiBlocked, apiVersion, + memberViewType, VizardComponent, RequestStatusComponent, openSqlRunner, + displayPrivateItems, + disableSidebarResizing, } = props; const cubeApi = useMemo(() => { @@ -45,10 +55,6 @@ export function QueryBuilder(props: Omit & { apiUrl queryCopy.timezone = storedTimezones[0]; } - if (typeof queryCopy.limit !== 'number' || queryCopy.limit < 1 || queryCopy.limit > 50_000) { - queryCopy.limit = 5_000; - } - return queryCopy; } @@ -72,8 +78,10 @@ export function QueryBuilder(props: Omit & { apiUrl defaultPivotConfig, schemaVersion, onQueryChange, + memberViewType, tracking, queryValidator, + displayPrivateItems, }); useEffect(() => { @@ -86,7 +94,11 @@ export function QueryBuilder(props: Omit & { apiUrl return runQuery(); }, true); - return apiToken && cubeApi && apiUrl ? ( + if (!apiToken || !cubeApi || !apiUrl) { + return null; + } + + return ( & { apiUrl VizardComponent, RequestStatusComponent, openSqlRunner, + disableSidebarResizing, ...otherProps, }} > @@ -122,9 +135,11 @@ export function QueryBuilder(props: Omit & { apiUrl )} + ) : props.children ? ( + props.children ) : ( )} - ) : null; + ); } diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderChart.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderChart.tsx index fbc28fc920404..dc4fcca35c7a3 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderChart.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderChart.tsx @@ -23,7 +23,7 @@ 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 { ChevronIcon } from './icons/ChevronIcon'; import { AccordionCard } from './components/AccordionCard'; import { OutdatedLabel } from './components/OutdatedLabel'; import { QueryBuilderChartResults } from './QueryBuilderChartResults'; @@ -117,7 +117,7 @@ export function QueryBuilderChart(props: QueryBuilderChartProps) { const pivotConfigurator = useMemo(() => { return pivotConfig ? ( - diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderChartResults.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderChartResults.tsx index 8fb2ad08bbddd..8ede0ec4b962b 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderChartResults.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderChartResults.tsx @@ -15,7 +15,7 @@ interface QueryBuilderChartResultsProps { containerRef?: RefObject; } -const MAX_HEIGHT = 400; +const MAX_HEIGHT = 350; const MAX_SERIES_LIMIT = 25; const ChartContainer = tasty({ @@ -57,8 +57,8 @@ export function QueryBuilderChartResults({ @@ -76,9 +76,9 @@ export function QueryBuilderChartResults({ return ( - No data available + No results available - Query metrics and dimensions with results to see the chart. + Compose and run a query to see the results. ); } diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderDevSidePanel.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderDevSidePanel.tsx deleted file mode 100644 index aed792a477db2..0000000000000 --- a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderDevSidePanel.tsx +++ /dev/null @@ -1,503 +0,0 @@ -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( - () => ( - )} ); - }, [query.ungrouped, query.timezone, query.offset, storedTimezones.join('::'), query.limit]); + }, [ + query.ungrouped, + query.timezone, + query.offset, + query.total, + storedTimezones.join('::'), + query.limit, + ]); const orderSelector = useMemo(() => { if (!allFields.length) { @@ -566,3 +577,42 @@ export function QueryBuilderExtras() { ); } + +export function QueryBuilderLimitSelect() { + const { query, updateQuery } = useQueryBuilderContext(); + + const limit = query.limit ?? DEFAULT_LIMIT; + const limitOptions = LIMIT_OPTION_VALUES.includes(limit) + ? LIMIT_OPTIONS + : [{ key: limit, label: formatNumber(limit) }, ...LIMIT_OPTIONS].sort((a, b) => a.key - b.key); + + return ( + + ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderFilters.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderFilters.tsx index cdea3f1b14934..d9530b32d361c 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderFilters.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderFilters.tsx @@ -1,17 +1,16 @@ 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 { Button, Flex, Flow, Space, tasty } from '@cube-dev/ui-kit'; import { TCubeDimension, TCubeMeasure } from '@cubejs-client/core'; import { useQueryBuilderContext } from './context'; -import { getTypeIcon } from './utils'; -import { useListMode } from './hooks/list-mode'; +import { useEvent } from './hooks'; 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 { FilterMember } from './components/FilterMember'; import { SegmentFilter } from './components/SegmentFilter'; +import { LogicalFilter } from './components/LogicalFilter'; +import { AddFilterInput } from './components/AddFilterInput'; const BadgeContainer = tasty(Space, { styles: { @@ -25,47 +24,33 @@ const BadgeContainer = tasty(Space, { }); 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, + joinableCubes, + usedCubes, + cubes, + memberViewType, + usedMembersInFilters, } = useQueryBuilderContext(); - const isCompact = - Object.keys(queryStats).length === 1 && - ((selectedCube && selectedCube === queryStats[selectedCube?.name]?.instance) || !selectedCube); + const isCompact = usedCubes.length === 1; + const isAddingCompact = joinableCubes.length === 1; 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 measureCounter = usedMembersInFilters.filter( + (memberName) => members.measures[memberName] + ).length; + const dimensionCounter = usedMembersInFilters.filter( + (memberName) => members.dimensions[memberName] + ).length; const isFiltered = filters.length > 0 || segments.length > 0 || dateRanges.list.length > 0; @@ -75,17 +60,6 @@ export function QueryBuilderFilters({ onToggle }: { onToggle?: (isExpanded: bool 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; @@ -101,41 +75,17 @@ export function QueryBuilderFilters({ onToggle }: { onToggle?: (isExpanded: bool 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; - } - - const buttons = filtersRef?.current?.querySelectorAll('button'); - const lastButton = buttons && buttons.length > 0 ? buttons[buttons.length - 1] : undefined; - - (lastButton as HTMLButtonElement | undefined)?.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - }, [query?.filters?.length, dateRanges.list.length, segments?.length]); + const onClearAction = useEvent(() => { + dateRanges.clear(); + filtersUpdater.clear(); + segmentsUpdater?.clear(); + }); return ( ) : undefined } + extra={ + timeCounter || dimensionCounter || measureCounter || segmentsCounter ? ( + + ) : null + } contentStyles={{ border: 'top' }} onToggle={(isExpanded) => { setIsExpanded(isExpanded); @@ -167,21 +124,30 @@ export function QueryBuilderFilters({ onToggle }: { onToggle?: (isExpanded: bool }} > - - {!isFiltered ? No filters set : null} + {dateRanges.list.map((dimensionName, i) => { const timeDimension = timeDimensions.find( (timeDimension) => timeDimension.dimension === dimensionName ); const dimension = members.dimensions[dimensionName]; + const cubeName = dimensionName.split('.')[0]; + const cube = cubes.find((cube) => cube.name === cubeName); + const memberName = dimensionName.split('.')[1]; + const member = members.measures[dimensionName] || members.dimensions[dimensionName]; return ( { dateRanges.remove(dimensionName); }} @@ -192,20 +158,89 @@ export function QueryBuilderFilters({ onToggle }: { onToggle?: (isExpanded: bool ); })} {filters.map((filter, index) => { + if ('and' in filter) { + return ( + { + filtersUpdater.remove(index); + }} + onChange={(filter) => { + filtersUpdater.update(index, filter); + }} + onUnwrap={() => { + if (filter.and.length === 1) { + filtersUpdater.update(index, filter.and[0]); + + return; + } + + filtersUpdater.remove(index); + filter.and.forEach((filter) => { + filtersUpdater.add(filter); + }); + }} + /> + ); + } + + if ('or' in filter) { + return ( + { + filtersUpdater.remove(index); + }} + onChange={(filter) => { + filtersUpdater.update(index, filter); + }} + onUnwrap={() => { + if (filter.or.length === 1) { + filtersUpdater.update(index, filter.or[0]); + + return; + } + + filtersUpdater.remove(index); + filter.or.forEach((filter) => { + filtersUpdater.add(filter); + }); + }} + /> + ); + } + if (!('member' in filter) || !filter.member) { return null; } - const member = members.measures[filter.member] || members.dimensions[filter.member]; + const memberFullName = filter.member; + const cubeName = memberFullName.split('.')[0]; + const cube = cubes.find((cube) => cube.name === cubeName); + const memberName = memberFullName.split('.')[1]; + const member = members.measures[memberFullName] || members.dimensions[memberFullName]; return ( - { filtersUpdater.remove(index); }} @@ -217,92 +252,42 @@ export function QueryBuilderFilters({ onToggle }: { onToggle?: (isExpanded: bool })} {segments.map((segment, i) => { const member = members.segments[segment]; + const cubeName = segment.split('.')[0]; + const cube = cubes.find((cube) => cube.name === cubeName); + const memberName = segment.split('.')[1]; return ( { - segmentsUpdater?.remove(segment); + 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} + { + filtersUpdater.add(filter); + }} + onSegmentAdd={(name) => { + segmentsUpdater.add(name); + }} + onDateRangeAdd={(name) => { + dateRanges.set(name); + }} + /> + ); diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderGraphQL.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderGraphQL.tsx index 12bc72d90998a..b0d1a458e9b25 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderGraphQL.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderGraphQL.tsx @@ -157,7 +157,7 @@ export function QueryBuilderGraphQL() { value={ queryError ? // @ts-ignore - queryError?.networkError?.result?.error ?? queryError.toString() + (queryError?.networkError?.result?.error ?? queryError.toString()) : JSON.stringify(cleanedRawData, null, 2) } /> diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderInternals.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderInternals.tsx index c46f5602b5bf0..9966caa44d5d0 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderInternals.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderInternals.tsx @@ -1,10 +1,9 @@ -import { Block, Flow, tasty } from '@cube-dev/ui-kit'; +import { Flex, Flow, Panel, tasty } from '@cube-dev/ui-kit'; import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { QUERY_BUILDER_COLOR_TOKENS } from './color-tokens'; -import { useAutoSize, useEvent, useListMode, useLocalStorage } from './hooks'; +import { useAutoSize, useEvent, 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'; @@ -15,12 +14,11 @@ 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 CHART_THRESHOLD = 448; const Divider = tasty({ styles: { @@ -33,9 +31,9 @@ const Divider = tasty({ type Tab = 'results' | 'generated-sql' | 'json' | 'graphql' | 'sql'; const QueryBuilderPanel = tasty(Panel, { + isFlex: true, isStretched: true, qa: 'QueryBuilder', - gridColumns: '42x 1ow minmax(0, 1fr)', styles: { fill: '#white', @@ -44,7 +42,6 @@ const QueryBuilderPanel = tasty(Panel, { }); const QueryBuilderInternals = memo(function QueryBuilderInternals() { - const [listMode] = useListMode(); const { error, resultSet, queryHash, dateRanges } = useQueryBuilderContext(); const [isChartExpanded, setIsChartExpanded] = useLocalStorage( 'QueryBuilder:Chart:expanded', @@ -54,7 +51,7 @@ const QueryBuilderInternals = memo(function QueryBuilderInternals() { const ref = useRef(null); const chartRef = useRef(null); const [isFiltersExpanded, setIsFiltersExpanded] = useState(true); - const [chartSize, updateChartSize] = useAutoSize(chartRef, -48); + const [chartSize, updateChartSize] = useAutoSize(chartRef, 0); const ResultsAndSQL = useMemo(() => { return ( @@ -96,14 +93,9 @@ const QueryBuilderInternals = memo(function QueryBuilderInternals() { return ( - {useMemo( - () => (listMode === 'bi' ? : ), - [listMode] - )} + - - - + {useMemo( () => ( <> @@ -137,9 +129,9 @@ const QueryBuilderInternals = memo(function QueryBuilderInternals() { ) : ( - + - + )} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderResults.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderResults.tsx index daa971ec638d5..12e751c60f790 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderResults.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderResults.tsx @@ -1,5 +1,4 @@ import { - Badge, Button, CubeButtonProps, Grid, @@ -15,6 +14,7 @@ import { tasty, Text, Title, + Panel, CloseIcon, } from '@cube-dev/ui-kit'; import { Key, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -53,17 +53,16 @@ import { useListState, } from 'react-stately'; +import { PREDEFINED_GRANULARITIES } from './values'; import { formatCurrency, formatNumber } from './utils/formatters'; import { useDeepMemo, useIntervalEffect } from './hooks'; import { OutdatedLabel } from './components/OutdatedLabel'; 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 { MemberLabel } from './components/MemberLabel'; import { areQueriesRelated } from './utils/query-helpers'; import { ORDER_LABEL_BY_TYPE } from './utils/labels'; @@ -73,19 +72,6 @@ const StyledTag = tasty(Tag, { }, }); -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: { @@ -107,6 +93,7 @@ const TableContainer = tasty({ const TableFooter = tasty(Space, { qa: 'ResultsTableFooter', styles: { + fill: '#white', padding: '1x', width: '100%', placeContent: 'center space-between', @@ -122,6 +109,9 @@ const OptionsButtonElement = tasty(ListMemberButton, { color: '#dark', gridColumns: 'auto', placeContent: 'center', + padding: 0, + width: '3.5x', + height: '4x', margin: '-.5x -.5x -.5x .5x', ButtonIcon: { fontSize: '20px' }, }, @@ -239,9 +229,9 @@ interface OptionsButtonProps extends Omit { name: string; member: 'dimension' | 'measure' | 'timeDimension'; order: 'none' | 'asc' | 'desc'; - onOrderChange: (order?: QueryOrder) => void; + onOrderChange?: (order?: QueryOrder) => void; onMemberRemove: (member: string) => void; - onAddFilter: (member: string) => void; + onAddFilter?: (member: string) => void; type: 'string' | 'number' | 'time' | 'boolean'; } @@ -249,59 +239,83 @@ function OptionsButton(props: OptionsButtonProps) { const { name, member, type, order, onAddFilter, onOrderChange, onMemberRemove, ...otherProps } = props; - function onAction(key: Key) { - switch (key) { - case 'none': - case 'asc': - case 'desc': - onOrderChange(key === 'none' ? undefined : key); - break; - case 'remove': - onMemberRemove(name); - break; - case 'filter': - onAddFilter(name); - break; - } - } + const onAction = useCallback( + (key: Key) => { + switch (key) { + case 'none': + case 'asc': + case 'desc': + onOrderChange?.(key === 'none' ? undefined : key); + break; + case 'remove': + onMemberRemove(name); + break; + case 'filter': + onAddFilter?.(name); + break; + } + }, + [onOrderChange, onMemberRemove, onAddFilter, name] + ); const disabledKeys = type === 'boolean' ? ['filter'] : []; + const onMemberRemoveLocal = useCallback(() => onMemberRemove(name), [onMemberRemove, name]); + + if (!onAddFilter && !onOrderChange) { + return ( + } + data-member={member} + {...otherProps} + onPress={onMemberRemoveLocal} + /> + ); + } + return ( - - }> - Do not sort - - } - textValue="Sort ASC" - > - Sort {ORDER_LABEL_BY_TYPE[type]?.[0] || 'ASC'} - - } - textValue="Sort DESC" - > - Sort {ORDER_LABEL_BY_TYPE[type]?.[1] || 'DESC'} - - - - }> - Add filter - - } - textValue="Remove member" - > - Remove member - - + {[ + ...(onOrderChange + ? [ + + }> + Do not sort + + } + textValue="Sort ASC" + > + Sort {ORDER_LABEL_BY_TYPE[type]?.[0] || 'ASC'} + + } + textValue="Sort DESC" + > + Sort {ORDER_LABEL_BY_TYPE[type]?.[1] || 'DESC'} + + , + ] + : []), + + {onAddFilter && ( + }> + Add filter + + )} + } + textValue="Remove member" + > + Remove member + + , + ]} ); @@ -385,7 +399,7 @@ const ColumnHeader = tasty({ position: 'sticky', top: 0, display: 'grid', - gridColumns: 'auto auto', + gridColumns: 'max-content max-content', placeContent: 'center space-between', placeItems: 'center', color: '#dark', @@ -673,14 +687,15 @@ export function QueryBuilderResults({ forceMinHeight }: { forceMinHeight?: boole order, cubes, error, - queryStats, - selectedCube, + usedCubes, updateQuery, grouping, + totalRows, + memberViewType, + meta, } = useQueryBuilderContext(); - const isCompact = - Object.keys(queryStats).length === 1 && - ((selectedCube && selectedCube === queryStats[selectedCube?.name]?.instance) || !selectedCube); + + const isCompact = usedCubes.length === 1; const [selectedCell, setSelectedCell] = useState<[number, string] | null>(null); const dataRef = useRef<{ [k: string]: string | number }[] | undefined>(EMPTY_DATA); const tableRef = useRef(null); @@ -711,6 +726,10 @@ export function QueryBuilderResults({ forceMinHeight }: { forceMinHeight?: boole if (tableRef.current) { tableRef.current.scrollTop = 0; } + + if (selectedCell) { + setSelectedCell(null); + } }, [page]); // reset pagination when data is changed @@ -851,9 +870,8 @@ export function QueryBuilderResults({ forceMinHeight }: { forceMinHeight?: boole selectedCell && selectedCell[0] === rowId && selectedCell[1] === timeDimension.dimension; - let value = row[timeDimension.dimension] - ? String(row[timeDimension.dimension]) - : undefined; + const rawValue = row[timeDimension.dimension + '.' + timeDimension.granularity]; + let value = rawValue ? String(rawValue) : undefined; try { value = @@ -914,6 +932,7 @@ export function QueryBuilderResults({ forceMinHeight }: { forceMinHeight?: boole page, selectedCell, data, + meta, ]); function addFilter(name: string) { @@ -927,6 +946,8 @@ export function QueryBuilderResults({ forceMinHeight }: { forceMinHeight?: boole const items = dimensions.map((dimension) => { const member = members.dimensions[dimension]; + const cubeName = dimension.split('.')[0]; + const cube = cubes.find((cube) => cube.name === cubeName); return { id: dimension, @@ -937,34 +958,38 @@ export function QueryBuilderResults({ forceMinHeight }: { forceMinHeight?: boole data-member={member ? 'dimension' : undefined} mods={{ movable: dimensions.length > 1 }} > - - - - {!isCompact ? ( - <> - {dimension.split('.')[0]} - . - - ) : undefined} - {dimension.split('.')[1]} - - {!member && MISSING} + {getOrderIcon(order.get(dimension))} - + { - if (ord) { - order.set(dimension, ord); - } else { - order.remove(dimension); - } - }} + onAddFilter={member ? addFilter : undefined} + onOrderChange={ + member + ? (ord?: QueryOrder) => { + if (ord) { + order.set(dimension, ord); + } else { + order.remove(dimension); + } + } + : undefined + } onMemberRemove={(name) => dimensionsUpdater?.remove(name)} /> @@ -990,7 +1015,7 @@ export function QueryBuilderResults({ forceMinHeight }: { forceMinHeight?: boole {(item) => {item.rendered}} ); - }, [dimensions, order]); + }, [dimensions, JSON.stringify(query.order), meta, memberViewType, isCompact]); const measuresColumns = useDeepMemo(() => { if (!measures.length) { @@ -999,6 +1024,8 @@ export function QueryBuilderResults({ forceMinHeight }: { forceMinHeight?: boole const items = measures.map((measure) => { const member = members.measures[measure]; + const cubeName = measure.split('.')[0]; + const cube = cubes.find((cube) => cube.name === cubeName); return { id: measure, @@ -1009,33 +1036,38 @@ export function QueryBuilderResults({ forceMinHeight }: { forceMinHeight?: boole data-member={member ? 'measure' : undefined} mods={{ movable: measures.length > 1 }} > - - - - {!isCompact ? ( - <> - {measure.split('.')[0]} - . - - ) : undefined} - {measure.split('.')[1]} - - {!member && MISSING} + {getOrderIcon(order.get(measure))} - + + { - if (ord) { - order.set(measure, ord); - } else { - order.remove(measure); - } - }} + onAddFilter={member ? addFilter : undefined} + onOrderChange={ + member + ? (ord?: QueryOrder) => { + if (ord) { + order.set(measure, ord); + } else { + order.remove(measure); + } + } + : undefined + } onMemberRemove={(name) => measuresUpdater?.remove(name)} /> @@ -1061,7 +1093,7 @@ export function QueryBuilderResults({ forceMinHeight }: { forceMinHeight?: boole {(item) => {item.rendered}} ); - }, [measures, order]); + }, [measures, JSON.stringify(query.order), meta, memberViewType, isCompact]); const timeDimensionsColumns = useDeepMemo(() => { if (!timeDimensions.length) { @@ -1071,6 +1103,18 @@ export function QueryBuilderResults({ forceMinHeight }: { forceMinHeight?: boole const items = timeDimensions.map((timeDimension) => { const member = members.dimensions[timeDimension.dimension]; const ordering = order.get(timeDimension.dimension); + const availableGranularities = [ + ...((member && 'granularities' in member && member?.granularities?.map((g) => g.name)) || + []), + ...PREDEFINED_GRANULARITIES, + ]; + const cubeName = timeDimension.dimension.split('.')[0]; + const cube = cubes.find((cube) => cube.name === cubeName); + const granularity = + timeDimension.granularity && + member && + 'granularities' in member && + member?.granularities?.find((g) => g.name === timeDimension.granularity); return { id: timeDimension.dimension, @@ -1081,38 +1125,51 @@ export function QueryBuilderResults({ forceMinHeight }: { forceMinHeight?: boole data-member={member ? 'timeDimension' : undefined} mods={{ movable: timeDimensions.length > 1 }} > - - - - {!isCompact ? ( - <> - {timeDimension.dimension.split('.')[0]} - . - - ) : undefined} - {timeDimension.dimension.split('.')[1]} - - {timeDimension.granularity ? ( - - {timeDimension.granularity} + + {granularity ? ( + + {memberViewType === 'title' + ? granularity.title + : (timeDimension.granularity ?? timeDimension.granularity)} ) : undefined} - {!member && MISSING} {getOrderIcon(ordering)} - + + { - if (ord) { - order.set(timeDimension.dimension, ord); - } else { - order.remove(timeDimension.dimension); - } - }} + onAddFilter={member ? addFilter : undefined} + onOrderChange={ + member + ? (ord?: QueryOrder) => { + if (ord) { + order.set(timeDimension.dimension, ord); + } else { + order.remove(timeDimension.dimension); + } + } + : undefined + } onMemberRemove={(name) => grouping.remove(name)} /> @@ -1136,7 +1193,7 @@ export function QueryBuilderResults({ forceMinHeight }: { forceMinHeight?: boole {(item) => {item.rendered}} ); - }, [timeDimensions, order]); + }, [timeDimensions, JSON.stringify(query.order), meta, memberViewType, isCompact]); const timestamp = useMemo(() => { return new Date(); @@ -1150,6 +1207,15 @@ export function QueryBuilderResults({ forceMinHeight }: { forceMinHeight?: boole setTimeDistance(formatDistance(timestamp, new Date(), { addSuffix: true })); }, 60 * 1000); + const noResultsDisclaimer = ( + + + No results available + + Compose and run a query to see the results. + + ); + return ( - {!executedQuery ? ( - - - Run query to see results here - - - ) : null} + {!executedQuery ? noResultsDisclaimer : null} ) : ( - - - Run query to see results here - - Select dimensions and metrics that you want to see in results. - + noResultsDisclaimer )} {isLoading ? : isResultOutdated ? : undefined} {executedQuery && !isLoading && isColumnsSelected && queryRelated && ( - + - {data.length ? `${data.length} result${data.length > 1 ? 's' : ''}` : 'No results'} + {data.length + ? `${data.length} result${data.length > 1 ? 's' : ''}${ + totalRows + ? totalRows === data.length + ? ' in total' + : ` out of ${totalRows} in total` + : '' + }` + : 'No results'} received {timeDistance} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderSidePanel.tsx b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderSidePanel.tsx index 2feabd1ab17d6..a08a2af46eea5 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderSidePanel.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/QueryBuilderSidePanel.tsx @@ -4,6 +4,7 @@ import { DialogContainer, Flex, Radio, + Panel, SearchInput, Space, tasty, @@ -11,16 +12,26 @@ import { Title, CloseIcon, TooltipProvider, + ResizablePanel, } from '@cube-dev/ui-kit'; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { + ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { EditOutlined, LoadingOutlined, StarFilled, StarOutlined } from '@ant-design/icons'; -import { validateQuery } from '@cubejs-client/core'; -import { useDebouncedValue, useFilteredCubes, useDeepMemo, useEvent } from './hooks'; +import { useDebouncedValue, useFilteredCubes, useEvent, useLocalStorage } from './hooks'; import { useQueryBuilderContext } from './context'; -import { Panel } from './components/Panel'; import { EditQueryDialogForm } from './components/EditQueryDialogForm'; import { SidePanelCubeItem } from './components/SidePanelCubeItem'; +import { validateQuery } from './utils'; + +const DEFAULT_SIDEBAR_SIZE = 315; const RadioButton = tasty(Radio.Button, { styles: { flexGrow: 1, placeItems: 'stretch' }, @@ -38,18 +49,20 @@ const CountBadge = tasty(Badge, { type Props = { defaultSelectedType?: 'cubes' | 'views'; - customTypeSwitcher?: React.ReactNode; + customTypeSwitcher?: ReactNode; showEditQueryButton?: boolean; + width?: string; }; export function QueryBuilderSidePanel({ defaultSelectedType = 'cubes', customTypeSwitcher = null, showEditQueryButton = true, + width, }: Props) { const { query, - cubes: items = [], + cubes: cubesAndViews = [], selectCube, isQueryEmpty, joinableCubes, @@ -60,7 +73,13 @@ export function QueryBuilderSidePanel({ setQuery, usedCubes, usedMembers, + queryStats, + members, + missingCubes, apiVersion, + isMetaLoading, + memberViewType, + disableSidebarResizing, } = useQueryBuilderContext(); const contentRef = useRef(null); @@ -73,36 +92,35 @@ export function QueryBuilderSidePanel({ const [selectedType, setSelectedType] = useState<'cubes' | 'views'>(defaultSelectedType); - items.sort((a, b) => a.name.localeCompare(b.name)); + const [sidebarSize, setSidebarSize] = useLocalStorage( + 'QueryBuilder:Sidebar:size', + DEFAULT_SIDEBAR_SIZE + ); - // @ts-ignore - const cubes = items.filter((item) => item.type === 'cube'); - // @ts-ignore - const views = items.filter((item) => item.type === 'view'); + const cubes = cubesAndViews.filter((item) => item.type === 'cube'); + const views = cubesAndViews.filter((item) => item.type === 'view'); - const preparedFilterString = filterString.trim().replaceAll('_', ' ').toLowerCase(); + const preparedFilterString = filterString.trim().toLowerCase(); const debouncedFilterString = useDebouncedValue(preparedFilterString, 500); const appliedFilterString = preparedFilterString.length < 2 ? '' : debouncedFilterString; - const allCubes = selectedType === 'cubes' ? cubes : views; + const cubesOrViews = selectedType === 'cubes' ? cubes : views; const allJoinableCubes = selectedType === 'views' && usedCubes.length - ? allCubes.filter((cube) => usedCubes[0] === cube.name) - : allCubes.filter((cube) => joinableCubes.includes(cube)); + ? cubesOrViews.filter((cube) => usedCubes[0] === cube.name) + : cubesOrViews.filter((cube) => joinableCubes.includes(cube)); - const [openCubes, setOpenCubes] = useState>( - isQueryEmpty ? (allCubes.length ? new Set(allCubes[0].name) : new Set()) : new Set(usedCubes) - ); + const [openCubes, setOpenCubes] = useState>(new Set()); - const highlightedCubes = useMemo(() => { - if (appliedFilterString) { - return usedCubes; + useLayoutEffect(() => { + if (isQueryEmpty) { + setOpenCubes(cubesOrViews.length === 1 ? new Set([cubesOrViews[0].name]) : new Set()); } + }, [cubesOrViews.length, selectedType]); - return []; - }, [appliedFilterString]); + const highlightedCubes = appliedFilterString ? usedCubes : []; - allCubes.sort((a, b) => { + cubesOrViews.sort((a, b) => { if (highlightedCubes.includes(a.name) && !highlightedCubes.includes(b.name)) { return -1; } @@ -111,11 +129,17 @@ export function QueryBuilderSidePanel({ return 1; } - return a.name.localeCompare(b.name); + return memberViewType === 'name' + ? a.name.localeCompare(b.name) + : a.title.localeCompare(b.title); }); // Filtered cubes - const { cubes: filteredCubes } = useFilteredCubes(appliedFilterString, allJoinableCubes); + const filteredCubes = useFilteredCubes( + appliedFilterString, + allJoinableCubes, + memberViewType + ).cubes.map((cube) => cube.name); const resetScrollAndContentSize = useCallback(() => { if (contentRef?.current) { @@ -217,7 +241,7 @@ export function QueryBuilderSidePanel({ ); }, [selectedType, meta, filterString]); - useLayoutEffect(() => { + useEffect(() => { if (scrollToCubeName) { setTimeout(() => { const element = containerRef.current?.querySelector( @@ -229,18 +253,41 @@ export function QueryBuilderSidePanel({ block: 'start', }); } - }, 100); + }); setScrollToCubeName(null); } }, [scrollToCubeName]); + // Close all disabled cubes to avoid layout shift on deselecting member. + useEffect(() => { + const currentSize = openCubes.size; + const allJoinableCubeNames = allJoinableCubes.map((cube) => cube.name); + + openCubes.forEach((cubeName) => { + if (!allJoinableCubeNames.includes(cubeName) && !missingCubes.includes(cubeName)) { + openCubes.delete(cubeName); + } + }); + + if (currentSize !== openCubes.size) { + setOpenCubes(new Set(openCubes)); + } + }, [openCubes.size, missingCubes.length, allJoinableCubes.length]); + + function resetState(cubeName?: string) { + setFilterString(''); + setViewMode('all'); + + if (cubeName) { + setOpenCubes(new Set([cubeName])); + setScrollToCubeName(cubeName); + } + } + function onCubeToggle(name: string, isOpen: boolean) { if (appliedFilterString || viewMode === 'query') { - setFilterString(''); - setViewMode('all'); - setOpenCubes(new Set([name])); - setScrollToCubeName(name); + resetState(name); return; } @@ -255,42 +302,77 @@ export function QueryBuilderSidePanel({ } const onMemberToggle = useEvent((cubeName: string, memberName: string) => { - if ( - appliedFilterString && - !query?.dimensions?.includes(memberName) && - !query?.measures?.includes(memberName) - ) { - setScrollToCubeName(cubeName); - setFilterString(''); - setViewMode('all'); - setOpenCubes(new Set([cubeName])); + const isTimeDimension = members.dimensions[memberName]?.type === 'time'; + + // Always reset state if we click on time dimension + if (isTimeDimension || (appliedFilterString && !usedMembers.includes(memberName))) { + resetState(cubeName); } }); - const cubeList = useDeepMemo(() => { + const onHierarchyToggle = useEvent((cubeName?: string) => { + if (appliedFilterString || viewMode === 'query') { + resetState(cubeName); + } + }); + + const cubeList = useMemo(() => { return ( - - {allCubes.map((item) => ( - { - onCubeToggle(item.name, isOpen); - }} - onMemberToggle={(name) => { - onMemberToggle(item.name, name); - }} - /> - ))} + + {missingCubes + .filter((cubeName) => (appliedFilterString ? filteredCubes.includes(cubeName) : true)) + .map((cubeName) => ( + { + onMemberToggle(cubeName, name); + }} + /> + ))} + {cubesOrViews + .filter((cube) => + appliedFilterString + ? // If filter is applied, show only filtered cubes + filteredCubes.includes(cube.name) + : viewMode === 'query' + ? // In query mode, show only used cubes + usedCubes.includes(cube.name) + : true + ) + .map((cube) => ( + { + onCubeToggle(cube.name, isOpen); + }} + onMemberToggle={(name) => { + onMemberToggle(cube.name, name); + }} + onHierarchyToggle={onHierarchyToggle} + /> + ))} ); - }, [allCubes, viewMode, meta, openCubes.size, appliedFilterString, usedCubes.join(',')]); + }, [ + viewMode, + queryStats, + [...openCubes.values()].join(), + appliedFilterString, + memberViewType, + selectedType, + ]); const onApplyQuery = useCallback(async (query) => { try { @@ -310,6 +392,8 @@ export function QueryBuilderSidePanel({ setOpenCubes(new Set(usedCubes)); + setScrollToCubeName(usedCubes[0]); + if (isQueryEmpty) { setViewMode('all'); } @@ -318,7 +402,7 @@ export function QueryBuilderSidePanel({ const topBar = useMemo(() => { return ( - + {showEditQueryButton ? editQueryButton : null} {!usedCubes.length ? ( @@ -336,11 +420,11 @@ export function QueryBuilderSidePanel({ icon={viewMode === 'all' ? : } onPress={() => setViewMode(viewMode === 'all' ? 'query' : 'all')} > - {viewMode === 'all' ? 'All' : 'Used'} members + {viewMode === 'all' ? 'All members' : 'Used only'} )} - {isVerifying && } + {isVerifying || isMetaLoading ? : null} @@ -353,8 +437,9 @@ export function QueryBuilderSidePanel({ icon={} onPress={() => { clearQuery(); - selectCube(null); - setOpenCubes(new Set()); + setOpenCubes( + cubesOrViews.length === 1 ? new Set([cubesOrViews[0].name]) : new Set() + ); resetScrollAndContentSize(); }} > @@ -364,17 +449,10 @@ export function QueryBuilderSidePanel({ ); - }, [viewMode, isQueryEmpty, usedMembers.length, appliedFilterString, isVerifying]); + }, [viewMode, isQueryEmpty, isMetaLoading, usedMembers.length, appliedFilterString, isVerifying]); - return ( - + const content = ( + <> setIsPasteDialogOpen(false)}> ) : undefined} - + {cubeList} + + ); + + return disableSidebarResizing ? ( + + {content} + ) : ( + + {content} + ); } diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionItemTitle.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionItemTitle.tsx index ecd65ada15a8c..05f7757722111 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionItemTitle.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/Accordion/AccordionItemTitle.tsx @@ -28,7 +28,7 @@ const StyledAccordionItemTitleWrap = tasty({ subtitle: 'auto 1fr auto', }, placeItems: 'center start', - gap: '1x', + gap: '0', width: '100%', borderRadius: { '': 0, focused: '0.5x' }, outline: { '': '#purple-04.0', focused: '#purple-04' }, diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/AddFilterInput.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/AddFilterInput.tsx new file mode 100644 index 0000000000000..87e5dd64949d6 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/AddFilterInput.tsx @@ -0,0 +1,316 @@ +import { + Block, + Button, + ComboBox, + Menu, + MenuTrigger, + PlusIcon, + Select, + tasty, + useToastsApi, +} from '@cube-dev/ui-kit'; +import { Filter, TCubeDimension, TCubeMeasure, TCubeSegment } from '@cubejs-client/core'; +import { Key, useEffect, useMemo, useRef, useState } from 'react'; + +import { useQueryBuilderContext } from '../context'; +import { useEvent } from '../hooks'; +import { MemberType } from '../types'; + +import { MemberLabel } from './MemberLabel'; + +const AddFilterButton = tasty(Button, { + qa: 'AddFilterButton', + 'aria-label': 'Add a new filter', + size: 'small', + type: 'secondary', + icon: , + width: { + '': '3x', + label: 'auto', + }, +}); + +interface AddFilterInputProps { + hasLabel?: boolean; + isCompact?: boolean; + onAdd: (filter: Filter) => void; + onSegmentAdd?: (segment: string) => void; + onDateRangeAdd?: (timeDimension: string) => void; +} + +export function AddFilterInput(props: AddFilterInputProps) { + const { onAdd, onDateRangeAdd, onSegmentAdd, isCompact, hasLabel } = props; + const [mode, setMode] = useState<'measure' | 'dimension' | 'segment' | 'dateRange' | null>(null); + const { toast } = useToastsApi(); + const inputRef = useRef(null); + + const { + query, + joinableMembers, + usedCubes, + cubes, + memberViewType, + dateRanges: queryDateRanges, + } = useQueryBuilderContext(); + + const [members, dimensions, measures, dateRanges, segments, nameToMemberType] = useMemo(() => { + // Sort members by cube name and used status + const sort = ( + a: TCubeDimension | TCubeMeasure | TCubeSegment, + b: TCubeDimension | TCubeMeasure | TCubeSegment + ) => { + const aCubeName = usedCubes.find((cubeName) => cubeName === a.name.split('.')[0]); + const bCubeName = usedCubes.find((cubeName) => cubeName === b.name.split('.')[0]); + + if (aCubeName || bCubeName) { + if (aCubeName && bCubeName) { + return aCubeName.localeCompare(bCubeName); + } else { + return aCubeName && !bCubeName ? -1 : 1; + } + } + + return a.name.localeCompare(b.name); + }; + + const members = [ + ...Object.values(joinableMembers.dimensions).sort(sort), + ...Object.values(joinableMembers.measures).sort(sort), + ...Object.values(joinableMembers.segments).sort(sort), + ]; + + const nameToMemberType = members.reduce( + (acc, member) => { + acc[member.name] = ['dimension', 'measure', 'segment'][ + [ + !!joinableMembers.dimensions[member.name], + !!joinableMembers.measures[member.name], + !!joinableMembers.segments[member.name], + ].indexOf(true) + ] as MemberType; + + return acc; + }, + {} as Record + ); + + const dimensions = members.filter((member) => { + return nameToMemberType[member.name] === 'dimension'; + }); + + const measures = members.filter((member) => { + return nameToMemberType[member.name] === 'measure'; + }); + + const segments = members + .filter((member) => { + return nameToMemberType[member.name] === 'segment'; + }) + .filter((member) => { + return !query.segments?.includes(member.name); + }); + + const dateRanges = members + .filter( + (member) => + nameToMemberType[member.name] === 'dimension' && + (member as TCubeDimension).type === 'time' + ) + .filter((member) => { + return !queryDateRanges.list.includes(member.name); + }) as TCubeDimension[]; + + return [ + members, + dimensions as TCubeDimension[], + measures as TCubeMeasure[], + dateRanges as TCubeDimension[], + segments as TCubeSegment[], + nameToMemberType, + ]; + }, [ + JSON.stringify(joinableMembers), + queryDateRanges.list.join(), + query.segments?.join(), + usedCubes.length, + ]); + + const shownMembers = useMemo(() => { + let shownMembers: (TCubeSegment | TCubeDimension | TCubeMeasure)[]; + + switch (mode) { + case 'measure': + shownMembers = measures; + break; + case 'dimension': + shownMembers = dimensions; + break; + case 'segment': + shownMembers = segments; + break; + case 'dateRange': + shownMembers = dateRanges; + break; + default: + shownMembers = []; + } + + return shownMembers; + }, [members, mode]); + + const onAction = useEvent((key: Key) => { + if (key === 'and') { + onAdd({ and: [] as Filter[] }); + + return; + } + + if (key === 'or') { + onAdd({ or: [] as Filter[] }); + + return; + } + + setMode(key as 'measure' | 'dimension' | 'segment' | 'dateRange'); + }); + + const onFilterAdd = useEvent((key: Key | null) => { + if (!key) { + return; + } + + if (mode === 'dateRange') { + onDateRangeAdd?.(key as string); + } else if (mode === 'segment') { + onSegmentAdd?.(key as string); + } else { + onAdd({ member: key as string, operator: 'equals', values: [] }); + } + }); + + const items = useMemo(() => { + const items = [ + { value: 'dimension', label: 'Filter by Dimension' }, + { value: 'measure', label: 'Filter by Measure' }, + ]; + + if (onSegmentAdd) { + items.push({ value: 'segment', label: 'Filter by Segment' }); + } + + if (onDateRangeAdd) { + items.push({ value: 'dateRange', label: 'Filter by Date Range' }); + } + + items.push({ value: 'and', label: 'AND Branch' }, { value: 'or', label: 'OR Branch' }); + + return items; + }, [onDateRangeAdd]); + + const disabledKeys = useMemo(() => { + const disabledKeys: string[] = []; + + if (!dateRanges.length) { + disabledKeys.push('dateRange'); + } + + if (!dimensions.length) { + disabledKeys.push('dimension'); + } + + if (!measures.length) { + disabledKeys.push('measure'); + } + + if (!segments.length) { + disabledKeys.push('segment'); + } + + return disabledKeys; + }, [dateRanges.length, dimensions.length, measures.length, segments.length]); + + useEffect(() => { + if (mode && !shownMembers.length && !['or', 'and'].includes(mode)) { + const title = { + measure: 'filter', + dimension: 'filter', + segment: 'segment', + dateRange: 'date range', + }[mode]; + + toast.attention({ + header: `Unable to add new ${title}`, + description: 'No available members', + }); + setMode(null); + } + }, [shownMembers.length, mode]); + + // Hack to focus input after it's shown + // autoFocus and menuTrigger="focus" don't work together + useEffect(() => { + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + }, [inputRef.current, mode]); + + return ( + + {!mode ? ( + + + {hasLabel ? 'Add' : undefined} + + + {items.map((item) => ( + + {item.label} + + ))} + + + ) : ( + item.value === mode)?.label} + listBoxStyles={{ + height: 'max min(40x, 45vh)', + }} + onSelectionChange={onFilterAdd} + onBlur={() => setMode(null)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setMode(null); + } + }} + > + {shownMembers.map((member) => { + const memberName = member.name.split('.')[1]; + const cubeName = member.name.split('.')[0]; + const cube = cubes.find((cube) => cube.name === cubeName); + + return ( + + + + ); + })} + + )} + + ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/Badge.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/Badge.tsx index e753ea7857584..de89582467a10 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/Badge.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/Badge.tsx @@ -11,7 +11,7 @@ const MemberBadgeElement = tasty(Badge, { color: { '': '#dark', '![data-member]': '#danger-text', - special: '#white', + 'special | missing': '#white', }, fill: { '': '#danger-text.15', @@ -25,6 +25,7 @@ const MemberBadgeElement = tasty(Badge, { '[data-member="timeDimension"] & special': '#time-dimension-text', '[data-member="segment"] & special': '#segment-text', '[data-member="filter"] & special': '#filter-text', + missing: '#danger', }, preset: 't4m', width: 'max-content', @@ -38,16 +39,18 @@ export const MemberBadge = memo( ({ type, isSpecial, + isMissing, children, }: { type?: 'measure' | 'dimension' | 'segment' | 'filter' | 'timeDimension'; isSpecial?: boolean; + isMissing?: boolean; children: ReactNode | number; }) => { return ( {!type && } diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/ChartRenderer.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/ChartRenderer.tsx index e96ac3201c705..e1d60a2d1f441 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/ChartRenderer.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/ChartRenderer.tsx @@ -89,7 +89,6 @@ function CartesianChart({ tooltipCursor = false, extra, }: any) { - const locale = 'en-US'; const legendFormatter = useCallback( (value) => {value}, [] diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/DateRangeFilter.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/DateRangeFilter.tsx index 9a3f224a68947..dd4f176884682 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/DateRangeFilter.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/DateRangeFilter.tsx @@ -1,25 +1,60 @@ import { Key, useCallback, useState } from 'react'; -import { Item, Select, Space, Text, TooltipProvider } from '@cube-dev/ui-kit'; +import { Item, Select, Space, tasty, Text } 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 { useEvent } from '../hooks'; +import { MemberViewType } from '../types'; import { FilterLabel } from './FilterLabel'; import { TimeDateRangeSelector } from './TimeDateRangeSelector'; -import { DeleteFilterButton } from './DeleteFilterButton'; +import { FilterOptionsAction, FilterOptionsButton } from './FilterOptionsButton'; interface TimeDimensionFilterProps { + name: string; member: TimeDimension; + memberName?: string; + memberTitle?: string; + cubeName?: string; + cubeTitle?: string; + memberViewType?: MemberViewType; isCompact?: boolean; isMissing?: boolean; onChange: (dateRange?: DateRange) => void; onRemove: () => void; } +const DateRangeFilterWrapper = tasty(Space, { + qa: 'DateRangeFilter', + styles: { + gap: '1x', + flow: 'row wrap', + radius: true, + fill: { + '': '#clear', + ':has(>[data-qa="FilterOptionsButton"][data-is-hovered])': '#light', + }, + margin: '-.5x', + padding: '.5x', + width: 'max-content', + }, +}); + export function DateRangeFilter(props: TimeDimensionFilterProps) { - const { member, isCompact, isMissing, onRemove, onChange } = props; + const { + member, + isCompact, + isMissing, + name, + cubeName, + cubeTitle, + memberName, + memberTitle, + onRemove, + onChange, + } = props; const [open, setOpen] = useState(false); // const onGranularityChange = useCallback( @@ -61,16 +96,26 @@ export function DateRangeFilter(props: TimeDimensionFilterProps) { setOpen(open); }; + const onAction = useEvent((key: FilterOptionsAction) => { + if (key === 'remove') { + onRemove(); + } + }); + return ( - - - - + + + for @@ -112,6 +157,6 @@ export function DateRangeFilter(props: TimeDimensionFilterProps) { {/* );*/} {/* })}*/} {/**/} - + ); } diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/DeleteFilterButton.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/DeleteFilterButton.tsx deleted file mode 100644 index dca934ddbb121..0000000000000 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/DeleteFilterButton.tsx +++ /dev/null @@ -1,9 +0,0 @@ -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 index 35f1a2228c6e7..b67d667ae5a38 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/EditQueryDialogForm.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/EditQueryDialogForm.tsx @@ -1,11 +1,12 @@ import { DialogForm, LoadingIcon, Radio, Space, TextArea, useForm } from '@cube-dev/ui-kit'; -import { Meta, Query, validateQuery } from '@cubejs-client/core'; +import { Meta, Query } from '@cubejs-client/core'; import { ValidationRule } from '@cube-dev/ui-kit/types/shared'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { parse as BestEffortJsonParse } from 'best-effort-json-parser'; import { useQueryBuilderContext } from '../context'; import { useServerCoreVersionGte } from '../hooks'; -import { convertGraphQLToJsonQuery, convertJsonQueryToGraphQL } from '../utils'; +import { convertGraphQLToJsonQuery, convertJsonQueryToGraphQL, validateQuery } from '../utils'; interface PasteQueryDialogFormProps { query?: Query; @@ -15,9 +16,13 @@ interface PasteQueryDialogFormProps { onSubmit: (query: Query) => void; } +const DEFAULT_GRAPHQL_QUERY = `query CubeQuery { + cube +}`; + function validateJsonQuery(json: string) { try { - return validateQuery(JSON.parse(json)); + return validateQuery(BestEffortJsonParse(json)); } catch (e: any) { throw 'Invalid query'; } @@ -46,10 +51,10 @@ function getJSONValidator(apiUrl: string, apiToken: string | null, meta?: Meta | return [ { async validator(rule: ValidationRule, query: string) { - const originalQuery = JSON.stringify(JSON.parse(query)); + const originalQuery = JSON.stringify(BestEffortJsonParse(query)); const graphQLQuery = convertJsonQueryToGraphQL({ meta, - query: JSON.parse(query), + query: BestEffortJsonParse(query), }); return convertGraphQLToJsonQuery({ @@ -79,7 +84,7 @@ const QUERY_VALIDATOR = { const JSON_VALIDATOR = { async validator(rule: ValidationRule, value: string) { try { - JSON.parse(value); + BestEffortJsonParse(value); } catch (e: any) { throw ''; // do not show any error message } @@ -113,7 +118,7 @@ export function EditQueryDialogForm(props: PasteQueryDialogFormProps) { ); } - return validateQuery(JSON.parse(query) || {}); + return validateQuery(BestEffortJsonParse(query) || {}); } const onJsonBlur = useCallback(async () => { @@ -127,29 +132,16 @@ export function EditQueryDialogForm(props: PasteQueryDialogFormProps) { } const jsonQuery = form.getFieldValue('jsonQuery'); - + let sanitizedQuery = {}; try { - const query = validateQuery(JSON.parse(jsonQuery) || {}); - const graphQLQuery = convertJsonQueryToGraphQL({ meta, query }); - const ungrouped = query.ungrouped; - - return convertGraphQLToJsonQuery({ - apiUrl, - apiToken, - query: graphQLQuery, - }).then( - (jsonQuery) => { - const query = JSON.parse(jsonQuery); - - form.setFieldValue('jsonQuery', JSON.stringify({ ...query, ungrouped }, null, 2)); - }, - () => { - throw ''; - } - ); - } catch (e: any) { + sanitizedQuery = validateQuery(BestEffortJsonParse(jsonQuery)); + } catch (e) { // do nothing } + + const query = sanitizedQuery; + + form.setFieldValue('jsonQuery', JSON.stringify(query, null, 2)); }, [meta]); const onGraphqlBlur = useCallback(async () => { @@ -200,6 +192,14 @@ export function EditQueryDialogForm(props: PasteQueryDialogFormProps) { form.setFieldValue(type === 'json' ? 'jsonQuery' : 'graphqlQuery', value); }) + .catch((e) => { + form.setFieldValue( + type === 'json' ? 'jsonQuery' : 'graphqlQuery', + type === 'json' ? '{}' : DEFAULT_GRAPHQL_QUERY + ); + + return 'Unable to convert query'; + }) .finally(() => { setIsBlocked(false); }); diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/FilterByMemberButton.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/FilterByMemberButton.tsx index 52c61023759b3..2fcec81bb0b69 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/FilterByMemberButton.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/FilterByMemberButton.tsx @@ -1,11 +1,13 @@ -import { tasty, TooltipProvider } from '@cube-dev/ui-kit'; +import { CubeButtonProps, tasty, TooltipProvider } from '@cube-dev/ui-kit'; import { FilterFilled, FilterOutlined } from '@ant-design/icons'; +import { memo } from 'react'; import { ListMemberButton } from './ListMemberButton'; const OptionButtonElement = tasty(ListMemberButton, { 'aria-label': 'Options', styles: { + width: '3.5x', color: { '': '#measure-text-color', 'filtered & [data-member="timeDimension"]': '#time-dimension-text', @@ -23,8 +25,16 @@ const OptionButtonElement = tasty(ListMemberButton, { }, gridColumns: 'auto', placeContent: 'center', - radius: '1r right', - margin: '-.75x -1.5x -.75x 0', + placeItems: 'center', + radius: { + '': 0, + angular: '1r right', + }, + margin: { + '': '-.75x 0 -.75x 0', + angular: '-.75x -.75x -.75x 0', + }, + padding: 0, ButtonIcon: { fontSize: '16px' }, }, @@ -32,21 +42,27 @@ const OptionButtonElement = tasty(ListMemberButton, { interface ListMemberOptionButtonProps { type: string; + isAngular?: boolean; isFiltered?: boolean; + color?: CubeButtonProps['color']; onPress?: () => void; } -export function FilterByMemberButton(props: ListMemberOptionButtonProps) { - const { type, isFiltered, onPress } = props; +export const FilterByMemberButton = memo((props: ListMemberOptionButtonProps) => { + const { type, isFiltered, isAngular, color, onPress } = props; return ( - + : } - mods={{ filtered: isFiltered }} + color={color} + mods={{ filtered: isFiltered, angular: isAngular }} data-member={type} onPress={onPress} /> ); -} +}); diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/FilterLabel.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/FilterLabel.tsx index cec1e793b044b..0f6f774968af4 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/FilterLabel.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/FilterLabel.tsx @@ -1,13 +1,21 @@ -import { tasty } from '@cube-dev/ui-kit'; +import { Action, CloseIcon, tasty } from '@cube-dev/ui-kit'; import { TCubeMemberType } from '@cubejs-client/core'; import { getTypeIcon } from '../utils'; +import { MemberViewType } from '../types'; +import { useShownMemberName } from '../hooks'; import { MemberLabelText } from './MemberLabelText'; const FilterMemberElement = tasty({ styles: { - padding: '.75x 1x', + padding: { + '': '.75x 1x', + '[data-size="small"]': '.25x .5x', + }, + display: 'flex', + justifyContent: 'space-between', + position: 'relative', radius: true, fill: { '': '#border', @@ -25,25 +33,61 @@ interface FilterLabelProps { isCompact?: boolean; isMissing?: boolean; name: string; + memberName?: string; + memberTitle?: string; + cubeName?: string; + cubeTitle?: string; + memberViewType?: MemberViewType; + size?: 'small' | 'normal'; + hideIcon?: boolean; + onRemove?: () => Promise; } export function FilterLabel(props: FilterLabelProps) { - const { type, isMissing, member, name, isCompact } = props; + const { + type, + isMissing, + member, + name, + cubeName = props.name.split('.')[0], + cubeTitle, + memberName = props.name.split('.')[1], + memberTitle, + memberViewType, + isCompact, + size = 'normal', + hideIcon = false, + onRemove, + } = props; + + const { shownMemberName, shownCubeName } = useShownMemberName({ + memberName, + memberTitle, + cubeName, + cubeTitle, + type: memberViewType, + }); return ( - - - {getTypeIcon(type)} - + + + {!hideIcon ? getTypeIcon(type) : null} + {!isCompact ? ( <> - {name.split('.')[0]} - . + {shownCubeName} + {memberViewType === 'name' ? '.' : <> } ) : undefined} - {name.split('.')[1]} + {shownMemberName} + + {onRemove ? ( + + + + ) : null} ); } diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/MemberFilter.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/FilterMember.tsx similarity index 55% rename from packages/cubejs-playground/src/QueryBuilderV2/components/MemberFilter.tsx rename to packages/cubejs-playground/src/QueryBuilderV2/components/FilterMember.tsx index 2b774e3f28091..bab235df0a4a6 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/MemberFilter.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/FilterMember.tsx @@ -14,19 +14,22 @@ import { BinaryFilter, BinaryOperator, Filter, + LogicalAndFilter, + LogicalOrFilter, TCubeMemberType, UnaryFilter, UnaryOperator, } from '@cubejs-client/core'; -import { useDeepMemo } from '../hooks'; +import { useDeepMemo, useEvent } from '../hooks'; import { OPERATOR_LABELS, OPERATORS, OPERATORS_BY_TYPE, UNARY_OPERATORS } from '../values'; +import { MemberViewType } from '../types'; import { ValuesInput } from './ValuesInput'; import { TimeDateRangeSelector } from './TimeDateRangeSelector'; import { TimeDateSelector } from './TimeDateSelector'; -import { DeleteFilterButton } from './DeleteFilterButton'; import { FilterLabel } from './FilterLabel'; +import { FilterOptionsAction, FilterOptionsButton } from './FilterOptionsButton'; interface OperatorSelectorProps { type: TCubeMemberType; @@ -36,9 +39,18 @@ interface OperatorSelectorProps { } const MemberFilterElement = tasty(Space, { + qa: 'MemberFilter', styles: { gap: '1x', placeItems: 'start', + radius: true, + fill: { + '': '#clear', + ':has([data-qa="FilterOptionsButton"][data-is-hovered])': '#light', + }, + margin: '-.5x', + padding: '.5x', + width: 'max-content', InnerContainer: { display: 'flex', @@ -61,6 +73,7 @@ const ValueTag = tasty(Tag, { styles: { padding: '.625x .75x', preset: 't3', + fill: '#light', }, }); @@ -72,7 +85,7 @@ function OperatorSelector(props: OperatorSelectorProps) { isDisabled={isDisabled} aria-label="Filter operator" size="small" - width="14x max-content max-content" + listBoxStyles={{ height: 'auto' }} selectedKey={value} onSelectionChange={(operator: Key) => onChange(operator as UnaryOperator | BinaryOperator)} > @@ -87,11 +100,16 @@ function OperatorSelector(props: OperatorSelectorProps) { ); } -interface MemberFilterProps { - member: Filter; +interface FilterMemberProps { + filter: BinaryFilter | UnaryFilter; + cubeName?: string; + cubeTitle?: string; + memberName?: string; + memberTitle?: string; memberType?: 'dimension' | 'measure'; + memberViewType?: MemberViewType; type: TCubeMemberType; - // Extra compact where all items in the filter are streched to fit within the container. + // Extra compact where all items in the filter are stretched to fit within the container. isExtraCompact?: boolean; isCompact?: boolean; isMissing?: boolean; @@ -99,62 +117,79 @@ interface MemberFilterProps { onRemove: () => void; } -export function MemberFilter(props: MemberFilterProps) { +export function FilterMember(props: FilterMemberProps) { const { - member, + filter, memberType, isCompact, isExtraCompact = false, isMissing, type, + cubeName, + cubeTitle, + memberName, + memberTitle, + memberViewType, onRemove, + onChange, } = props; - const onOperatorChange = useCallback( - (operator?: Key) => { - const updatedFilter = { - ...member, - operator: operator, - values: [], - } as Filter; + const onOperatorChange = useEvent((operator?: Key) => { + const updatedFilter = { + values: [], + ...filter, + operator: operator, + } as BinaryFilter | UnaryFilter; - if (['set', 'notSet'].includes(operator as string)) { - delete (updatedFilter as UnaryFilter | BinaryFilter).values; - } + if (type === 'time') { + updatedFilter.values = []; + } - if (['equals', 'notEquals'].includes(operator as string) && type === 'boolean') { - (updatedFilter as UnaryFilter | BinaryFilter).values = ['true']; - } + if (['set', 'notSet'].includes(operator as string)) { + delete updatedFilter.values; + } - props.onChange(updatedFilter); - }, - [props.onChange] - ); + if (['equals', 'notEquals'].includes(operator as string) && type === 'boolean') { + updatedFilter.values = ['true']; + } - const onValuesChange = useCallback( - (values?: string[]) => { - props.onChange({ ...member, values: values } as Filter); - }, - [props.onChange] - ); + onChange(updatedFilter); + }); + + const onValuesChange = useEvent((values?: string[]) => { + onChange({ ...filter, values: values } as Filter); + }); + + const wrapFilter = useEvent((type: 'and' | 'or') => { + onChange({ [type]: [filter] } as LogicalAndFilter | LogicalOrFilter); + }); const inputs = useDeepMemo(() => { + const operator = filter.operator; + if ( - !('member' in member) || - UNARY_OPERATORS.includes(member.operator) || - !OPERATORS.includes(member.operator) + !('member' in filter) || + UNARY_OPERATORS.includes(filter.operator) || + !OPERATORS.includes(filter.operator) ) { return null; } + const allowSuggestions = + type === 'string' && (operator === 'equals' || operator === 'notEquals'); + switch (type) { case 'number': case 'string': return ( ); @@ -162,22 +197,24 @@ export function MemberFilter(props: MemberFilterProps) { return ( onValuesChange(value ? ['true'] : ['false'])} /> ); case 'time': - if (member.operator.includes('Range')) { + if (filter.operator.includes('Range')) { return ( ); - } else if (member.operator.includes('Date')) { + } else if (filter.operator.includes('Date')) { return ( { onValuesChange([val]); }} @@ -185,15 +222,24 @@ export function MemberFilter(props: MemberFilterProps) { ); } else { return ( - + ); } default: - return member.values?.map((value: string, i: number) => { + return filter.values?.map((value: string, i: number) => { return {value}; }); } - }, [member, type]); + }, [filter, type]); const ElementWrapper = useMemo( () => (isExtraCompact ? Fragment : MemberFilterElement), @@ -222,19 +268,31 @@ export function MemberFilter(props: MemberFilterProps) { [isExtraCompact] ); + const onAction = useEvent((key: FilterOptionsAction) => { + switch (key) { + case 'remove': + onRemove(); + break; + case 'wrapWithAnd': + wrapFilter('and'); + break; + case 'wrapWithOr': + wrapFilter('or'); + break; + } + }); + return ( - - - + - {'and' in member || 'or' in member ? ( + {'and' in filter || 'or' in filter ? ( <> UNSUPPORTED OPERATOR... @@ -242,22 +300,27 @@ export function MemberFilter(props: MemberFilterProps) { ) : ( <> - {'member' in member && member.member ? ( + {'member' in filter && filter.member ? ( ) : null} { } diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/FilterOptionsButton.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/FilterOptionsButton.tsx new file mode 100644 index 0000000000000..82d3797cf9d82 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/FilterOptionsButton.tsx @@ -0,0 +1,83 @@ +import { Button, Menu, MenuTrigger, MoreIcon, tasty } from '@cube-dev/ui-kit'; +import { Key } from '@react-types/shared'; +import { useMemo } from 'react'; + +const OptionsButton = tasty(Button, { + qa: 'FilterOptionsButton', + 'aria-label': 'Filter options', + size: 'small', + type: 'secondary', + icon: , + styles: { + width: '3x', + }, +}); + +export type FilterOptionsAction = 'convert' | 'remove' | 'unwrap' | 'wrapWithOr' | 'wrapWithAnd'; +export type FilterOptionsType = 'and' | 'or' | 'member' | 'segment' | 'dateRange'; + +export interface FilterOptionsButtonProps { + type: FilterOptionsType; + onAction: (action: FilterOptionsAction) => void; + disableKeys?: FilterOptionsAction[]; +} + +export function FilterOptionsButton({ type, disableKeys, onAction }: FilterOptionsButtonProps) { + const items = useMemo(() => { + const items: { key: string, label: string, color?: string }[] = []; + + if (type === 'or' || type === 'and') { + if (type === 'and') { + items.push({ + key: 'convert', + label: 'Convert to OR Branch', + }); + } + + if (type === 'or') { + items.push({ + key: 'convert', + label: 'Convert to AND Branch', + }); + } + + items.push({ + key: 'unwrap', + label: 'Unwrap Branch', + }); + } + + if (type === 'member' || type === 'or' || type === 'and') { + items.push({ + key: 'wrapWithOr', + label: 'Wrap with OR Branch', + }); + + items.push({ + key: 'wrapWithAnd', + label: 'Wrap with AND Branch', + }); + } + + items.push({ + key: 'remove', + label: 'Remove', + color: '#danger', + }); + + return items.filter((item) => !disableKeys?.includes(item.key as FilterOptionsAction)); + }, [type, disableKeys]); + + return ( + + + onAction(key as FilterOptionsAction)}> + {items.map((item) => ( + + {item.label} + + ))} + + + ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/FilteredLabel.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/FilteredLabel.tsx index f8eec14cad36b..de96a5512a276 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/FilteredLabel.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/FilteredLabel.tsx @@ -17,10 +17,22 @@ export function FilteredLabel({ text, filter }: FilteredLabelProps) { filter = filter.toLowerCase(); - const index = lowerText.indexOf(filter); + let index = lowerText.indexOf(filter); if (index === -1) { - return <>{text}; + filter = filter.replace(/\s/g, '_'); + + index = lowerText.indexOf(filter); + + if (index === -1) { + filter = filter.replace(/_/g, ' '); + + index = lowerText.indexOf(filter); + + if (index === -1) { + return <>{text}; + } + } } const startPart = text.slice(0, index); diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/Folder.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/Folder.tsx new file mode 100644 index 0000000000000..71836d3ce1860 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/Folder.tsx @@ -0,0 +1,83 @@ +import { Button, Text, tasty, FolderOpenFilledIcon, FolderFilledIcon } from '@cube-dev/ui-kit'; +import { ReactElement } from 'react'; + +import { FilteredLabel } from './FilteredLabel'; + +export interface FolderProps { + name: string; + isOpen?: boolean; + onToggle: (isOpen: boolean, name: string) => void; + filterString?: string; + children?: ReactElement[]; +} + +const OpenButton = tasty(Button, { + size: 'small', + type: 'neutral', + styles: { + gridColumns: 'auto auto 1fr', + placeContent: 'center start', + placeItems: 'center start', + color: '#dark', + }, +}); + +const FolderElement = tasty({ + styles: { + display: 'flex', + flow: 'column', + gap: '1bw', + + Contents: { + display: 'flex', + hide: { + '': false, + empty: true, + }, + position: 'relative', + margin: '4x left', + flow: 'column', + gap: '1bw', + padding: '0 0 .5x 0', + }, + + FolderLine: { + position: 'absolute', + inset: '0 auto .5x (1bw - 2x)', + fill: '#border-opaque', + width: '.25x', + radius: true, + }, + + Extra: { + display: 'grid', + }, + }, +}); + +export function Folder(props: FolderProps) { + const { name, isOpen, onToggle, filterString, children } = props; + + return ( + + + ) : ( + + ) + } + onPress={() => onToggle(!isOpen, name)} + > + + {filterString ? : name} + + +
+ {children} +
+
+ + ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/GranularityListMember.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/GranularityListMember.tsx index b9f356ca8d99f..332cdfd2fe8d5 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/GranularityListMember.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/GranularityListMember.tsx @@ -1,9 +1,9 @@ -import { CalendarEditIcon, CalendarIcon, Text, TooltipProvider } from '@cube-dev/ui-kit'; +import { CalendarEditIcon, CalendarIcon, Text } from '@cube-dev/ui-kit'; import { useRef } from 'react'; -import { useHasOverflow } from '../hooks/index'; -import { titleize } from '../utils/index'; +import { MemberViewType } from '../types'; +import { InstanceTooltipProvider } from './InstanceTooltipProvider'; import { ListMemberButton } from './ListMemberButton'; export interface GranularityListMemberProps { @@ -11,46 +11,28 @@ export interface GranularityListMemberProps { title?: string; isCustom?: boolean; isSelected: boolean; + isMissing?: boolean; + memberViewType?: MemberViewType; onToggle: () => void; } export function GranularityListMember(props: GranularityListMemberProps) { - const { name, title, isCustom, isSelected, onToggle } = props; + const { name, title, isCustom, isSelected, isMissing, memberViewType = 'name', onToggle } = props; const textRef = useRef(null); - const hasOverflow = useHasOverflow(textRef); - const isAutoTitle = titleize(name) === title; - - const button = ( - : } - data-member="timeDimension" - isSelected={isSelected} - onPress={onToggle} - > - - {name} - - - ); - - if (hasOverflow || (!isAutoTitle && isCustom)) { - return ( - - {name} -
- {title} - - } - delay={1000} - placement="right" + return ( + + : } + data-member="timeDimension" + isSelected={isSelected} + mods={{ missing: isMissing }} + onPress={onToggle} > - {button} -
- ); - } else { - return button; - } + + {(memberViewType === 'name' ? name : title) ?? name} + + + + ); } diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/HierarchyMember.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/HierarchyMember.tsx new file mode 100644 index 0000000000000..b33664e05e543 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/HierarchyMember.tsx @@ -0,0 +1,106 @@ +import { Text, tasty, HierarchyIcon, Space } from '@cube-dev/ui-kit'; +import { Cube } from '@cubejs-client/core'; +import { ReactElement, useRef } from 'react'; + +import { MemberViewType, TCubeHierarchy } from '../types'; +import { useShownMemberName } from '../hooks'; +import { ChevronIcon } from '../icons/ChevronIcon'; + +import { InstanceTooltipProvider } from './InstanceTooltipProvider'; +import { ListMemberButton } from './ListMemberButton'; +import { FilteredLabel } from './FilteredLabel'; + +export interface FolderProps { + cube: Cube; + isOpen?: boolean; + onToggle: (isOpen: boolean, name: string) => void; + filterString?: string; + member: TCubeHierarchy; + memberViewType?: MemberViewType; + children?: ReactElement[]; + count?: number; +} + +const HierarchyElement = tasty({ + styles: { + display: 'flex', + flow: 'column', + gap: '1bw', + + Contents: { + display: 'flex', + position: 'relative', + margin: '4x left', + flow: 'column', + gap: '1bw', + }, + + HierarchyLine: { + position: 'absolute', + inset: '0 auto 0 (1bw - 2x)', + fill: '#dimension-active', + width: '.25x', + radius: true, + }, + + Extra: { + display: 'grid', + }, + }, +}); + +export function HierarchyMember(props: FolderProps) { + const { isOpen, onToggle, cube, memberViewType, member, filterString, children } = props; + + const textRef = useRef(null); + const name = member.name.replace(`${cube.name}.`, '').trim(); + const title = 'title' in member ? member.title : undefined; + + const { shownMemberName } = useShownMemberName({ + cubeName: cube.name, + cubeTitle: 'title' in cube ? cube.title : undefined, + memberName: name, + memberTitle: title, + type: memberViewType, + }); + + return ( + + + } + data-member="dimension" + onPress={() => onToggle?.(!isOpen, member.name)} + > + + + {filterString ? ( + + ) : ( + shownMemberName + )} + + + + + + {children && children.length ? ( +
+ {children} +
+
+ ) : null} + + ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/InstanceTooltipProvider.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/InstanceTooltipProvider.tsx new file mode 100644 index 0000000000000..8581bd49c2897 --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/InstanceTooltipProvider.tsx @@ -0,0 +1,84 @@ +import { CubeTooltipProviderProps, tasty, TooltipProvider } from '@cube-dev/ui-kit'; +import { RefObject } from 'react'; + +import { useHasOverflow } from '../hooks'; +import { titleize } from '../utils'; + +const TooltipWrapper = tasty({ + styles: { + Name: { + display: 'block', + width: 'max-content', + preset: 't4m', + }, + + Title: { + display: 'block', + width: 'max-content', + preset: 't3', + }, + + Description: { + display: 'block', + preset: 'p3', + }, + + Type: { + preset: 'c2', + opacity: 0.7, + }, + }, +}); + +interface InstanceTooltipProviderProps { + name: string; + fullName?: string; + title?: string; + type?: 'dimension' | 'measure' | 'hierarchy' | 'folder' | 'segment'; + description?: string; + forceShown?: boolean; + children: CubeTooltipProviderProps['children']; + isDisabled?: boolean; + overflowRef?: RefObject; +} + +export function InstanceTooltipProvider(props: InstanceTooltipProviderProps) { + const { + name, + fullName, + type, + title, + description, + children, + isDisabled, + forceShown, + overflowRef, + } = props; + + const hasOverflow = useHasOverflow(overflowRef); + const isAutoTitle = titleize(name) === title; + + if ((!forceShown && (isDisabled || (!hasOverflow && isAutoTitle) || !overflowRef)) || !fullName) { + return children; + } + + return ( + + + {type &&
{type}
} +
{fullName}
+
{title}
+
{description}
+
+ + } + width="max-content" + delay={1000} + placement="right" + > + {children} +
+ ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/ListButton.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/ListButton.tsx index e568d2431dedd..9382da6c5a609 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/ListButton.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/ListButton.tsx @@ -15,7 +15,8 @@ export const ListButton = tasty(Button, { disabled: '#purple', }, placeContent: 'space-between', - gridTemplateColumns: 'auto 1fr auto', + gridTemplateColumns: 'auto minmax(0, 1fr) auto', textAlign: 'left', + padding: '(.75x - 1bw) (0.75x - 1bw) (.75x - 1bw) (1.25x - 1bw)', }, }); diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/ListCube.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/ListCube.tsx index 997d8f92686f7..504e1d72c5e7d 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/ListCube.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/ListCube.tsx @@ -2,7 +2,7 @@ import { Space, tasty, Text, TooltipProvider, ViewIcon, CubeIcon } from '@cube-d import { PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons'; import { CubeStats } from '../types'; -import { ArrowIcon } from '../icons/ArrowIcon'; +import { ChevronIcon } from '../icons/ChevronIcon'; import { NonPublicIcon } from '../icons/NonPublicIcon'; import { ListButton } from './ListButton'; @@ -55,6 +55,7 @@ export function ListCube({ {description ? <> – {description} : undefined} } + width="max-content" placement="right" > diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/ListMember.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/ListMember.tsx index bce1d54ef356a..6186b0ae1ea16 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/ListMember.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/ListMember.tsx @@ -1,45 +1,38 @@ -import { useRef } from 'react'; -import { - Menu, - MenuTrigger, - Space, - tasty, - Text, - CloseIcon, - TooltipProvider, -} from '@cube-dev/ui-kit'; +import { useMemo, useRef } from 'react'; +import { Menu, MenuTrigger, Space, Text, CloseIcon, PlusIcon, CubeIcon } from '@cube-dev/ui-kit'; import { TCubeMeasure, TCubeDimension, TCubeSegment, Cube, MemberType } from '@cubejs-client/core'; -import { PlusOutlined } from '@ant-design/icons'; -import { getTypeIcon, titleize } from '../utils'; +import { getTypeIcon } from '../utils'; import { PrimaryKeyIcon } from '../icons/PrimaryKeyIcon'; import { NonPublicIcon } from '../icons/NonPublicIcon'; import { ItemInfoIcon } from '../icons/ItemInfoIcon'; -import { useHasOverflow } from '../hooks/has-overflow'; +import { MemberViewType } from '../types'; +import { useEvent, useShownMemberName } from '../hooks'; import { ListMemberButton } from './ListMemberButton'; import { FilterByMemberButton } from './FilterByMemberButton'; import { FilteredLabel } from './FilteredLabel'; +import { InstanceTooltipProvider } from './InstanceTooltipProvider'; interface ListMemberProps { - cube: Cube; - member: TCubeMeasure | TCubeDimension | TCubeSegment; + cube: Cube | { name: string }; + member: + | TCubeMeasure + | TCubeDimension + | TCubeSegment + | { name: string; type?: 'string' | 'number' }; + isMissing?: boolean; category: MemberType; filterString?: string; isSelected: boolean; isFiltered?: boolean; + isImported?: boolean; + memberViewType?: MemberViewType; onToggle?: (name: string) => void; onAddFilter?: (name: string) => void; onRemoveFilter?: (name: string) => void; } -const ListMemberWrapper = tasty({ - styles: { - display: 'grid', - position: 'relative', - }, -}); - export function ListMember(props: ListMemberProps) { const textRef = useRef(null); const { @@ -47,100 +40,144 @@ export function ListMember(props: ListMemberProps) { filterString, category, member, + memberViewType, + isMissing, isSelected, isFiltered, + isImported, onAddFilter, onRemoveFilter, onToggle, } = props; - const type = 'type' in member ? member.type : 'string'; + const type = 'type' in member ? member.type : undefined; const name = member.name.replace(`${cube.name}.`, '').trim(); - const title = member.title; + const title = 'shortTitle' in member ? member.shortTitle : undefined; // @ts-ignore const description = member.description; + const { shownMemberName } = useShownMemberName({ + cubeName: cube.name, + cubeTitle: 'title' in cube ? cube.title : undefined, + memberName: name, + memberTitle: title, + type: memberViewType, + }); + + const onFilterPress = useEvent(() => + !isFiltered ? onAddFilter?.(member.name) : onRemoveFilter?.(member.name) + ); - const hasOverflow = useHasOverflow(textRef); - const isAutoTitle = titleize(member.name) === title; + const filterMenu = useMemo(() => { + const dangerProps = isFiltered + ? { + color: '#danger-text', + } + : {}; - const button = ( - + return ( + + + { + switch (key) { + case 'add': + onAddFilter?.(member.name); + break; + case 'remove': + onRemoveFilter?.(member.name); + break; + default: + return; + } + }} + > + }> + Filter by This Member + + }> + Remove All + + + + ); + }, [category, isMissing, member.name, onAddFilter, onRemoveFilter, isFiltered]); + + return ( + onToggle?.(`${cube.name}.${member.name}`)} + mods={{ selected: isSelected, missing: isMissing }} + onPress={() => onToggle?.(member.name)} > - {filterString ? : name} + {filterString ? ( + + ) : ( + shownMemberName + )} - - {description ? : undefined} - {/* @ts-ignore */} - {member.primaryKey ? : undefined} - {/* @ts-ignore */} - {member.public === false ? : undefined} - - {onAddFilter ? ( - isFiltered ? ( - - - { - switch (key) { - case 'add': - onAddFilter?.(member.name); - break; - case 'remove': - onRemoveFilter?.(member.name); - break; - default: - return; - } - }} - > - }> - Add an additional filter with this member - - }> - Remove all filters associated with this member - - - + {description || + isImported || + ('primaryKey' in member && member.primaryKey) || + ('public' in member && member.public === false) ? ( + + {description || isImported ? ( + + {description ? ( + <> + {description} +
+
+ + ) : null} + This member is imported from another cube: +
+ {member.name.split('.')[0]} + + ) : ( + description + ) + } + /> + ) : undefined} + {'primaryKey' in member && member.primaryKey ? ( + + ) : undefined} + {'public' in member && member.public === false ? : undefined} +
+ ) : null} + {onAddFilter || onRemoveFilter ? ( + isFiltered && !isMissing ? ( + filterMenu ) : ( - !isFiltered ? onAddFilter?.(member.name) : onRemoveFilter?.(member.name) - } + type={category.replace(/s$/, '')} + onPress={onFilterPress} /> ) ) : undefined}
-
- ); - - return hasOverflow || !isAutoTitle ? ( - - - {name} - -
- {title} - - } - delay={1000} - placement="right" - > - {button} -
- ) : ( - button + ); } diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/ListMemberButton.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/ListMemberButton.tsx index 5686936a24a23..67657fd89b7b9 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/ListMemberButton.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/ListMemberButton.tsx @@ -4,13 +4,20 @@ import { ListButton } from './ListButton'; export const ListMemberButton = tasty(ListButton, { styles: { + width: 'initial 100% 100%', placeContent: 'center start', - color: '#text', + color: { + '': '#text', + missing: '#danger-text', + }, fill: { '': '#clear', hovered: '#hover', selected: '#active', 'selected & hovered': '#active.8', + missing: '#dark.04', + 'missing & selected': '#danger.2', + 'missing & hovered & selected': '#danger.16', }, '--text-color': { diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/LogicalFilter.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/LogicalFilter.tsx new file mode 100644 index 0000000000000..4ec4c904471fd --- /dev/null +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/LogicalFilter.tsx @@ -0,0 +1,248 @@ +import { Flex, tasty } from '@cube-dev/ui-kit'; +import { + Filter, + LogicalAndFilter, + LogicalOrFilter, + TCubeDimension, + TCubeMeasure, +} from '@cubejs-client/core'; +import { Key } from '@react-types/shared'; + +import { useEvent } from '../hooks'; +import { useQueryBuilderContext } from '../context'; + +import { FilterMember } from './FilterMember'; +import { AddFilterInput } from './AddFilterInput'; +import { FilterOptionsButton } from './FilterOptionsButton'; + +interface LogicalFilterProps { + type: 'and' | 'or'; + values: Filter[]; + isCompact?: boolean; + isAddingCompact?: boolean; + onRemove: () => void; + onUnwrap: () => void; + onChange: (filter: LogicalAndFilter | LogicalOrFilter) => void; +} + +const LogicalOperatorContainer = tasty({ + qa: 'LogicalFilter', + styles: { + display: 'grid', + gridColumns: 'min-content min-content 1fr', + gap: '.5x', + placeItems: 'center start', + radius: true, + fill: { + '': '#clear', + ':has(>[data-qa="FilterOptionsButton"][data-is-hovered])': '#light', + }, + margin: '-.5x', + padding: '.5x', + width: 'max-content', + }, +}); + +const LogicalOperatorButton = tasty({ + styles: { + display: 'grid', + gridRows: '1fr auto 1fr', + placeItems: 'stretch center', + placeSelf: 'stretch', + flow: 'column', + preset: 'c2', + color: '#dark-03', + width: '3.5x', + + '&::before': { + content: '""', + width: '1ow', + fill: '#dark.2', + radius: 'top', + }, + + '&::after': { + content: '""', + width: '1ow', + fill: '#dark.2', + radius: 'bottom', + }, + }, +}); + +export function LogicalFilter(props: LogicalFilterProps) { + const { isCompact, onChange, isAddingCompact, onRemove, onUnwrap, type, values } = props; + const { members, memberViewType, cubes } = useQueryBuilderContext(); + + 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; + } + + const filters = [...values]; + + const changeValues = (filters: Filter[]) => { + onChange({ [type]: filters } as LogicalAndFilter | LogicalOrFilter); + }; + + const removeFilter = (index: number) => { + filters.splice(index, 1); + + changeValues(filters); + }; + + const updateFilter = (index: number, filter: Filter) => { + filters[index] = filter; + changeValues(filters); + }; + + const wrapFilter = useEvent((type: 'and' | 'or') => { + onChange({ [type]: [{ [type]: values }] } as LogicalAndFilter | LogicalOrFilter); + }); + + const unwrapFilter = (index: number) => { + const filter = filters[index]; + if ('and' in filter) { + filters.splice(index, 1, ...filter.and); + } + if ('or' in filter) { + filters.splice(index, 1, ...filter.or); + } + changeValues(filters); + }; + + const convert = () => { + if (type === 'and') { + onChange({ or: filters }); + } else { + onChange({ and: filters }); + } + }; + + const onFilterAction = useEvent((key: Key) => { + switch (key) { + case 'remove': + onRemove(); + break; + case 'unwrap': + onUnwrap(); + break; + case 'convert': + convert(); + break; + case 'wrapWithAnd': + wrapFilter('and'); + break; + case 'wrapWithOr': + wrapFilter('or'); + break; + } + }); + + return ( + + + + {type} + + + {filters.map((filter, index) => { + if ('and' in filter) { + return ( + { + removeFilter(index); + }} + onChange={(filter) => { + updateFilter(index, filter); + }} + onUnwrap={() => { + unwrapFilter(index); + }} + /> + ); + } + + if ('or' in filter) { + return ( + { + removeFilter(index); + }} + onChange={(filter) => { + updateFilter(index, filter); + }} + onUnwrap={() => { + unwrapFilter(index); + }} + /> + ); + } + + if (!('member' in filter) || !filter.member) { + return null; + } + + const member = members.measures[filter.member] || members.dimensions[filter.member]; + const memberFullName = filter.member; + const cubeName = memberFullName.split('.')[0]; + const cube = cubes.find((cube) => cube.name === cubeName); + const memberName = memberFullName.split('.')[1]; + + return ( + { + removeFilter(index); + }} + onChange={(updatedFilter) => { + updateFilter(index, updatedFilter); + }} + /> + ); + })} + + { + changeValues([...filters, filter]); + }} + /> + + + ); +} diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/MemberLabel.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/MemberLabel.tsx index f2aed0ecb440b..36f49a3f66fa0 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/MemberLabel.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/MemberLabel.tsx @@ -1,14 +1,19 @@ +import { ReactNode } from 'react'; + import { getTypeIcon } from '../utils'; +import { MemberViewType } from '../types'; +import { useShownMemberName } from '../hooks'; import { MemberLabelText } from './MemberLabelText'; import { MemberBadge } from './Badge'; function StyledTypeIcon(props: { - member: 'measure' | 'dimension' | 'timeDimension' | 'missing'; - type: 'number' | 'string' | 'time' | 'boolean' | 'filter'; + isMissing?: boolean; + memberType: 'measure' | 'dimension' | 'timeDimension' | 'segment'; + type?: 'number' | 'string' | 'time' | 'boolean' | 'filter'; }) { - const { type, member } = props; - const memberColorName = member === 'missing' ? 'danger' : member; + const { type, memberType, isMissing } = props; + const memberColorName = isMissing ? 'danger' : memberType; return ( - {getTypeIcon(type || 'number')} + {getTypeIcon(type)} ); } interface MemberLabelProps { name: string; - member?: 'measure' | 'dimension' | 'timeDimension'; + memberName?: string; + cubeName?: string; + memberTitle?: string; + cubeTitle?: string; + memberViewType?: MemberViewType; + isCompact?: boolean; + memberType?: 'measure' | 'dimension' | 'timeDimension' | 'segment'; type?: 'number' | 'string' | 'time' | 'boolean' | 'filter'; + isMissing?: boolean; + children?: ReactNode; } export function MemberLabel(props: MemberLabelProps) { - const { name, member, type } = props; - + const { + name, + cubeName = props.name.split('.')[0], + cubeTitle, + memberName = props.name.split('.')[1], + memberTitle, + isCompact, + memberType, + type, + memberViewType, + isMissing, + children, + } = props; const arr = name.split('.'); + const { shownMemberName, shownCubeName } = useShownMemberName({ + cubeName, + cubeTitle, + memberName, + memberTitle, + type: memberViewType, + }); + return ( - - {type && member ? : null} - {arr.length > 1 ? ( + + {memberType ? ( + + ) : null} + {!isCompact || !cubeName ? ( <> - {arr[0]} - . - {arr[1]} + {shownCubeName} + {memberViewType === 'name' ? '.' : <> } + {shownMemberName} {arr[2] ? ( - + {arr[2]} ) : null} ) : ( - {name} + {shownMemberName} )} + {children} ); } diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/MemberLabelText.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/MemberLabelText.tsx index be268e1572797..3a70aa7b9a8be 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/MemberLabelText.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/MemberLabelText.tsx @@ -1,19 +1,24 @@ import { tasty } from '@cube-dev/ui-kit'; export const MemberLabelText = tasty({ + qa: 'MemberLabel', + 'aria-label': 'Member label', styles: { display: 'grid', flow: 'column', - gap: '.5x', - preset: 't3m', + gap: '.75x', + preset: { + '': 't3m', + '[data-size="small"]': 't4', + }, color: { '': '#dark', - '[data-member="missing"]': '#danger-text', '[data-member="measure"]': '#measure-text', '[data-member="dimension"]': '#dimension-text', '[data-member="timeDimension"]': '#time-dimension-text', '[data-member="segment"]': '#segment-text', '[data-member="filter"]': '#filter-text', + '[data-member="missing"] | missing': '#danger-text', }, whiteSpace: 'nowrap', placeItems: 'center start', @@ -30,9 +35,11 @@ export const MemberLabelText = tasty({ color: '#dark', }, - MemberName: { + MemberPath: { overflow: 'hidden', textOverflow: 'ellipsis', + // Fixes issue with `overflow: elipsis` not applying correctly + maxWidth: '100%', }, Divider: { diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/Panel.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/Panel.tsx deleted file mode 100644 index 0ea24542c81bd..0000000000000 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/Panel.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { - BASE_STYLES, - BaseProps, - BaseStyleProps, - BLOCK_STYLES, - BlockStyleProps, - COLOR_STYLES, - ColorStyleProps, - OUTER_STYLES, - OuterStyleProps, - Styles, - tasty, -} from '@cube-dev/ui-kit'; -import { ForwardedRef, forwardRef, ReactNode, useMemo } from 'react'; - -const PanelElement = tasty({ - as: 'section', - qa: 'Panel', - styles: { - position: { - '': 'relative', - 'stretched | floating': 'absolute', - }, - inset: { - '': 'initial', - stretched: true, - }, - display: 'block', - overflow: 'hidden', - radius: { - '': '0', - card: '1r', - }, - border: { - '': '0', - card: '1bw', - }, - flexGrow: 1, - }, -}); - -const PanelInnerElement = tasty({ - styles: { - position: 'absolute', - display: 'grid', - top: 0, - left: 0, - right: 0, - bottom: 0, - overflow: 'auto', - styledScrollbar: true, - gridColumns: 'minmax(100%, 100%)', - gridRows: { - '': 'initial', - stretched: 'minmax(0, 1fr)', - }, - radius: { - '': '0', - card: '(1r - 1bw)', - }, - flow: 'row', - placeContent: 'start stretch', - }, - styleProps: [...OUTER_STYLES, ...BASE_STYLES, ...COLOR_STYLES], -}); - -interface CubePanelProps - extends OuterStyleProps, - BlockStyleProps, - BaseStyleProps, - ColorStyleProps, - BaseProps { - isStretched?: boolean; - isCard?: boolean; - isFloating?: boolean; - styles?: Styles; - innerStyles?: Styles; - placeContent?: Styles['placeContent']; - placeItems?: Styles['placeItems']; - gridColumns?: Styles['gridTemplateColumns']; - gridRows?: Styles['gridTemplateRows']; - flow?: Styles['flow']; - gap?: Styles['gap']; - children?: ReactNode; -} - -const STYLES = [ - 'placeContent', - 'placeItems', - 'gridColumns', - 'gridRows', - 'flow', - 'gap', - 'padding', - 'overflow', - 'fill', - 'color', - 'preset', -] as const; - -function Panel(props: CubePanelProps, ref: ForwardedRef) { - let { qa, mods, isStretched, isFloating, isCard, styles, innerStyles, children } = props; - - STYLES.forEach((style) => { - if (props[style]) { - innerStyles = { ...innerStyles, [style]: props[style] }; - } - }); - - [...OUTER_STYLES, ...BASE_STYLES, ...BLOCK_STYLES, ...COLOR_STYLES].forEach((style) => { - if (props[style]) { - styles = { ...styles, [style]: props[style] }; - } - }); - - const appliedMods = useMemo( - () => ({ - floating: isFloating, - stretched: isStretched, - card: isCard, - ...mods, - }), - [isStretched, isCard, mods] - ); - - return ( - - - {children} - - - ); -} - -const _Panel = forwardRef(Panel); - -export { _Panel as Panel }; diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/QueryVisualization.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/QueryVisualization.tsx deleted file mode 100644 index 12c4acc7099f0..0000000000000 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/QueryVisualization.tsx +++ /dev/null @@ -1,252 +0,0 @@ -import { ReactNode, useMemo } from 'react'; -import { Block, Button, Flex, Space, Title, CloseIcon, TooltipProvider } from '@cube-dev/ui-kit'; -import { - CaretDownFilled, - LoadingOutlined, - PlusOutlined, - UnorderedListOutlined, -} from '@ant-design/icons'; - -import { capitalize } from '../utils/capitalize'; -import { useQueryBuilderContext } from '../context'; -import { CubeStats } from '../types'; - -import { MemberLabelText } from './MemberLabelText'; -import { ListCube } from './ListCube'; -import { MemberBadge } from './Badge'; - -const TYPES: readonly ('measure' | 'segment' | 'dimension' | 'filter' | 'timeDimension')[] = [ - 'measure', - 'dimension', - 'filter', - 'segment', - 'timeDimension', -]; - -type AllComponentTypes = typeof TYPES; -type ComponentType = AllComponentTypes[number]; - -const TYPE_PROPS_MAP: Record> = { - measure: 'measures', - dimension: 'dimensions', - filter: 'filters', - segment: 'segments', - timeDimension: 'timeDimensions', -}; - -interface QueryVisualizationProps { - onReset?: () => void; - type: 'views' | 'cubes'; - actions?: ReactNode; -} - -export function QueryVisualization({ - onReset, - type: selectedType, - actions, -}: QueryVisualizationProps) { - const { - queryHash, - isVerifying, - clearQuery, - selectedCube, - selectCube, - isQueryEmpty, - members, - cubes, - connectionId, - isCubeUsed, - joinableCubes, - queryStats, - } = useQueryBuilderContext(); - - const connectedCubes = joinableCubes.filter((cube) => !isCubeUsed(cube.name)); - - return useMemo(() => { - if (isQueryEmpty && !selectedCube) { - return null; - } - - const isUnconnectable = - selectedCube && - // @ts-ignore - selectedCube?.type !== 'view' && - (isQueryEmpty || - (connectedCubes?.length === 1 && - // @ts-ignore - selectedCube?.connectedComponent === connectionId && - !isCubeUsed(selectedCube?.name)) || - !connectedCubes?.length); - - return !isQueryEmpty || selectedCube ? ( - - - - {actions} - {/* @ts-ignore */} - {selectedCube && selectedCube?.type !== 'view' ? ( - - - - ) : ( - - Query - - )} - {isVerifying && } - - - - - - - - - {Object.entries(queryStats).map(([cubeName, cubeStats]) => { - return ( - - selectCube(cubeName)} - /> - {!selectedCube && ( - - - {TYPES.map((type) => { - const section = TYPE_PROPS_MAP[type]; - const count = cubeStats[section]?.length || 0; - - if (!count) { - return null; - } - - return cubeStats[section].map((memberName, i) => { - const member = - members.measures[memberName] || - members.dimensions[memberName] || - members.segments[memberName]; - - return ( - - Member not found: {memberName} - - ) : ( - <> - {capitalize(type)}: {memberName} -
- {/* @ts-ignore */} - {member?.description} - - ) - } - > - - - - - {memberName.split('.')[1]} - - - - -
- ); - }); - })} -
-
- )} -
- ); - })} - {selectedCube && !isCubeUsed(selectedCube.name) ? ( - - ) : null} - {selectedCube && ( - - ) : null} ); } else if (filterString) { return null; - } else if (isOpen || mode === 'query') { + } else if (isOpen) { return ( No members{mode === 'query' ? ' selected' : ''} ); @@ -424,29 +805,26 @@ export function SidePanelCubeItem({ } })(); + const isLocked = isOpen && type === 'view' && !isQueryEmpty; const isCollapsable = isNonJoinable || !!filterString; const cubeButton = ( - ) : type === 'cube' ? ( - + type === 'cube' ? ( + ) : ( - + ) } rightIcon={ - mode === 'all' && !isNonJoinable ? ( + mode === 'all' && !isNonJoinable && !isLocked ? ( - - ) : isNonJoinable ? ( - ) : undefined } mods={{ @@ -457,38 +835,24 @@ export function SidePanelCubeItem({ }} flow="column" placeContent="space-between" - onPress={() => !isMissing && !isNonJoinable && onToggle?.(!isOpen)} + onPress={() => !isMissing && !isNonJoinable && !isLocked && onToggle?.(!isOpen)} > - {filterString ? : name} + {filterString ? : shownName} - {description ? : undefined} - {isPrivate ? : undefined} + + {description ? : undefined} + {isPrivate ? : undefined} + ); return ( - {hasOverflow || !isAutoTitle ? ( - - - {name} - -
- {title} - - } - placement="right" - > - {cubeButton} -
- ) : ( - cubeButton - )} + + {cubeButton} +
{memberList}
diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/TimeListMember.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/TimeListMember.tsx index 1acb869174e66..3dbebf571c98c 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/TimeListMember.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/TimeListMember.tsx @@ -1,71 +1,128 @@ -import { useMemo, useRef, useState } from 'react'; -import { Flex, Space, Text, TimeIcon, TooltipProvider } from '@cube-dev/ui-kit'; +import { useMemo, useRef } from 'react'; +import { + CloseIcon, + Flex, + Menu, + MenuTrigger, + PlusIcon, + Space, + tasty, + Text, + TimeIcon, +} from '@cube-dev/ui-kit'; import { Cube, TCubeDimension, TimeDimensionGranularity } from '@cubejs-client/core'; -import { ArrowIcon } from '../icons/ArrowIcon'; +import { ChevronIcon } from '../icons/ChevronIcon'; import { NonPublicIcon } from '../icons/NonPublicIcon'; import { ItemInfoIcon } from '../icons/ItemInfoIcon'; -import { useHasOverflow } from '../hooks/has-overflow'; import { titleize } from '../utils/index'; +import { PREDEFINED_GRANULARITIES } from '../values'; +import { useEvent, useShownMemberName } from '../hooks'; +import { MemberViewType } from '../types'; import { GranularityListMember } from './GranularityListMember'; import { ListMemberButton } from './ListMemberButton'; import { FilterByMemberButton } from './FilterByMemberButton'; import { FilteredLabel } from './FilteredLabel'; +import { InstanceTooltipProvider } from './InstanceTooltipProvider'; -interface ListMemberProps { - cube: Cube; - member: TCubeDimension; +const GranularitiesWrapper = tasty(Flex, { + styles: { + position: 'relative', + flow: 'column', + gap: '1bw', + margin: '4x left', + + TimeListLine: { + position: 'absolute', + inset: '0 auto 0 (1bw - 2x)', + fill: '#dimension-active', + width: '.25x', + radius: true, + }, + }, +}); + +interface TimeListMemberProps { + cube: Cube | { name: string }; + isOpen?: boolean; + member: TCubeDimension | { name: string; type: 'time' }; filterString?: string; isCompact?: boolean; isSelected: (granularity?: TimeDimensionGranularity) => boolean; isFiltered: boolean; + isDateRangeFiltered: boolean; + isMissing?: boolean; + selectedGranularities?: string[]; + memberViewType?: MemberViewType; onDimensionToggle: (component: string) => void; onGranularityToggle: (name: string, granularity: TimeDimensionGranularity) => void; - onToggleDataRange?: (name: string) => void; + onAddDataRange?: (name: string) => void; + onRemoveDataRange?: (name: string) => void; + onToggle: (isOpen: boolean, name: string) => void; + onAddFilter?: (name: string) => void; + onRemoveFilter?: (name: string) => void; } -const PREDEFINED_GRANULARITIES: TimeDimensionGranularity[] = [ - 'second', - 'minute', - 'hour', - 'day', - 'week', - 'month', - 'quarter', - 'year', -]; - -export function TimeListMember(props: ListMemberProps) { +export function TimeListMember(props: TimeListMemberProps) { const textRef = useRef(null); - let [open, setOpen] = useState(false); - const { + isOpen, cube, member, filterString, - isCompact, isSelected, isFiltered, + isDateRangeFiltered, + isMissing, + selectedGranularities = [], + memberViewType, onDimensionToggle, onGranularityToggle, - onToggleDataRange, + onAddDataRange, + onRemoveDataRange, + onAddFilter, + onRemoveFilter, + onToggle, } = props; - // const title = member.title.replace(cube.title, '').trim(); const name = member.name.replace(`${cube.name}.`, '').trim(); - const title = member.title; + const title = 'shortTitle' in member ? member.shortTitle : undefined; // @ts-ignore const description = member.description; const isTimestampSelected = isSelected(); + const definedGranularities = + (member.type === 'time' ? ('granularities' in member ? member?.granularities : []) : []) ?? []; + const definedGranularityNames = definedGranularities.map((g) => g.name); + const nonPredefinedGranularityNames = [...definedGranularityNames]; + const { shownMemberName } = useShownMemberName({ + cubeName: cube.name, + cubeTitle: 'title' in cube ? cube.title : undefined, + memberName: name, + memberTitle: title, + type: memberViewType, + }); - const customGranularities = - member.type === 'time' && member.granularities ? member.granularities.map((g) => g.name) : []; - const customGranularitiesTitleMap = useMemo(() => { + selectedGranularities.forEach((granularity) => { + if ( + !nonPredefinedGranularityNames.includes(granularity) && + !PREDEFINED_GRANULARITIES.includes(granularity) + ) { + nonPredefinedGranularityNames.push(granularity); + } + }); + + const missingGranularities = selectedGranularities.filter( + (granularity) => + !definedGranularityNames.includes(granularity) && + !PREDEFINED_GRANULARITIES.includes(granularity) + ); + + const definedGranularitiesTitleMap = useMemo(() => { return ( member.type === 'time' && - member.granularities?.reduce( + definedGranularities?.reduce( (map, granularity) => { map[granularity.name] = granularity.title; @@ -74,69 +131,25 @@ export function TimeListMember(props: ListMemberProps) { {} as Record ) ); - }, [member.type === 'time' ? member.granularities : null]); - const memberGranularities = customGranularities.concat(PREDEFINED_GRANULARITIES); + }, [member.type === 'time' ? definedGranularities : null]); + + const allGranularityNames = nonPredefinedGranularityNames.concat(PREDEFINED_GRANULARITIES); const isGranularitySelectedMap: Record = {}; - memberGranularities.forEach((granularity) => { + + allGranularityNames.forEach((granularity) => { isGranularitySelectedMap[granularity] = isSelected(granularity); }); - const selectedGranularity = memberGranularities.find((granularity) => isSelected(granularity)); - - open = isCompact ? false : open; - - const hasOverflow = useHasOverflow(textRef); - const isAutoTitle = titleize(member.name) === title; - const button = ( - - ) : ( - - ) - } - data-member="dimension" - isSelected={isTimestampSelected && (isCompact || !open)} - onPress={() => { - if (!isCompact) { - setOpen(!open); - } else { - if (isTimestampSelected) { - onDimensionToggle(member.name); - } else if (selectedGranularity) { - onGranularityToggle(member.name, selectedGranularity); - } - } - }} - > - - {filterString ? : name} - - - - - {description ? : undefined} - {/* @ts-ignore */} - {member.public === false ? : undefined} - - onToggleDataRange?.(member.name)} - /> - - - ); + const selectedGranularity = allGranularityNames.find((granularity) => isSelected(granularity)); const granularityItems = (items: string[], isCustom?: boolean) => { return items.map((granularity: string) => { - if ((!open || isCompact) && !isGranularitySelectedMap[granularity]) { + if (!isOpen && !isGranularitySelectedMap[granularity]) { return null; } - const title = customGranularitiesTitleMap - ? customGranularitiesTitleMap[granularity] + const title = definedGranularitiesTitleMap + ? definedGranularitiesTitleMap[granularity] : titleize(granularity); return ( @@ -144,52 +157,148 @@ export function TimeListMember(props: ListMemberProps) { key={`${name}.${granularity}`} name={granularity} title={title} + memberViewType={memberViewType} + isMissing={missingGranularities.includes(granularity)} isCustom={isCustom} isSelected={isGranularitySelectedMap[granularity]} onToggle={() => { onGranularityToggle(member.name, granularity); - setOpen(false); }} /> ); }); }; + const onPress = useEvent(() => { + onToggle(!isOpen, member.name); + }); + + const filterMenu = useMemo(() => { + const dangerProps = isFiltered + ? { + color: '#danger-text', + } + : {}; + const disabledMenuKeys: string[] = []; + + if (!isFiltered) { + disabledMenuKeys.push('remove'); + } + + if (isDateRangeFiltered) { + disabledMenuKeys.push('add-date-range'); + } + + return ( + + + { + switch (key) { + case 'add-date-range': + onAddDataRange?.(member.name); + break; + case 'add-filter': + onAddFilter?.(member.name); + break; + case 'remove': + onRemoveFilter?.(member.name); + onRemoveDataRange?.(member.name); + break; + default: + return; + } + }} + > + }> + Filter by Date Range + + }> + Filter by This Member + + }> + Remove all + + + + ); + }, [ + member?.name, + isDateRangeFiltered, + isFiltered, + isMissing, + onAddDataRange, + onRemoveDataRange, + onAddFilter, + onRemoveFilter, + ]); + return ( <> - {hasOverflow || !isAutoTitle ? ( - - {name} - - } - delay={1000} - placement="right" + + } + data-member="dimension" + isSelected={isTimestampSelected && !isOpen} + mods={{ missing: isMissing }} + gridColumns="auto minmax(0, 1fr) auto" + onPress={onPress} > - {button} - - ) : ( - button - )} - {open || isCompact || selectedGranularity ? ( - - {open && !isCompact ? ( + + + {filterString ? ( + + ) : ( + shownMemberName + )} + + + + + + {('public' in member && member.public === false) || description ? ( + + {description ? : undefined} + {'public' in member && member.public === false ? : undefined} + + ) : null} + {member && filterMenu} + + + + {isOpen || selectedGranularity ? ( + + {isOpen ? ( } data-member="dimension" isSelected={isTimestampSelected} onPress={() => { onDimensionToggle(member.name); - setOpen(false); }} > value ) : null} - {granularityItems(customGranularities, true)} + {granularityItems(nonPredefinedGranularityNames, true)} {granularityItems(PREDEFINED_GRANULARITIES)} - +
+ ) : null} ); diff --git a/packages/cubejs-playground/src/QueryBuilderV2/components/ValuesInput.tsx b/packages/cubejs-playground/src/QueryBuilderV2/components/ValuesInput.tsx index 26dd82eba144c..247577ea4badc 100644 --- a/packages/cubejs-playground/src/QueryBuilderV2/components/ValuesInput.tsx +++ b/packages/cubejs-playground/src/QueryBuilderV2/components/ValuesInput.tsx @@ -1,5 +1,9 @@ import { Button, + CaretDownIcon, + ComboBox, + Grid, + InfoCircleIcon, NumberInput, Space, Tag, @@ -7,10 +11,12 @@ import { TextInput, TooltipProvider, } from '@cube-dev/ui-kit'; +import { Key } from '@react-types/shared'; import React, { KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'; import { PlusOutlined } from '@ant-design/icons'; -import { useOutsideFocus } from '../hooks'; +import { useQueryBuilderContext } from '../context'; +import { useEvent, useOutsideFocus, useDimensionValues } from '../hooks'; const ButtonWrapper = tasty({ styles: { @@ -27,9 +33,12 @@ const AddButton = tasty(Button, { size: 'small', icon: , styles: { - radius: '(1r - 1bw) right', width: '(4x - 2bw)', height: '(4x - 2bw)', + radius: { + '': true, + inside: 'right', + }, }, }); @@ -56,55 +65,90 @@ const StyledTag = tasty(Tag, { interface ValuesInputProps { type?: 'string' | 'number'; + memberName?: string; + memberType?: 'measure' | 'dimension'; + placeholder?: string; isCompact?: boolean; + allowSuggestions?: boolean; values: string[]; onChange: (values: string[]) => void; } export function ValuesInput(props: ValuesInputProps) { - const { type = 'string', values, isCompact, onChange } = props; + const { + type = 'string', + memberName, + memberType, + allowSuggestions, + placeholder, + values, + isCompact, + onChange, + } = props; - const [open, setOpen] = useState(!values.length); - const [error, setError] = useState(!values.length); + const [isOpen, setIsOpen] = useState(!values.length); + const [hasError, setHasError] = useState(false); const [textValue, setTextValue] = useState(''); + const [showSuggestions, setShowSuggestions] = useState(false); const ref = useRef(); - const inputRef = useRef(null); + const inputRef = useRef(null); + + const { cubeApi, mutexObj } = useQueryBuilderContext(); - // If focus goes outside the widget, then clear the value and hid the input + const { + suggestions, + isLoading: isSuggestionLoading, + error: suggestionError, + } = useDimensionValues({ + cubeApi, + mutexObj, + dimension: memberName, + skip: + !isOpen || !allowSuggestions || !showSuggestions || !memberName || memberType !== 'dimension', + }); + + // If focus goes outside the widget, update the state useOutsideFocus( ref, - useCallback(() => { - setTextValue(''); - if (values.length) { - setOpen(false); - } else { - setError(true); + useEvent(() => { + if (textValue && textValue.trim()) { + addValueLazy(); } - }, [values.length, ref?.current]) + }) ); const onAddButtonPress = () => { - if (open) { + if (isOpen) { addValue(); } else { - setOpen(true); + setIsOpen(true); } }; const onFocus = () => { - setError(false); + setHasError(false); }; + function focusOnInput() { + setTimeout(() => { + ref.current?.querySelector('input')?.focus(); + }, 100); + } + // If input is shown, then focus on it useEffect(() => { - if (open && ref.current) { - ref.current?.querySelector('input')?.focus(); + if (isOpen && ref.current) { + focusOnInput(); } - }, [open]); + }, [isOpen]); + + useEffect(() => { + focusOnInput(); + }, [suggestions]); // Add current value to the value list and clear the input value - const addValue = () => { + const addValue = useEvent(() => { const value = textValue.trim(); if (!value) { @@ -113,11 +157,23 @@ export function ValuesInput(props: ValuesInputProps) { onChange([...values.filter((val) => val !== value), value]); setTextValue(''); + setIsOpen(false); + }); + + const addValueLazy = () => { + setTimeout(() => { + addValue(); + }); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter') { - addValue(); + e.preventDefault(); + addValueLazy(); + } + if (e.key === 'Escape') { + setTextValue(''); + setIsOpen(false); } }; @@ -126,8 +182,8 @@ export function ValuesInput(props: ValuesInputProps) { const newValues = values.filter((val) => val !== value); if (!newValues.length) { - setError(true); - setOpen(true); + setHasError(true); + setIsOpen(true); } onChange(newValues); @@ -135,20 +191,9 @@ export function ValuesInput(props: ValuesInputProps) { [values.length] ); - function onTextChange(value: string | number) { - if (!Number.isNaN(value)) { - setTextValue(typeof value === 'number' ? String(value) : value); - } - } - - function onBlur() { - if (inputRef?.current) { - if (!inputRef.current.value || !inputRef.current.value.trim()) { - inputRef.current.value = textValue; - setOpen(false); - } - } - } + const onTextChange = useEvent((value: string | number) => { + setTextValue(typeof value === 'number' ? (!Number.isNaN(value) ? String(value) : '') : value); + }); function onInput() { if (inputRef?.current) { @@ -164,35 +209,105 @@ export function ValuesInput(props: ValuesInputProps) { } } - const addButton = ; + const addButton = ( + + ); const input = type === 'string' ? ( - + memberType === 'dimension' && allowSuggestions && showSuggestions && suggestions.length ? ( + + + + ) : null + } + suffixPosition="after" + width="30x" + menuTrigger="focus" + isLoading={isSuggestionLoading && !suggestions.length} + onSelectionChange={(key: Key | null) => { + key && onTextChange(key as string); + addValueLazy(); + }} + onInputChange={(key: Key | null) => { + onTextChange(key as string); + }} + onKeyDown={onKeyDown} + onFocus={onFocus} + > + {suggestions.map((suggestion) => ( + + {suggestion} + + ))} + + ) : ( + +