From 0e151a9552092807ecbc6993f3f6193fef621f44 Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 24 Aug 2021 14:52:20 +0200 Subject: [PATCH] feat(panel): render templates on init with render state (#4845) * feat(panel): render templates on init with render state Before this PR the initial render happens *before* widget init. This doesn't have a huge effect, although it got rendered with just an empty object. This makes things needlessly dynamic (more than the types were saying even, because the Template isn't super strict), and would make a template like `header({ widgetParams }) { return widgetParams.attribute }` throw, even though with this PR it is possible without conditionals or flashing. Under very strict conditions this could be construed as a breakign change, although it's closer to a fix, therefore I have classified it as a new feature. * undo some type changes (hidden doesn't get called on init) --- src/components/Panel/Panel.tsx | 9 +- src/widgets/panel/__tests__/panel-test.ts | 251 ++++++++++++++++++++-- src/widgets/panel/panel.tsx | 68 ++++-- 3 files changed, 284 insertions(+), 44 deletions(-) diff --git a/src/components/Panel/Panel.tsx b/src/components/Panel/Panel.tsx index 44173267de..f2cdcfd074 100644 --- a/src/components/Panel/Panel.tsx +++ b/src/components/Panel/Panel.tsx @@ -6,13 +6,10 @@ import cx from 'classnames'; import Template from '../Template/Template'; import type { PanelCSSClasses, + PanelSharedOptions, PanelTemplates, } from '../../widgets/panel/panel'; -import type { - ComponentCSSClasses, - RenderOptions, - UnknownWidgetFactory, -} from '../../types'; +import type { ComponentCSSClasses, UnknownWidgetFactory } from '../../types'; export type PanelComponentCSSClasses = ComponentCSSClasses< // `collapseIcon` is only used in the default templates of the widget @@ -26,7 +23,7 @@ export type PanelProps = { hidden: boolean; collapsible: boolean; isCollapsed: boolean; - data: RenderOptions | Record; + data: PanelSharedOptions; cssClasses: PanelComponentCSSClasses; templates: PanelComponentTemplates; bodyElement: HTMLElement; diff --git a/src/widgets/panel/__tests__/panel-test.ts b/src/widgets/panel/__tests__/panel-test.ts index b16faa4f92..1a866fb8a9 100644 --- a/src/widgets/panel/__tests__/panel-test.ts +++ b/src/widgets/panel/__tests__/panel-test.ts @@ -114,10 +114,12 @@ describe('Templates', () => { test('with default templates', () => { const widgetWithPanel = panel()(widgetFactory); - widgetWithPanel({ + const widget = widgetWithPanel({ container: document.createElement('div'), }); + widget.init(createInitOptions()); + const firstRender = render.mock.calls[0][0] as VNode< PanelProps >; @@ -137,10 +139,12 @@ describe('Templates', () => { }, })(widgetFactory); - widgetWithPanel({ + const widget = widgetWithPanel({ container: document.createElement('div'), }); + widget.init(createInitOptions()); + const firstRender = render.mock.calls[0][0] as VNode< PanelProps >; @@ -156,10 +160,12 @@ describe('Templates', () => { }, })(widgetFactory); - widgetWithPanel({ + const widget = widgetWithPanel({ container: document.createElement('div'), }); + widget.init(createInitOptions()); + const firstRender = render.mock.calls[0][0] as VNode< PanelProps >; @@ -175,10 +181,12 @@ describe('Templates', () => { }, })(widgetFactory); - widgetWithPanel({ + const widget = widgetWithPanel({ container: document.createElement('div'), }); + widget.init(createInitOptions()); + const firstRender = render.mock.calls[0][0] as VNode< PanelProps >; @@ -206,32 +214,235 @@ describe('Lifecycle', () => { container: document.createElement('div'), }); - widgetWithPanel.init!(createInitOptions()); - widgetWithPanel.render!(createRenderOptions()); - widgetWithPanel.dispose!(createDisposeOptions()); + widgetWithPanel.init(createInitOptions()); + widgetWithPanel.render(createRenderOptions()); + widgetWithPanel.dispose(createDisposeOptions()); expect(widget.init).toHaveBeenCalledTimes(1); expect(widget.render).toHaveBeenCalledTimes(1); expect(widget.dispose).toHaveBeenCalledTimes(1); }); - test('returns the `state` from the widget dispose function', () => { - const nextSearchParameters = new algoliasearchHelper.SearchParameters({ - facets: ['brands'], + describe('init', () => { + test("calls the wrapped widget's init", () => { + const widget = { + $$type: 'mock.widget', + init: jest.fn(), + }; + const widgetFactory = () => widget; + + const widgetWithPanel = panel()(widgetFactory)({ + container: document.createElement('div'), + }); + + const initOptions = createInitOptions(); + + widgetWithPanel.init(initOptions); + + expect(widget.init).toHaveBeenCalledTimes(1); + expect(widget.init).toHaveBeenCalledWith(initOptions); }); - const widget = { - $$type: 'mock.widget', - init: jest.fn(), - dispose: jest.fn(() => nextSearchParameters), - }; - const widgetFactory = () => widget; - const widgetWithPanel = panel()(widgetFactory)({ - container: document.createElement('div'), + test('does not call hidden and collapsed yet', () => { + const renderState = { + widgetParams: {}, + swag: true, + }; + + const widget = { + $$type: 'mock.widget', + render: jest.fn(), + getWidgetRenderState() { + return renderState; + }, + }; + + const widgetFactory = () => widget; + + const hiddenFn = jest.fn(); + const collapsedFn = jest.fn(); + + const widgetWithPanel = panel({ + hidden: hiddenFn, + collapsed: collapsedFn, + })(widgetFactory)({ + container: document.createElement('div'), + }); + + const initOptions = createInitOptions(); + + widgetWithPanel.init(initOptions); + + expect(hiddenFn).toHaveBeenCalledTimes(0); + expect(collapsedFn).toHaveBeenCalledTimes(0); + }); + + test('renders with render state', () => { + const renderState = { + widgetParams: {}, + swag: true, + }; + + const widget = { + $$type: 'mock.widget', + render: jest.fn(), + getWidgetRenderState() { + return renderState; + }, + }; + + const widgetFactory = () => widget; + + const widgetWithPanel = panel()(widgetFactory)({ + container: document.createElement('div'), + }); + + const initOptions = createInitOptions(); + + widgetWithPanel.init(initOptions); + + const firstRender = render.mock.calls[0][0] as VNode< + PanelProps + >; + + expect(firstRender.props).toEqual( + expect.objectContaining({ + hidden: true, + collapsible: false, + isCollapsed: false, + data: { + ...renderState, + ...initOptions, + }, + }) + ); + }); + }); + + describe('render', () => { + test("calls the wrapped widget's render", () => { + const widget = { + $$type: 'mock.widget', + render: jest.fn(), + }; + const widgetFactory = () => widget; + + const widgetWithPanel = panel()(widgetFactory)({ + container: document.createElement('div'), + }); + + const renderOptions = createRenderOptions(); + + widgetWithPanel.render(renderOptions); + + expect(widget.render).toHaveBeenCalledTimes(1); + expect(widget.render).toHaveBeenCalledWith(renderOptions); + }); + + test("calls hidden and collapsed with the wrapped widget's render state", () => { + const renderState = { + widgetParams: {}, + swag: true, + }; + + const widget = { + $$type: 'mock.widget', + render: jest.fn(), + getWidgetRenderState() { + return renderState; + }, + }; + + const widgetFactory = () => widget; + + const hiddenFn = jest.fn(); + const collapsedFn = jest.fn(); + + const widgetWithPanel = panel({ + hidden: hiddenFn, + collapsed: collapsedFn, + })(widgetFactory)({ + container: document.createElement('div'), + }); + + const renderOptions = createRenderOptions(); + + widgetWithPanel.render(renderOptions); + + expect(hiddenFn).toHaveBeenCalledTimes(1); + expect(hiddenFn).toHaveBeenCalledWith({ + ...renderState, + ...renderOptions, + }); + + expect(collapsedFn).toHaveBeenCalledTimes(1); + expect(collapsedFn).toHaveBeenCalledWith({ + ...renderState, + ...renderOptions, + }); + }); + + test('renders with render state', () => { + const renderState = { + widgetParams: {}, + swag: true, + }; + + const widget = { + $$type: 'mock.widget', + render: jest.fn(), + getWidgetRenderState() { + return renderState; + }, + }; + + const widgetFactory = () => widget; + + const widgetWithPanel = panel()(widgetFactory)({ + container: document.createElement('div'), + }); + + const renderOptions = createRenderOptions(); + + widgetWithPanel.render(renderOptions); + + const firstRender = render.mock.calls[0][0] as VNode< + PanelProps + >; + + expect(firstRender.props).toEqual( + expect.objectContaining({ + hidden: false, + collapsible: false, + isCollapsed: false, + data: { + ...renderState, + ...renderOptions, + }, + }) + ); }); + }); - const nextState = widgetWithPanel.dispose!(createDisposeOptions({})); + describe('dispose', () => { + test("returns the state from the widget's dispose function", () => { + const nextSearchParameters = new algoliasearchHelper.SearchParameters({ + facets: ['brands'], + }); + const widget = { + $$type: 'mock.widget', + init: jest.fn(), + dispose: jest.fn(() => nextSearchParameters), + }; + const widgetFactory = () => widget; + + const widgetWithPanel = panel()(widgetFactory)({ + container: document.createElement('div'), + }); + + const nextState = widgetWithPanel.dispose(createDisposeOptions()); - expect(nextState).toEqual(nextSearchParameters); + expect(nextState).toEqual(nextSearchParameters); + }); }); }); diff --git a/src/widgets/panel/panel.tsx b/src/widgets/panel/panel.tsx index 221a7d5464..8f2bf0bee3 100644 --- a/src/widgets/panel/panel.tsx +++ b/src/widgets/panel/panel.tsx @@ -11,7 +11,13 @@ import { import { component } from '../../lib/suit'; import type { PanelComponentCSSClasses } from '../../components/Panel/Panel'; import Panel from '../../components/Panel/Panel'; -import type { Template, RenderOptions, WidgetFactory } from '../../types'; +import type { + Template, + RenderOptions, + WidgetFactory, + InitOptions, + Widget, +} from '../../types'; export type PanelCSSClasses = Partial<{ /** @@ -100,6 +106,12 @@ type GetWidgetRenderState = export type PanelRenderOptions = RenderOptions & GetWidgetRenderState; +export type PanelSharedOptions = ( + | InitOptions + | RenderOptions +) & + GetWidgetRenderState; + export type PanelWidgetParams = { /** * A function that is called on each render to determine if the @@ -145,7 +157,7 @@ const renderer = collapsible, collapsed, }: { - options: RenderOptions | Record; + options: PanelSharedOptions; hidden: boolean; collapsible: boolean; collapsed: boolean; @@ -164,13 +176,19 @@ const renderer = ); }; +type AugmentedWidget< + TWidgetFactory extends AnyWidgetFactory, + TOverriddenKeys extends keyof Widget = 'init' | 'render' | 'dispose' +> = Omit, TOverriddenKeys> & + Pick, TOverriddenKeys>; + export type PanelWidget = ( panelWidgetParams?: PanelWidgetParams ) => ( widgetFactory: TWidgetFactory ) => ( widgetParams: Parameters[0] -) => ReturnType; +) => AugmentedWidget; /** * The panel widget wraps other widgets in a consistent panel design. @@ -265,32 +283,37 @@ const panel: PanelWidget = (panelWidgetParams) => { }, }); - renderPanel({ - options: {}, - hidden: true, - collapsible, - collapsed: false, - }); - const widget = widgetFactory({ ...widgetParams, container: bodyContainerNode, }); // TypeScript somehow loses track of the ...widget type, since it's - // not directly returned. Eventually the "as ReturnType" + // not directly returned. Eventually the "as AugmentedWidget" // will not be needed anymore. // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return { ...widget, - dispose(...args) { - render(null, containerNode); + init(...args) { + const [renderOptions] = args; - if (typeof widget.dispose === 'function') { - return widget.dispose.call(this, ...args); - } + const options = { + ...(widget.getWidgetRenderState + ? widget.getWidgetRenderState(renderOptions) + : {}), + ...renderOptions, + }; - return undefined; + renderPanel({ + options, + hidden: true, + collapsible, + collapsed: false, + }); + + if (typeof widget.init === 'function') { + widget.init.call(this, ...args); + } }, render(...args) { const [renderOptions] = args; @@ -313,7 +336,16 @@ const panel: PanelWidget = (panelWidgetParams) => { widget.render.call(this, ...args); } }, - } as ReturnType; + dispose(...args) { + render(null, containerNode); + + if (typeof widget.dispose === 'function') { + return widget.dispose.call(this, ...args); + } + + return undefined; + }, + } as AugmentedWidget; }; };