diff --git a/.gitignore b/.gitignore index fa518f0074193..d46c4ec44073e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ yarn-error.log **/coverage lerna-debug.log tsconfig.tsbuildinfo +**/*.swo +**/*.swp diff --git a/packages/cubejs-client-core/index.d.ts b/packages/cubejs-client-core/index.d.ts index ce59daf1e2ad3..35bb133ab713b 100644 --- a/packages/cubejs-client-core/index.d.ts +++ b/packages/cubejs-client-core/index.d.ts @@ -641,6 +641,7 @@ declare module '@cubejs-client/core' { */ tableColumns(pivotConfig?: PivotConfig): TableColumn[]; + totalRow(): ChartPivotRow; query(): Query; rawData(): T[]; annotation(): QueryAnnotations; diff --git a/packages/cubejs-client-react/index.d.ts b/packages/cubejs-client-react/index.d.ts index d9df0d3f6d8d6..e7c97e01ab899 100644 --- a/packages/cubejs-client-react/index.d.ts +++ b/packages/cubejs-client-react/index.d.ts @@ -5,16 +5,18 @@ import { ResultSet, Filter, PivotConfig, - MemberType, TCubeMeasure, TCubeDimension, - TCubeMember, + TCubeSegment, + TimeDimension, ProgressResponse, TDryRunResponse, TOrderMember, QueryOrder, - TQueryOrderArray, TSourceAxis, + TimeDimensionComparison, + TimeDimensionRanged, + Meta, } from '@cubejs-client/core'; /** @@ -141,9 +143,8 @@ declare module '@cubejs-client/react' { type ChartType = 'line' | 'bar' | 'table' | 'area' | 'number' | 'pie'; type VizState = { - [key: string]: any; + query?: Query; pivotConfig?: PivotConfig; - shouldApplyHeuristicOrder?: boolean; chartType?: ChartType; }; @@ -155,12 +156,12 @@ declare module '@cubejs-client/react' { /** * Default query */ - query?: Query; - vizState?: VizState; + defaultQuery?: Query; + defaultChartType?: ChartType; + initialVizState?: VizState; /** * @default defaultChartType line */ - defaultChartType?: ChartType; /** * Defaults to `false`. This means that the default heuristics will be applied. For example: when the query is empty and you select a measure that has a default time dimension it will be pushed to the query. * @default disableHeuristics false @@ -173,10 +174,9 @@ declare module '@cubejs-client/react' { */ stateChangeHeuristics?: (state: QueryBuilderState) => QueryBuilderState; /** - * Called by the `QueryBuilder` when the query state has changed. Use it when state is maintained outside of the `QueryBuilder` component. + * Called by the `QueryBuilder` when the viz state has changed. Use it to save state outside of the `QueryBuilder` component. */ - setQuery?: (query: Query) => void; - setVizState?: (vizState: VizState) => void; + onVizStateChanged?: (vizState: VizState) => void; }; type QueryBuilderState = VizState & { @@ -188,14 +188,19 @@ declare module '@cubejs-client/react' { resultSet?: ResultSet | null; error?: Error | null; loadingState?: TLoadingState; + + meta: Meta; + metaError?: Error | null; + isFetchingMeta: boolean; + /** * Indicates whether the query is ready to be displayed or not */ isQueryPresent: boolean; - measures: string[]; - dimensions: string[]; - segments: string[]; - timeDimensions: Filter[]; + measures: (TCubeMeasure & { index: number })[]; + dimensions: (TCubeDimension & { index: number })[]; + segments: (TCubeSegment & { index: number })[]; + timeDimensions: (TimeDimensionWithExtraFields & { index: number })[]; /** * An array of available measures to select. They are loaded via the API from Cube.js Backend. @@ -212,18 +217,18 @@ declare module '@cubejs-client/react' { /** * An array of available segments to select. They are loaded via the API from Cube.js Backend. */ - availableSegments: TCubeMember[]; + availableSegments: TCubeSegment[]; - updateMeasures: MemberUpdater; - updateDimensions: MemberUpdater; - updateSegments: MemberUpdater; - updateTimeDimensions: MemberUpdater; - updateFilters: MemberUpdater; + updateMeasures: MeasureUpdater; + updateDimensions: DimensionUpdater; + updateSegments: SegmentUpdater; + updateTimeDimensions: TimeDimensionUpdater; + updateFilters: FilterUpdater; /** * Used for partial of full query update */ updateQuery: (query: Query) => void; - filters: Filter[]; + filters: (FilterWithExtraFields & { index: number })[]; /** * All possible order members for the query */ @@ -235,22 +240,24 @@ declare module '@cubejs-client/react' { /** * See [Pivot Config](@cubejs-client-core#types-pivot-config) */ - pivotConfig: PivotConfig; + pivotConfig?: PivotConfig; /** * Helper method for `pivotConfig` updates */ updatePivotConfig: PivotConfigUpdater; - + /** * Selected chart type */ - chartType: ChartType; - + chartType?: ChartType; + /** * Used for chart type update */ updateChartType: (chartType: ChartType) => void; + validatedQuery: Query; + refresh: () => void; }; /** @@ -477,25 +484,44 @@ declare module '@cubejs-client/react' { * /> * ``` */ - type MemberUpdater = { - add: (member: MemberType) => void; - remove: (member: MemberType) => void; - update: (member: MemberType, updateWith: MemberType) => void; + + type FilterWithExtraFields = Omit & { + dimension: TCubeDimension | TCubeMeasure; + operators: { name: string; title: string }[]; + }; + type TimeDimensionWithExtraFields = Omit & { + dimension: TCubeDimension & { granularities: { name: string; title: string }[] }; + }; + + type MemberUpdater = { + add: (member: T) => void; + remove: (member: { index: number }) => void; + update: (member: { index: number }, updateWith: T) => void; }; + type DimensionUpdater = MemberUpdater; + type MeasureUpdater = MemberUpdater; + type SegmentUpdater = MemberUpdater; + // Only require the fields that are actually used (otherwise fields like `operators` are required just to add/update) + type TimeDimensionUpdater = MemberUpdater< + (Pick | Pick) & { dimension: TCubeDimension } + >; + type FilterUpdater = MemberUpdater< + Pick & { dimension: TCubeDimension | TCubeMeasure } + >; type OrderUpdater = { set: (memberId: string, order: QueryOrder | 'none') => void; - update: (order: TQueryOrderArray) => void; + update: (order: Query['order']) => void; reorder: (sourceIndex: number, destinationIndex: number) => void; }; type PivotConfigUpdater = { - moveItem: ( - sourceIndex: number, - destinationIndex: number, - sourceAxis: TSourceAxis, - destinationAxis: TSourceAxis - ) => void; - update: (pivotConfig: PivotConfig & { limit: number }) => void; + moveItem: (args: { + sourceIndex: number; + destinationIndex: number; + sourceAxis: TSourceAxis; + destinationAxis: TSourceAxis; + }) => void; + update: (pivotConfig: PivotConfig & { limit?: number }) => void; }; } diff --git a/packages/cubejs-client-react/package.json b/packages/cubejs-client-react/package.json index 5e00c558cde95..fba3358be67b6 100644 --- a/packages/cubejs-client-react/package.json +++ b/packages/cubejs-client-react/package.json @@ -12,6 +12,7 @@ "@babel/runtime": "^7.1.2", "@cubejs-client/core": "^0.26.19", "core-js": "^3.6.5", + "prop-types": "^15.7.2", "ramda": "^0.27.0" }, "peerDependencies": { diff --git a/packages/cubejs-client-react/src/QueryBuilder.jsx b/packages/cubejs-client-react/src/QueryBuilder.jsx index e96a71775c6fc..eeb74a34b6a57 100644 --- a/packages/cubejs-client-react/src/QueryBuilder.jsx +++ b/packages/cubejs-client-react/src/QueryBuilder.jsx @@ -1,6 +1,7 @@ import React from 'react'; -import { prop, uniqBy, indexBy, fromPairs } from 'ramda'; +import { prop, uniqBy, pick } from 'ramda'; import { ResultSet, moveItemInArray, defaultOrder, flattenFilters, getQueryMembers } from '@cubejs-client/core'; +import PropTypes from 'prop-types'; import QueryRenderer from './QueryRenderer.jsx'; import CubeContext from './CubeContext'; @@ -16,25 +17,6 @@ const granularities = [ ]; export default class QueryBuilder extends React.Component { - static getDerivedStateFromProps(props, state) { - const nextState = { - ...state, - ...(props.vizState || {}), - }; - - if (Array.isArray(props.query)) { - throw new Error('Array of queries is not supported.'); - } - - return { - ...nextState, - query: { - ...nextState.query, - ...(props.query || {}), - }, - }; - } - static resolveMember(type, { meta, query }) { if (!meta) { return []; @@ -64,56 +46,25 @@ export default class QueryBuilder extends React.Component { })); } - static getOrderMembers(state) { - const { query, meta } = state; - - if (!meta) { - return []; - } - - const toOrderMember = (member) => ({ - id: member.name, - title: member.title, - }); - - return uniqBy( - prop('id'), - [ - ...QueryBuilder.resolveMember('measures', state).map(toOrderMember), - ...QueryBuilder.resolveMember('dimensions', state).map(toOrderMember), - ...QueryBuilder.resolveMember('timeDimensions', state).map((td) => toOrderMember(td.dimension)), - ].map((member) => ({ - ...member, - order: query.order?.[member.id] || 'none', - })) - ); - } - constructor(props) { super(props); this.state = { - query: props.query, - chartType: 'line', - orderMembers: [], - pivotConfig: null, - validatedQuery: props.query, + chartType: props.defaultChartType, + query: props.defaultQuery, + ...props.initialVizState, missingMembers: [], isFetchingMeta: false, - ...props.vizState, }; this.mutexObj = {}; } - async componentDidMount() { - await this.fetchMeta(); + componentDidMount() { + this.fetchMeta(); } fetchMeta = async () => { - const { query, pivotConfig } = this.state; - let dryRunResponse; - let missingMembers = []; let meta; let metaError = null; @@ -124,21 +75,14 @@ export default class QueryBuilder extends React.Component { metaError = error; } - if (this.isQueryPresent()) { - missingMembers = this.getMissingMembers(query, meta); - - if (missingMembers.length === 0) { - dryRunResponse = this.cubejsApi().dryRun(query); - } - } - this.setState({ meta, metaError, - orderMembers: QueryBuilder.getOrderMembers({ meta, query }), - pivotConfig: ResultSet.getNormalizedPivotConfig(dryRunResponse?.pivotQuery || {}, pivotConfig), - missingMembers, isFetchingMeta: false + }, () => { + // Run update query to force viz state update + // This will catch any new missing members, and also validate the query against the new meta + this.updateQuery({}); }); } @@ -216,7 +160,7 @@ export default class QueryBuilder extends React.Component { meta, metaError, query, - orderMembers = [], + queryError, chartType, pivotConfig, validatedQuery, @@ -226,38 +170,75 @@ export default class QueryBuilder extends React.Component { const flatFilters = uniqBy( prop('member'), - flattenFilters((meta && query.filters) || []).map((filter) => ({ + flattenFilters(query.filters || []).map((filter) => ({ ...filter, member: filter.member || filter.dimension, })) ); - const filters = flatFilters.map((m, i) => ({ - ...m, - dimension: meta.resolveMember(m.member || m.dimension, ['dimensions', 'measures']), - operators: meta.filterOperatorsForMember(m.member || m.dimension, ['dimensions', 'measures']), - index: i, - })); + const filters = meta + ? flatFilters.map((m, i) => ({ + ...m, + dimension: meta.resolveMember(m.member || m.dimension, ['dimensions', 'measures']), + operators: meta.filterOperatorsForMember(m.member || m.dimension, ['dimensions', 'measures']), + index: i, + })) + : []; + + const measures = QueryBuilder.resolveMember('measures', this.state); + const dimensions = QueryBuilder.resolveMember('dimensions', this.state); + const timeDimensions = QueryBuilder.resolveMember('timeDimensions', this.state); + const segments = ((meta && query.segments) || []).map((m, i) => ({ index: i, ...meta.resolveMember(m, 'segments') })); + + const availableMeasures = meta ? meta.membersForQuery(query, 'measures') : []; + const availableDimensions = meta ? meta.membersForQuery(query, 'dimensions') : []; + const availableSegments = meta ? meta.membersForQuery(query, 'segments') : []; + + let orderMembers = uniqBy( + prop('id'), + [ + ...(Array.isArray(query.order) ? query.order : Object.entries(query.order || {})) + .map(([id, order]) => ({ + id, + order, + title: meta ? meta.resolveMember(id, ['measures', 'dimensions']).title : '' + })), + // uniqBy prefers first, so these will only be added if not already in the query + ...[ + ...measures, + ...dimensions + ].map(({ name, title }) => ({ id: name, title, order: 'none' })) + ] + ); + + // Preserve order until the members change or manually re-ordered + // This is needed so that when an order member becomes active, it doesn't jump to the top of the list + const orderMemberOrderKey = JSON.stringify(orderMembers.map(({ id }) => id).sort()); + if (this.orderMemberOrderKey && this.orderMemberOrder && orderMemberOrderKey === this.orderMemberOrderKey) { + orderMembers = this.orderMemberOrder.map(id => orderMembers.find(member => member.id === id)); + } else { + this.orderMemberOrderKey = orderMemberOrderKey; + this.orderMemberOrder = orderMembers.map(({ id }) => id); + } return { meta, metaError, query, + error: queryError, // Match same name as QueryRenderer prop validatedQuery, isQueryPresent: this.isQueryPresent(), chartType, - measures: QueryBuilder.resolveMember('measures', this.state), - dimensions: QueryBuilder.resolveMember('dimensions', this.state), - timeDimensions: QueryBuilder.resolveMember('timeDimensions', this.state), - segments: ((meta && query.segments) || []).map((m, i) => ({ index: i, ...meta.resolveMember(m, 'segments') })), + measures, + dimensions, + timeDimensions, + segments, filters, orderMembers, - availableMeasures: (meta && meta.membersForQuery(query, 'measures')) || [], - availableDimensions: (meta && meta.membersForQuery(query, 'dimensions')) || [], - availableTimeDimensions: ((meta && meta.membersForQuery(query, 'dimensions')) || []).filter( - (m) => m.type === 'time' - ), - availableSegments: (meta && meta.membersForQuery(query, 'segments')) || [], + availableMeasures, + availableDimensions, + availableTimeDimensions: availableDimensions.filter(m => m.type === 'time'), + availableSegments, updateQuery: (queryUpdate) => this.updateQuery(queryUpdate), updateMeasures: updateMethods('measures'), updateDimensions: updateMethods('dimensions'), @@ -266,12 +247,15 @@ export default class QueryBuilder extends React.Component { updateFilters: updateMethods('filters', toFilter), updateChartType: (newChartType) => this.updateVizState({ chartType: newChartType }), updateOrder: { - set: (memberId, order = 'asc') => { - this.updateVizState({ - orderMembers: orderMembers.map((orderMember) => ({ - ...orderMember, - order: orderMember.id === memberId ? order : orderMember.order, - })), + set: (memberId, newOrder = 'asc') => { + this.updateQuery({ + order: + orderMembers + .map((orderMember) => ({ + ...orderMember, + order: orderMember.id === memberId ? newOrder : orderMember.order, + })) + .reduce((acc, { id, order }) => (order !== 'none' ? ([...acc, [id, order]]) : acc), []) }); }, update: (order) => { @@ -284,8 +268,11 @@ export default class QueryBuilder extends React.Component { return; } - this.updateVizState({ - orderMembers: moveItemInArray(orderMembers, sourceIndex, destinationIndex), + this.orderMemberOrderKey = null; + this.updateQuery({ + order: + moveItemInArray(orderMembers, sourceIndex, destinationIndex) + .reduce((acc, { id, order }) => (order !== 'none' ? ([...acc, [id, order]]) : acc), []) }); }, }, @@ -310,26 +297,23 @@ export default class QueryBuilder extends React.Component { nextPivotConfig[destinationAxis].splice(destinationIndex, 0, id); this.updateVizState({ - pivotConfig: nextPivotConfig, + pivotConfig: nextPivotConfig }); }, update: (config) => { const { limit } = config; - if (limit == null) { - this.updateVizState({ - pivotConfig: { - ...pivotConfig, - ...config, - }, - }); - } else { - this.updateQuery({ limit }); - } + this.updateVizState({ + pivotConfig: { + ...pivotConfig, + ...config, + }, + ...(limit ? { query: { ...query, limit } } : null) + }); }, }, missingMembers, - refresh: this.fetchMeta, + refresh: () => this.fetchMeta(), isFetchingMeta, ...queryRendererProps, }; @@ -347,99 +331,52 @@ export default class QueryBuilder extends React.Component { } async updateVizState(state) { - const { setQuery, setVizState } = this.props; const { query: stateQuery, pivotConfig: statePivotConfig, meta } = this.state; - let finalState = this.applyStateChangeHeuristics(state); - const query = { ...(finalState.query || stateQuery) }; + // Only accept the 3 objects that are part of VizState + state = pick(['query', 'pivotConfig', 'chartType'], state); - const runSetters = (currentState) => { - if (setVizState) { - const { meta: _, validatedQuery, ...toSet } = currentState; - setVizState(toSet); - } - if (currentState.query && setQuery) { - setQuery(currentState.query); - } - }; + const finalState = this.applyStateChangeHeuristics(state); + if (!finalState.query) finalState.query = { ...stateQuery }; if (finalState.shouldApplyHeuristicOrder) { - query.order = defaultOrder(query); + finalState.query.order = defaultOrder(finalState.query); } - const updatedOrderMembers = indexBy( - prop('id'), - QueryBuilder.getOrderMembers({ - ...this.state, - ...finalState, - }) - ); - const currentOrderMemberIds = (finalState.orderMembers || []).map(({ id }) => id); - const currentOrderMembers = (finalState.orderMembers || []).filter(({ id }) => Boolean(updatedOrderMembers[id])); - - Object.entries(updatedOrderMembers).forEach(([id, orderMember]) => { - if (!currentOrderMemberIds.includes(id)) { - currentOrderMembers.push(orderMember); - } - }); - - const nextQuery = { - ...query, - order: fromPairs(currentOrderMembers.map(({ id, order }) => (order !== 'none' ? [id, order] : false)).filter(Boolean)) - }; - finalState.pivotConfig = ResultSet.getNormalizedPivotConfig( - nextQuery, + finalState.query, finalState.pivotConfig !== undefined ? finalState.pivotConfig : statePivotConfig ); - const missingMembers = this.getMissingMembers(nextQuery, meta); + finalState.missingMembers = this.getMissingMembers(finalState.query, meta); - runSetters({ - ...state, - query: nextQuery, - orderMembers: currentOrderMembers, - }); - - this.setState({ - ...finalState, - query: nextQuery, - orderMembers: currentOrderMembers, - missingMembers - }); - - let pivotQuery = {}; - if (QueryRenderer.isQueryPresent(query) && missingMembers.length === 0) { + this.setState(finalState); // Update optimistically so that UI does not stutter + + if (QueryRenderer.isQueryPresent(finalState.query) && finalState.missingMembers.length === 0) { + finalState.queryError = null; try { - const response = await this.cubejsApi().dryRun(query, { + const response = await this.cubejsApi().dryRun(finalState.query, { mutexObj: this.mutexObj, }); - pivotQuery = response.pivotQuery; if (finalState.shouldApplyHeuristicOrder) { - nextQuery.order = (response.queryOrder || []).reduce((memo, current) => ({ ...memo, ...current }), {}); + finalState.query.order = (response.queryOrder || []).reduce((memo, current) => ({ ...memo, ...current }), {}); } - if (QueryRenderer.isQueryPresent(stateQuery)) { - finalState = { - ...finalState, - query: nextQuery, - pivotConfig: ResultSet.getNormalizedPivotConfig(pivotQuery, finalState.pivotConfig), - }; - - this.setState({ - ...finalState, - validatedQuery: this.validatedQuery(finalState), - }); - runSetters({ - ...this.state, - ...finalState, - }); - } + finalState.pivotConfig = ResultSet.getNormalizedPivotConfig(response.pivotQuery, finalState.pivotConfig); + finalState.validatedQuery = this.validatedQuery(finalState); } catch (error) { console.error(error); + finalState.queryError = error; } } + + this.setState(finalState, () => { + const { onVizStateChanged } = this.props; + if (onVizStateChanged) { + onVizStateChanged(pick(['chartType', 'pivotConfig', 'query'], this.state)); + } + }); } validatedQuery(state) { @@ -452,7 +389,7 @@ export default class QueryBuilder extends React.Component { } defaultHeuristics(newState) { - const { query, sessionGranularity } = this.state; + const { query, sessionGranularity, meta } = this.state; const defaultGranularity = sessionGranularity || 'day'; if (Array.isArray(query)) { @@ -463,8 +400,6 @@ export default class QueryBuilder extends React.Component { const oldQuery = query; let newQuery = newState.query; - const { meta } = this.state; - if ( (oldQuery.timeDimensions || []).length === 1 && (newQuery.timeDimensions || []).length === 1 @@ -636,14 +571,30 @@ export default class QueryBuilder extends React.Component { QueryBuilder.contextType = CubeContext; +QueryBuilder.propTypes = { + cubejsApi: PropTypes.object, + defaultQuery: PropTypes.object, + defaultChartType: PropTypes.string, + initialVizState: PropTypes.shape({ + query: PropTypes.object, + pivotConfig: PropTypes.object, + chartType: PropTypes.string + }), + onVizStateChanged: PropTypes.func, + stateChangeHeuristics: PropTypes.func, + disableHeuristics: PropTypes.bool, + render: PropTypes.func, + wrapWithQueryRenderer: PropTypes.bool +}; + QueryBuilder.defaultProps = { cubejsApi: null, - query: {}, - setQuery: null, - setVizState: null, + defaultQuery: {}, + defaultChartType: 'line', + initialVizState: null, + onVizStateChanged: null, stateChangeHeuristics: null, disableHeuristics: false, render: null, wrapWithQueryRenderer: true, - vizState: {}, };