diff --git a/docs/content/en/latest/references/visual_components/repeater_chart.md b/docs/content/en/latest/references/visual_components/repeater_chart.md index 72a2d54e01c..92d7973aedc 100644 --- a/docs/content/en/latest/references/visual_components/repeater_chart.md +++ b/docs/content/en/latest/references/visual_components/repeater_chart.md @@ -102,5 +102,7 @@ const style = { height: 300 }; | backend | false | IAnalyticalBackend | The object with the configuration related to communication with the backend and access to analytical workspaces | | workspace | false | string | The workspace ID | | locale | false | string | The localization of the chart. Defaults to `en-US`. | +| drillableItems | false | IDrillableItem[] | An array of attribute values to be drillable, **only attributes in columns can be drilled now** | +| onDrill | false | Function | A callback when a drill is triggered on the component | | ErrorComponent | false | Component | A component to be rendered if this component is in error state | | LoadingComponent | false | Component | A component to be rendered if this component is in loading state | diff --git a/libs/sdk-ui-charts/api/sdk-ui-charts.api.md b/libs/sdk-ui-charts/api/sdk-ui-charts.api.md index eca8a761442..aae33f216b0 100644 --- a/libs/sdk-ui-charts/api/sdk-ui-charts.api.md +++ b/libs/sdk-ui-charts/api/sdk-ui-charts.api.md @@ -42,6 +42,7 @@ import { LodashIsEqual1x1 } from 'lodash/fp.js'; import { MeasureOrPlaceholder } from '@gooddata/sdk-ui'; import { MeasuresOrPlaceholders } from '@gooddata/sdk-ui'; import { NullableFiltersOrPlaceholders } from '@gooddata/sdk-ui'; +import { OnFiredDrillEvent } from '@gooddata/sdk-ui'; import { default as React_2 } from 'react'; import { SortsOrPlaceholders } from '@gooddata/sdk-ui'; import { VisType } from '@gooddata/sdk-ui'; @@ -758,6 +759,8 @@ export interface IRepeaterMeasureColumnWidthItemBody { // @beta (undocumented) export interface IRepeaterProps extends IBucketChartProps, IRepeaterBucketProps { + drillableItems?: ExplicitDrill[]; + onDrill?: OnFiredDrillEvent; } // @public (undocumented) diff --git a/libs/sdk-ui-charts/src/charts/repeater/CoreRepeater.tsx b/libs/sdk-ui-charts/src/charts/repeater/CoreRepeater.tsx index e7fe8fcb379..419d0bebaa7 100644 --- a/libs/sdk-ui-charts/src/charts/repeater/CoreRepeater.tsx +++ b/libs/sdk-ui-charts/src/charts/repeater/CoreRepeater.tsx @@ -1,6 +1,7 @@ // (C) 2024 GoodData Corporation import React, { useEffect, useMemo } from "react"; import { WrappedComponentProps, injectIntl, useIntl } from "react-intl"; +import noop from "lodash/noop.js"; import { LoadingComponent as SDKLoadingComponent, ErrorComponent as SDKErrorComponent, @@ -12,7 +13,7 @@ import { BucketNames, DataViewFacade, } from "@gooddata/sdk-ui"; -import { ITheme, bucketsFind } from "@gooddata/sdk-model"; +import { ITheme, bucketsFind, isAttribute } from "@gooddata/sdk-model"; import { ThemeContextProvider, useTheme, withTheme } from "@gooddata/sdk-ui-theme-provider"; import { IChartConfig, ICoreChartProps } from "../../interfaces/index.js"; import { RepeaterChart } from "./internal/RepeaterChart.js"; @@ -47,6 +48,8 @@ export const CoreRepeaterImpl: React.FC = (props) => { onError, onColumnResized, config = {}, + drillableItems = [], + onDrill = noop, } = props; const intl = useIntl(); @@ -107,6 +110,38 @@ export const CoreRepeaterImpl: React.FC = (props) => { } }, [theme, configWithColorPalette.colorPalette, configWithColorPalette.colorMapping, pushData, result]); + useEffect(() => { + if (result) { + const columns = bucketsFind(result.definition.buckets, BucketNames.COLUMNS); + + pushData?.({ + availableDrillTargets: { + attributes: result + .meta() + .attributeDescriptors() + .filter((descriptor) => + columns.items.find((item) => { + if (isAttribute(item)) { + return ( + item.attribute.localIdentifier === + descriptor.attributeHeader.localIdentifier + ); + } + return false; + }), + ) + .map((descriptor) => { + return { + attribute: descriptor, + intersectionAttributes: [descriptor], + }; + }), + measures: [], + }, + }); + } + }, [pushData, result]); + if (error) { const convertedError = convertError(error); const errorMessage = convertedError.getMessage(); @@ -138,6 +173,8 @@ export const CoreRepeaterImpl: React.FC = (props) => { return ( ({ + items, + columnApi: null, + drillablePredicates: [], + onDrill: props.onDrill, + dataView: props.dataView, + }); + + useEffect(() => { + drillingState.current.drillablePredicates = convertDrillableItemsToPredicates(props.drillableItems); + drillingState.current.onDrill = props.onDrill; + drillingState.current.dataView = props.dataView; + }, [props.dataView, props.drillableItems, props.onDrill]); + + const onCellClicked = useCallback((cellEvent: CellClickedEvent) => { + const drillableItem = getDrillable( + drillingState.current.dataView, + drillingState.current.drillablePredicates, + cellEvent.colDef, + ); + + if (!drillableItem) { + return false; + } + + const columnIndex = cellEvent.columnApi.getAllColumns().findIndex((col) => col === cellEvent.column); + const attributeHeaderItem = cellEvent.data[cellEvent.colDef.field]; + + const intersectionElement: IDrillEventIntersectionElement = { + header: { + ...drillableItem, + attributeHeaderItem, + }, + }; + + const drillEvent = createDrillEvent( + drillingState, + columnIndex, + cellEvent.rowIndex, + attributeHeaderItem?.name, + intersectionElement, + ); + return fireDrillEvent(drillingState, drillEvent, cellEvent); + }, []); + + const onGridReady = useCallback( + (readyEvent: GridReadyEvent) => { + drillingState.current.columnApi = readyEvent.columnApi; + }, + [drillingState], + ); + + useEffect(() => { + applyDrillableItems(columnDefs, drillingState); + }, [columnDefs]); + + return { + onGridReady, + onCellClicked, + }; +} + +function applyDrillableItems(columnDefs: ColDef[], drillingState: MutableRefObject) { + const columnApi = drillingState.current.columnApi; + + columnDefs.forEach((colDef) => { + const matched = getDrillable( + drillingState.current.dataView, + drillingState.current.drillablePredicates, + colDef, + ); + + if (matched && !columnApi) { + colDef.cellClass = cx(colDef.cellClass as string, "gd-repeater-cell-drillable"); + } + }); +} + +function getDrillable(dataView: DataViewFacade, drillablePredicates: IHeaderPredicate[], colDef: ColDef) { + const descriptors = [...dataView.meta().attributeDescriptors(), ...dataView.meta().measureDescriptors()]; + + const found = descriptors.find((item) => { + if (isAttributeDescriptor(item)) { + return item.attributeHeader.localIdentifier === colDef.field; + } + return item.measureHeaderItem.localIdentifier === colDef.field; + }); + return isSomeHeaderPredicateMatched(drillablePredicates, found, dataView) ? found : null; +} + +function createDrillEvent( + drillingState: MutableRefObject, + columnIndex: number, + rowIndex: number, + value: string | undefined, + el: IDrillEventIntersectionElement, +): IDrillEvent { + const drillContext: IDrillEventContext = { + type: VisualizationTypes.REPEATER, + element: "cell", + columnIndex, + rowIndex, + value, + intersection: [el], + }; + return { + dataView: drillingState.current.dataView.dataView, + drillContext, + }; +} + +function fireDrillEvent( + drillingState: MutableRefObject, + drillEvent: IDrillEvent, + cellEvent: CellClickedEvent, +) { + if (drillingState.current.onDrill?.(drillEvent)) { + // This is needed for /analyze/embedded/ drilling with post message + // More info: https://github.com/gooddata/gdc-analytical-designer/blob/develop/test/drillEventing/drillEventing_page.html + const event = new CustomEvent("drill", { + detail: drillEvent, + bubbles: true, + }); + cellEvent.event?.target?.dispatchEvent(event); + return true; + } + + return false; +} diff --git a/libs/sdk-ui-charts/src/charts/repeater/internal/RepeaterChart.tsx b/libs/sdk-ui-charts/src/charts/repeater/internal/RepeaterChart.tsx index f1b074f6625..225897b7822 100644 --- a/libs/sdk-ui-charts/src/charts/repeater/internal/RepeaterChart.tsx +++ b/libs/sdk-ui-charts/src/charts/repeater/internal/RepeaterChart.tsx @@ -28,6 +28,7 @@ import { InlineLineChart } from "./InlineLineChart.js"; import { InlineColumnChart } from "./InlineColumnChart.js"; import { RepeaterInlineVisualizationDataPoint } from "./dataViewToRepeaterData.js"; import isNil from "lodash/isNil.js"; +import { useDrilling } from "../hooks/useDrilling.js"; const DEFAULT_COL_DEF = { resizable: true }; @@ -134,7 +135,12 @@ export const RepeaterChart: React.FC = (props) => { config?.inlineVisualizations, ]); - const { onColumnResized, onGridReady, containerRef } = useResizing(columnDefs, items, props); + const { + onColumnResized, + onGridReady: onResizingGridReady, + containerRef, + } = useResizing(columnDefs, items, props); + const { onCellClicked, onGridReady: onDrillingGridReady } = useDrilling(columnDefs, items, props); return (
@@ -157,7 +163,11 @@ export const RepeaterChart: React.FC = (props) => { rowHeight={rowHeight} suppressCellFocus={true} suppressMovableColumns={true} - onGridReady={onGridReady} + onCellClicked={onCellClicked} + onGridReady={(e) => { + onResizingGridReady(e); + onDrillingGridReady(e); + }} onColumnResized={onColumnResized} />
diff --git a/libs/sdk-ui-charts/src/charts/repeater/internal/privateTypes.ts b/libs/sdk-ui-charts/src/charts/repeater/internal/privateTypes.ts index dc14493d2af..5ae5599e88c 100644 --- a/libs/sdk-ui-charts/src/charts/repeater/internal/privateTypes.ts +++ b/libs/sdk-ui-charts/src/charts/repeater/internal/privateTypes.ts @@ -1,6 +1,7 @@ // (C) 2024 GoodData Corporation import { ISeparators, IAttributeOrMeasure } from "@gooddata/sdk-model"; +import { IHeaderPredicate, OnFiredDrillEvent, DataViewFacade } from "@gooddata/sdk-ui"; import { ColumnApi, Column } from "@ag-grid-community/all-modules"; import { RepeaterDefaultColumnWidth, RepeaterColumnResizedCallback } from "../publicTypes.js"; @@ -30,3 +31,12 @@ export type ResizingState = { columnApi: ColumnApi | null; manuallyResizedColumns: Column[]; }; + +export type DrillingState = { + items: IAttributeOrMeasure[]; + columnApi: ColumnApi | null; + drillablePredicates: IHeaderPredicate[]; + dataView: DataViewFacade; + + onDrill: OnFiredDrillEvent | undefined; +}; diff --git a/libs/sdk-ui-charts/src/charts/repeater/publicTypes.ts b/libs/sdk-ui-charts/src/charts/repeater/publicTypes.ts index d0f7660ea33..fda8bf26fea 100644 --- a/libs/sdk-ui-charts/src/charts/repeater/publicTypes.ts +++ b/libs/sdk-ui-charts/src/charts/repeater/publicTypes.ts @@ -1,6 +1,6 @@ // (C) 2024 GoodData Corporation -import { DataViewFacade } from "@gooddata/sdk-ui"; +import { DataViewFacade, ExplicitDrill, OnFiredDrillEvent } from "@gooddata/sdk-ui"; import { IChartConfig } from "../../interfaces/index.js"; @@ -66,4 +66,14 @@ export interface IRepeaterChartProps { * @param columnWidths - new widths for columns */ onColumnResized?: RepeaterColumnResizedCallback; + + /** + * Configure drillability; e.g. which parts of the visualization can be interacted with. + */ + drillableItems?: ExplicitDrill[]; + + /** + * Called when user triggers a drill on a visualization. + */ + onDrill?: OnFiredDrillEvent; } diff --git a/libs/sdk-ui-charts/styles/scss/repeater.scss b/libs/sdk-ui-charts/styles/scss/repeater.scss index 23d1a9a7114..919bb379606 100644 --- a/libs/sdk-ui-charts/styles/scss/repeater.scss +++ b/libs/sdk-ui-charts/styles/scss/repeater.scss @@ -152,6 +152,15 @@ $subtotal-odd-background-color: var( } } + .gd-repeater-cell-drillable { + font-weight: bold; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + &.gd-repeater-header-hide { .ag-header-row { display: none;