From effa71b097b150b5703365eb3a969da568c2e430 Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 24 Aug 2021 11:06:00 +0200 Subject: [PATCH] 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. --- src/components/Panel/Panel.tsx | 9 +- src/widgets/panel/__tests__/panel-test.ts | 251 ++++++++++++++++++++-- src/widgets/panel/panel.tsx | 73 +++++-- 3 files changed, 285 insertions(+), 48 deletions(-) diff --git a/src/components/Panel/Panel.tsx b/src/components/Panel/Panel.tsx index 44173267de..77a78de52d 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, + PanelRenderOptions, 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: PanelRenderOptions; 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..e8a1cbdc47 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<{ /** @@ -97,8 +103,11 @@ type GetWidgetRenderState = : never : Record; -export type PanelRenderOptions = - RenderOptions & GetWidgetRenderState; +export type PanelRenderOptions = ( + | InitOptions + | RenderOptions +) & + GetWidgetRenderState; export type PanelWidgetParams = { /** @@ -145,7 +154,7 @@ const renderer = collapsible, collapsed, }: { - options: RenderOptions | Record; + options: PanelRenderOptions; hidden: boolean; collapsible: boolean; collapsed: boolean; @@ -164,13 +173,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,40 +280,45 @@ 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; const options = { - ...((widget.getWidgetRenderState + ...(widget.getWidgetRenderState ? widget.getWidgetRenderState(renderOptions) - : {}) as GetWidgetRenderState), + : {}), ...renderOptions, }; @@ -313,7 +333,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; }; };