diff --git a/package-lock.json b/package-lock.json index 73ad2e10d2..3aab3defee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31653,7 +31653,7 @@ "@deephaven/utils": "file:../utils", "@fortawesome/fontawesome-svg-core": "^6.2.1", "@fortawesome/react-fontawesome": "^0.2.0", - "@internationalized/date": "*", + "@internationalized/date": "^3.5.5", "@react-spectrum/theme-default": "^3.5.1", "@react-spectrum/utils": "^3.11.5", "@react-types/radio": "^3.8.1", diff --git a/packages/app-utils/src/plugins/remote-component.config.ts b/packages/app-utils/src/plugins/remote-component.config.ts index 2646d1b14a..9488237b7d 100644 --- a/packages/app-utils/src/plugins/remote-component.config.ts +++ b/packages/app-utils/src/plugins/remote-component.config.ts @@ -11,6 +11,7 @@ import * as AdobeReactSpectrum from '@adobe/react-spectrum'; import * as DeephavenAuthPlugins from '@deephaven/auth-plugins'; import * as DeephavenChart from '@deephaven/chart'; import * as DeephavenComponents from '@deephaven/components'; +import * as DeephavenConsole from '@deephaven/console'; import * as DeephavenDashboard from '@deephaven/dashboard'; import * as DeephavenDashboardCorePlugins from '@deephaven/dashboard-core-plugins'; import * as DeephavenIcons from '@deephaven/icons'; @@ -32,6 +33,7 @@ export const resolve = { '@deephaven/auth-plugins': DeephavenAuthPlugins, '@deephaven/chart': DeephavenChart, '@deephaven/components': DeephavenComponents, + '@deephaven/console': DeephavenConsole, '@deephaven/dashboard': DeephavenDashboard, '@deephaven/dashboard-core-plugins': DeephavenDashboardCorePlugins, '@deephaven/icons': DeephavenIcons, diff --git a/packages/chart/src/Chart.tsx b/packages/chart/src/Chart.tsx index 88c983784a..849af4b550 100644 --- a/packages/chart/src/Chart.tsx +++ b/packages/chart/src/Chart.tsx @@ -28,6 +28,7 @@ import { ModeBarButtonAny, } from 'plotly.js'; import type { PlotParams } from 'react-plotly.js'; +import { mergeRefs } from '@deephaven/react-hooks'; import { bindAllMethods } from '@deephaven/utils'; import createPlotlyComponent from './plotly/createPlotlyComponent'; import Plotly from './plotly/Plotly'; @@ -57,7 +58,7 @@ interface ChartProps { isActive: boolean; Plotly: typeof Plotly; - containerRef?: React.RefObject; + containerRef?: React.Ref; onDisconnect: () => void; onReconnect: () => void; onUpdate: (obj: { isLoading: boolean }) => void; @@ -81,11 +82,14 @@ interface ChartState { isDownsampleInProgress: boolean; isDownsamplingDisabled: boolean; - /** Any other kind of error */ + /** Any other kind of error that doesn't completely block the chart from rendering */ error: unknown; shownError: string | null; layout: Partial; revision: number; + + /** A message that blocks the chart from rendering. It can be bypassed by the user to continue rendering. */ + shownBlocker: string | null; } class Chart extends Component { @@ -156,7 +160,8 @@ class Chart extends Component { this.PlotComponent = createPlotlyComponent(props.Plotly); this.plot = React.createRef(); - this.plotWrapper = props.containerRef ?? React.createRef(); + this.plotWrapper = React.createRef(); + this.plotWrapperMerged = mergeRefs(this.plotWrapper, props.containerRef); this.columnFormats = []; this.dateTimeFormatterOptions = {}; this.decimalFormatOptions = {}; @@ -178,6 +183,7 @@ class Chart extends Component { datarevision: 0, }, revision: 0, + shownBlocker: null, }; } @@ -238,6 +244,8 @@ class Chart extends Component { plotWrapper: RefObject; + plotWrapperMerged: React.RefCallback; + columnFormats?: FormattingRule[]; dateTimeFormatterOptions?: DateTimeColumnFormatterOptions; @@ -508,6 +516,15 @@ class Chart extends Component { onError(new Error(error)); break; } + case ChartModel.EVENT_BLOCKER: { + const blocker = `${detail}`; + this.setState({ shownBlocker: blocker }); + break; + } + case ChartModel.EVENT_BLOCKER_CLEAR: { + this.setState({ shownBlocker: null }); + break; + } default: log.debug('Unknown event type', type, event); } @@ -701,6 +718,7 @@ class Chart extends Component { shownError, layout, revision, + shownBlocker, } = this.state; const config = this.getCachedConfig( downsamplingError, @@ -710,10 +728,49 @@ class Chart extends Component { data ?? [], error ); - const isPlotShown = data != null; + const { model } = this.props; + const isPlotShown = data != null && shownBlocker == null; + + let errorOverlay: React.ReactNode = null; + if (shownBlocker != null) { + errorOverlay = ( + { + model.fireBlockerClear(); + }} + /> + ); + } else if (shownError != null) { + errorOverlay = ( + { + this.handleDownsampleErrorClose(); + }} + onConfirm={() => { + this.handleDownsampleErrorClose(); + this.handleDownsampleClick(); + }} + /> + ); + } else if (downsamplingError != null) { + errorOverlay = ( + { + this.handleDownsampleErrorClose(); + }} + onConfirm={() => { + this.handleDownsampleErrorClose(); + this.handleDownsampleClick(); + }} + /> + ); + } return ( -
+
{isPlotShown && ( { style={{ height: '100%', width: '100%' }} /> )} - {downsamplingError != null && shownError == null && ( - { - this.handleDownsampleErrorClose(); - }} - onConfirm={() => { - this.handleDownsampleErrorClose(); - this.handleDownsampleClick(); - }} - /> - )} - {shownError != null && ( - { - this.handleErrorClose(); - }} - /> - )} + {errorOverlay}
); } diff --git a/packages/chart/src/ChartModel.ts b/packages/chart/src/ChartModel.ts index 0cf03815af..ad03608766 100644 --- a/packages/chart/src/ChartModel.ts +++ b/packages/chart/src/ChartModel.ts @@ -37,6 +37,10 @@ class ChartModel { static EVENT_ERROR = 'ChartModel.EVENT_ERROR'; + static EVENT_BLOCKER = 'ChartModel.EVENT_BLOCKER'; + + static EVENT_BLOCKER_CLEAR = 'ChartModel.EVENT_BLOCKER_CLEAR'; + constructor(dh: typeof DhType) { this.dh = dh; this.listeners = []; @@ -184,6 +188,14 @@ class ChartModel { fireError(detail: string[]): void { this.fireEvent(new CustomEvent(ChartModel.EVENT_ERROR, { detail })); } + + fireBlocker(detail: string[]): void { + this.fireEvent(new CustomEvent(ChartModel.EVENT_BLOCKER, { detail })); + } + + fireBlockerClear(): void { + this.fireEvent(new CustomEvent(ChartModel.EVENT_BLOCKER_CLEAR)); + } } export default ChartModel; diff --git a/packages/components/src/spectrum/comboBox/ComboBox.tsx b/packages/components/src/spectrum/comboBox/ComboBox.tsx index 80809b4fd6..4a689acd1a 100644 --- a/packages/components/src/spectrum/comboBox/ComboBox.tsx +++ b/packages/components/src/spectrum/comboBox/ComboBox.tsx @@ -5,9 +5,9 @@ import { } from '@adobe/react-spectrum'; import type { DOMRef } from '@react-types/shared'; import cl from 'classnames'; +import { useMergeRef } from '@deephaven/react-hooks'; import type { NormalizedItem } from '../utils'; import { PickerPropsT, usePickerProps } from '../picker'; -import useMultiRef from '../picker/useMultiRef'; export type ComboBoxProps = PickerPropsT>; @@ -22,7 +22,7 @@ export const ComboBox = React.forwardRef(function ComboBox( ref: scrollRef, ...comboBoxProps } = usePickerProps(props); - const pickerRef = useMultiRef(ref, scrollRef); + const pickerRef = useMergeRef(ref, scrollRef); return ( (...refs: readonly Ref[]): RefCallback { - return useCallback(newRef => { - refs.forEach(ref => { - if (typeof ref === 'function') { - ref(newRef); - } else if (ref != null) { - // eslint-disable-next-line no-param-reassign - (ref as MutableRefObject).current = newRef; - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, refs); -} - -export default useMultiRef; diff --git a/packages/dashboard-core-plugins/src/panels/ChartPanel.tsx b/packages/dashboard-core-plugins/src/panels/ChartPanel.tsx index e6ebff54d1..3d8630ff7d 100644 --- a/packages/dashboard-core-plugins/src/panels/ChartPanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/ChartPanel.tsx @@ -123,8 +123,11 @@ interface OwnProps extends DashboardPanelProps { makeModel: () => Promise; localDashboardId: string; Plotly?: typeof PlotlyType; - /** The plot container div */ - containerRef?: RefObject; + /** + * The plot container div. + * The ref will be undefined on initial render if the chart needs to be loaded. + */ + containerRef?: React.Ref; panelState?: GLChartPanelState; } diff --git a/packages/react-hooks/src/index.ts b/packages/react-hooks/src/index.ts index 83145b70db..de94dbe05e 100644 --- a/packages/react-hooks/src/index.ts +++ b/packages/react-hooks/src/index.ts @@ -34,3 +34,4 @@ export * from './useSetAttributesCallback'; export * from './useSpectrumDisableSpellcheckRef'; export * from './useWindowedListData'; export * from './useResizeObserver'; +export * from './useMergeRef'; diff --git a/packages/components/src/spectrum/picker/useMultiRef.test.ts b/packages/react-hooks/src/useMergeRef.test.ts similarity index 53% rename from packages/components/src/spectrum/picker/useMultiRef.test.ts rename to packages/react-hooks/src/useMergeRef.test.ts index 7237f3c2da..70a01e4d09 100644 --- a/packages/components/src/spectrum/picker/useMultiRef.test.ts +++ b/packages/react-hooks/src/useMergeRef.test.ts @@ -1,12 +1,52 @@ +import React from 'react'; import { renderHook } from '@testing-library/react-hooks'; -import useMultiRef from './useMultiRef'; +import { useMergeRef, mergeRefs } from './useMergeRef'; -describe('useMultiRef', () => { +describe('mergeRefs', () => { + it('merges ref objects', () => { + const refA = React.createRef(); + const refB = React.createRef(); + const mergedRef = mergeRefs(refA, refB); + + const refValue = {}; + mergedRef(refValue); + expect(refA.current).toBe(refValue); + expect(refB.current).toBe(refValue); + }); + + it('merges ref callbacks', () => { + const refA: React.RefCallback = jest.fn(); + const refB: React.RefCallback = jest.fn(); + const mergedRef = mergeRefs(refA, refB); + + const refValue = {}; + mergedRef(refValue); + expect(refA).toHaveBeenCalledWith(refValue); + expect(refB).toHaveBeenCalledWith(refValue); + }); + + it('ignores null/undefined refs', () => { + const refA = React.createRef(); + const refB: React.RefCallback = jest.fn(); + const refC = null; + const refD = undefined; + const mergedRef = mergeRefs(refA, refB, refC, refD); + + const refValue = {}; + mergedRef(refValue); + expect(refA.current).toBe(refValue); + expect(refB).toHaveBeenCalledWith(refValue); + expect(refC).toBe(null); + expect(refD).toBe(undefined); + }); +}); + +describe('useMergeRef', () => { it('should assign the ref to all refs passed in', () => { const ref1 = jest.fn(); const ref2 = jest.fn(); const ref3 = jest.fn(); - const { result } = renderHook(() => useMultiRef(ref1, ref2, ref3)); + const { result } = renderHook(() => useMergeRef(ref1, ref2, ref3)); const multiRef = result.current; const element = document.createElement('div'); multiRef(element); @@ -19,7 +59,7 @@ describe('useMultiRef', () => { const ref1 = jest.fn(); const ref2 = jest.fn(); const ref3 = jest.fn(); - const { result } = renderHook(() => useMultiRef(ref1, ref2, ref3)); + const { result } = renderHook(() => useMergeRef(ref1, ref2, ref3)); const multiRef = result.current; multiRef(null); expect(ref1).toHaveBeenCalledWith(null); @@ -32,7 +72,7 @@ describe('useMultiRef', () => { const ref2 = { current: null }; const ref3 = { current: null }; const { result } = renderHook(() => - useMultiRef(ref1, ref2, ref3) + useMergeRef(ref1, ref2, ref3) ); const multiRef = result.current; const element = document.createElement('div'); @@ -47,7 +87,7 @@ describe('useMultiRef', () => { const ref2 = { current: null }; const ref3 = jest.fn(); const { result } = renderHook(() => - useMultiRef(ref1, ref2, ref3) + useMergeRef(ref1, ref2, ref3) ); const multiRef = result.current; const element = document.createElement('div'); diff --git a/packages/react-hooks/src/useMergeRef.ts b/packages/react-hooks/src/useMergeRef.ts new file mode 100644 index 0000000000..3e98293978 --- /dev/null +++ b/packages/react-hooks/src/useMergeRef.ts @@ -0,0 +1,43 @@ +import { + type LegacyRef, + type MutableRefObject, + type Ref, + type RefCallback, + useMemo, +} from 'react'; + +/** + * Merge multiple react refs into a single ref callback. + * This can be used to merge callback and object refs into a single ref. + * Merged callback refs will be called while object refs will have their current property set. + * @param refs The refs to merge + * @returns A ref callback that will set the value on all refs + */ +export function mergeRefs( + ...refs: readonly (MutableRefObject | LegacyRef | null | undefined)[] +): RefCallback { + return newRef => { + refs.forEach(ref => { + if (ref != null) { + if (typeof ref === 'function') { + ref(newRef); + } else { + // React marks RefObject as readonly, but it's just to indicate React manages it + // We can still write to its current value + // eslint-disable-next-line no-param-reassign + (ref as React.MutableRefObject).current = newRef; + } + } + }); + }; +} + +/** + * Merges multiple refs into one ref that can be assigned to the component. + * In turn all the refs passed in will be assigned when the ref returned is assigned. + * @param refs Array of refs to assign + */ +export function useMergeRef(...refs: readonly Ref[]): RefCallback { + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => mergeRefs(...refs), refs); +}