From f3989591dcadec068e678557405bb3aa20db41d1 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Fri, 28 Jul 2023 17:23:30 -0400 Subject: [PATCH 01/29] chore(cleanup) - improving url logic (#612) Reduced complexity of determining the url --- src/core/components/hv-route/index.tsx | 27 +++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/core/components/hv-route/index.tsx b/src/core/components/hv-route/index.tsx index 7d8b5aae8..6220719f0 100644 --- a/src/core/components/hv-route/index.tsx +++ b/src/core/components/hv-route/index.tsx @@ -349,24 +349,25 @@ export default function HvRoute(props: Types.Props) { url = navigatorMapContext.getRoute(NavigatorService.cleanHrefFragment(url)); } - if (!url) { - // Use the route id if available to look up the url - if (props.route?.params?.id) { - url = navigatorMapContext.getRoute(props.route.params.id); - } else if (navigatorMapContext.initialRouteName) { - // Try to use the initial route for this - url = navigatorMapContext.getRoute(navigatorMapContext.initialRouteName); - } - } - - // Fall back to the entrypoint url - url = url || navigationContext.entrypointUrl; - const id: string | undefined = props.route?.params?.id || navigatorMapContext.initialRouteName || undefined; + if (!url) { + // Use the route id or initial routeto look up the url + if (id) { + url = navigatorMapContext.getRoute(id); + } + } + + // Fall back to the entrypoint url, only for the top route + if (!url && !props.navigation) { + url = navigationContext.entrypointUrl; + } else { + url = url || ''; + } + const { index, type } = props.navigation?.getState() || {}; // The nested element is only used when the navigator is not a stack // or is the first screen in a stack. Other stack screens will require a url From 9a8f7d68bd9f7aaface33ac61e324c444f524073 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Tue, 1 Aug 2023 19:30:06 -0400 Subject: [PATCH 02/29] fix(navigator) - improved handling of nested navigators (#615) The original implementation for handling `` elements nested inside `` elements relied on storing Dom elements in a map by the id of the route which contained them. This had several problems: - A cache had to be created - The items in the cache could become orphaned from their parent doc - Mutations to the doc would not be reflected in the cache - The cache was never cleared - The id or index of the implementation could change This new solution uses a context to wrap every `hv-route` which contains its own document. This allows child routes to retrieve their Dom element and retrieve any nested navigator direction from the doc. Improvements are also made to the initial route by creating all routes into the stack navigator. --- src/contexts/navigator-map.tsx | 17 --- src/contexts/route-doc.tsx | 18 ++++ src/core/components/hv-navigator/index.tsx | 36 ++++--- src/core/components/hv-navigator/types.ts | 5 +- src/core/components/hv-route/index.tsx | 116 +++++++++++++++------ src/core/components/hv-route/types.ts | 7 +- src/services/dom/helpers-legacy.ts | 26 +++++ src/types-legacy.ts | 3 + 8 files changed, 150 insertions(+), 78 deletions(-) create mode 100644 src/contexts/route-doc.tsx diff --git a/src/contexts/navigator-map.tsx b/src/contexts/navigator-map.tsx index 8eaac2f3f..b63e2c487 100644 --- a/src/contexts/navigator-map.tsx +++ b/src/contexts/navigator-map.tsx @@ -12,11 +12,8 @@ import React, { createContext, useState } from 'react'; export type NavigatorMapContextProps = { setRoute: (key: string, route: string) => void; getRoute: (key: string) => string | undefined; - setElement: (key: string, element: TypesLegacy.Element) => void; - getElement: (key: string) => TypesLegacy.Element | undefined; setPreload: (key: number, element: TypesLegacy.Element) => void; getPreload: (key: number) => TypesLegacy.Element | undefined; - initialRouteName?: string; }; /** @@ -24,14 +21,11 @@ export type NavigatorMapContextProps = { * Each navigator creates its own context * - routeMap: Urls defined in elements are stored in the routeMap by their key * - elementMap: Contains element sub-navigators defined in a element - * - initialRouteName: The name of the first route to render * - preloadMap: A map of preload elements by their id */ export const NavigatorMapContext = createContext({ - getElement: () => undefined, getPreload: () => undefined, getRoute: () => '', - setElement: () => undefined, setPreload: () => undefined, setRoute: () => undefined, }); @@ -44,7 +38,6 @@ type Props = { children: React.ReactNode }; */ export function NavigatorMapProvider(props: Props) { const [routeMap] = useState>(new Map()); - const [elementMap] = useState>(new Map()); const [preloadMap] = useState>(new Map()); const setRoute = (key: string, route: string) => { @@ -55,14 +48,6 @@ export function NavigatorMapProvider(props: Props) { return routeMap.get(key); }; - const setElement = (key: string, element: TypesLegacy.Element) => { - elementMap.set(key, element); - }; - - const getElement = (key: string): TypesLegacy.Element | undefined => { - return elementMap.get(key); - }; - const setPreload = (key: number, element: TypesLegacy.Element) => { preloadMap.set(key, element); }; @@ -74,10 +59,8 @@ export function NavigatorMapProvider(props: Props) { return ( ( + undefined, +); diff --git a/src/core/components/hv-navigator/index.tsx b/src/core/components/hv-navigator/index.tsx index daf8cfb01..626e16879 100644 --- a/src/core/components/hv-navigator/index.tsx +++ b/src/core/components/hv-navigator/index.tsx @@ -12,7 +12,7 @@ import * as NavigatorService from 'hyperview/src/services/navigator'; import * as Types from './types'; import * as TypesLegacy from 'hyperview/src/types-legacy'; import React, { PureComponent, useContext } from 'react'; -import { getFirstTag } from 'hyperview/src/services/dom/helpers-legacy'; +import { getFirstChildTag } from 'hyperview/src/services/dom/helpers-legacy'; /** * Flag to show the navigator UIs @@ -26,7 +26,7 @@ export default class HvNavigator extends PureComponent { /** * Build an individual tab screen */ - buildTabScreen = ( + buildScreen = ( id: string, type: TypesLegacy.DOMString, ): React.ReactElement => { @@ -40,6 +40,16 @@ export default class HvNavigator extends PureComponent { /> ); } + if (type === NavigatorService.NAVIGATOR_TYPE.STACK) { + return ( + + ); + } throw new NavigatorService.HvNavigatorError( `No navigator found for type '${type}'`, ); @@ -63,7 +73,7 @@ export default class HvNavigator extends PureComponent { throw new NavigatorService.HvRouteError('No context found'); } - const { buildTabScreen } = this; + const { buildScreen } = this; const elements: TypesLegacy.Element[] = NavigatorService.getChildElements( element, ); @@ -85,14 +95,12 @@ export default class HvNavigator extends PureComponent { } // Check for nested navigators - const nestedNavigator: TypesLegacy.Element | null = getFirstTag( + const nestedNavigator: TypesLegacy.Element | null = getFirstChildTag( navRoute, TypesLegacy.LOCAL_NAME.NAVIGATOR, ); - if (nestedNavigator) { - // Cache the navigator for the route - navigatorMapContext.setElement(id, nestedNavigator); - } else { + + if (!nestedNavigator) { const href: | TypesLegacy.DOMString | null @@ -110,11 +118,7 @@ export default class HvNavigator extends PureComponent { // Cache the url for the route by nav-route id navigatorMapContext.setRoute(id, url); } - - // 'stack' uses route urls, other types build out the screens - if (type !== NavigatorService.NAVIGATOR_TYPE.STACK) { - screens.push(buildTabScreen(id, type)); - } + screens.push(buildScreen(id, type)); } }); @@ -186,16 +190,14 @@ export default class HvNavigator extends PureComponent { const selectedId: string | undefined = selected .getAttribute('id') ?.toString(); - if (selectedId) { - navigatorMapContext.initialRouteName = selectedId; - } + const { buildScreens } = this; switch (type) { case NavigatorService.NAVIGATOR_TYPE.STACK: return ( ({ header: undefined, headerMode: 'screen', diff --git a/src/core/components/hv-navigator/types.ts b/src/core/components/hv-navigator/types.ts index e2a876666..a61f565b8 100644 --- a/src/core/components/hv-navigator/types.ts +++ b/src/core/components/hv-navigator/types.ts @@ -14,10 +14,7 @@ export type RouteParams = { url: string; }; -export type ParamTypes = { - dynamic: RouteParams; - modal: RouteParams; -}; +export type ParamTypes = Record; export type ScreenParams = { params: RouteParams; diff --git a/src/core/components/hv-route/index.tsx b/src/core/components/hv-route/index.tsx index 6220719f0..6b4962bb0 100644 --- a/src/core/components/hv-route/index.tsx +++ b/src/core/components/hv-route/index.tsx @@ -15,6 +15,7 @@ import * as NavigationContext from 'hyperview/src/contexts/navigation'; import * as NavigatorMapContext from 'hyperview/src/contexts/navigator-map'; import * as NavigatorService from 'hyperview/src/services/navigator'; import * as Render from 'hyperview/src/services/render'; +import * as RouteDocContext from 'hyperview/src/contexts/route-doc'; import * as Stylesheets from 'hyperview/src/services/stylesheets'; import * as Types from './types'; import * as TypesLegacy from 'hyperview/src/types-legacy'; @@ -266,6 +267,15 @@ class HvRouteInner extends PureComponent { } if (renderElement.localName === TypesLegacy.LOCAL_NAME.NAVIGATOR) { + if (this.state.doc) { + // The provides doc access to nested navigators + return ( + + + + ); + } + // Without a doc, the navigator shares the higher level context return ; } const { Screen } = this; @@ -320,6 +330,70 @@ class HvRouteInner extends PureComponent { } } +/** + * Retrieve the url from the props, params, or context + */ +const getRouteUrl = ( + props: Types.Props, + navigationContext: Types.NavigationContextProps, + navigatorMapContext: Types.NavigatorMapContextProps, +) => { + // The initial hv-route element will use the entrypoint url + if (props.navigation === undefined) { + return navigationContext.entrypointUrl; + } + + // Use the passed url + if (props.route?.params?.url) { + if (NavigatorService.isUrlFragment(props.route?.params?.url)) { + // Look up the url from the route map where it would have been + // stored from the initial definition + return navigatorMapContext.getRoute( + NavigatorService.cleanHrefFragment(props.route?.params?.url), + ); + } + return props.route?.params?.url; + } + + // Look up by route id + if (props.route?.params?.id) { + return navigatorMapContext.getRoute(props.route?.params?.id); + } + + return undefined; +}; + +/** + * Retrieve a nested navigator as a child of the nav-route with the given id + */ +const getNestedNavigator = ( + id?: string, + doc?: TypesLegacy.Document, +): TypesLegacy.Element | undefined => { + if (!id || !doc) { + return undefined; + } + + const routes = doc + .getElementsByTagNameNS( + Namespaces.HYPERVIEW, + TypesLegacy.LOCAL_NAME.NAV_ROUTE, + ) + .filter((n: TypesLegacy.Element) => { + return n.getAttribute('id') === id; + }); + const route = routes && routes.length > 0 ? routes[0] : undefined; + if (route) { + return ( + Helpers.getFirstChildTag( + route, + TypesLegacy.LOCAL_NAME.NAVIGATOR, + ) || undefined + ); + } + return undefined; +}; + /** * Functional component wrapper around HvRouteInner * NOTE: The reason for this approach is to allow accessing @@ -340,43 +414,17 @@ export default function HvRoute(props: Types.Props) { throw new NavigatorService.HvRouteError('No context found'); } - // Retrieve the url from params or from the context - let url: string | undefined = props.route?.params?.url; - // Fragment urls are used to designate a route within a document - if (url && NavigatorService.isUrlFragment(url)) { - // Look up the url from the route map where it would have been - // stored from the initial definition - url = navigatorMapContext.getRoute(NavigatorService.cleanHrefFragment(url)); - } - - const id: string | undefined = - props.route?.params?.id || - navigatorMapContext.initialRouteName || - undefined; - - if (!url) { - // Use the route id or initial routeto look up the url - if (id) { - url = navigatorMapContext.getRoute(id); - } - } - - // Fall back to the entrypoint url, only for the top route - if (!url && !props.navigation) { - url = navigationContext.entrypointUrl; - } else { - url = url || ''; - } + const routeDocContext: TypesLegacy.Document | undefined = useContext( + RouteDocContext.Context, + ); - const { index, type } = props.navigation?.getState() || {}; - // The nested element is only used when the navigator is not a stack - // or is the first screen in a stack. Other stack screens will require a url - const includeElement: boolean = - type !== NavigatorService.NAVIGATOR_TYPE.STACK || index === 0; + const url = getRouteUrl(props, navigationContext, navigatorMapContext); // Get the navigator element from the context - const element: TypesLegacy.Element | undefined = - id && includeElement ? navigatorMapContext.getElement(id) : undefined; + const element: TypesLegacy.Element | undefined = getNestedNavigator( + props.route?.params?.id, + routeDocContext, + ); return ( string | undefined; - getElement: (key: string) => TypesLegacy.Element | undefined; setPreload: (key: number, element: TypesLegacy.Element) => void; getPreload: (key: number) => TypesLegacy.Element | undefined; - initialRouteName?: string; }; export type State = { @@ -60,8 +58,7 @@ export type RouteProps = NavigatorService.Route; * The props used by inner components of hv-route */ export type InnerRouteProps = { - id?: string; - url: string; + url?: string; navigation?: NavigatorService.NavigationProp; route?: NavigatorService.Route; entrypointUrl: string; @@ -79,10 +76,8 @@ export type InnerRouteProps = { loadingScreen?: ComponentType; handleBack?: ComponentType<{ children: ReactNode }>; getRoute: (key: string) => string | undefined; - getElement: (key: string) => TypesLegacy.Element | undefined; setPreload: (key: number, element: TypesLegacy.Element) => void; getPreload: (key: number) => TypesLegacy.Element | undefined; - initialRouteName?: string; element?: TypesLegacy.Element; }; diff --git a/src/services/dom/helpers-legacy.ts b/src/services/dom/helpers-legacy.ts index 93b993b3b..70a7bb777 100644 --- a/src/services/dom/helpers-legacy.ts +++ b/src/services/dom/helpers-legacy.ts @@ -15,7 +15,9 @@ import { Document, Element, LocalName, + NODE_TYPE, NamespaceURI, + Node, } from 'hyperview/src/types-legacy'; export const getFirstTag = ( @@ -29,3 +31,27 @@ export const getFirstTag = ( } return null; }; + +/** + * Find the first child element of a node with a given local name and namespace + */ +export const getFirstChildTag = ( + node: Node, + localName: LocalName, + namespace: NamespaceURI = Namespaces.HYPERVIEW, +): T | null => { + if (!node || !node.childNodes) { + return null; + } + for (let i = 0; i < node.childNodes.length; i += 1) { + const child = node.childNodes[i]; + if ( + child.nodeType === NODE_TYPE.ELEMENT_NODE && + child.localName === localName && + child.namespaceURI === namespace + ) { + return child as T; + } + } + return null; +}; diff --git a/src/types-legacy.ts b/src/types-legacy.ts index 5ea22014f..4edbbd86a 100644 --- a/src/types-legacy.ts +++ b/src/types-legacy.ts @@ -73,6 +73,9 @@ export type DOMString = string; export type NamespaceURI = string; export type NodeList = { + // ***** ADDED ***** + filter: (predicate: (item: T) => boolean) => T[]; + length: number; item: (index: number) => T | null | undefined; } & { From 1a916e0997b3cf517d978e6da3782f71e0a44609 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Wed, 2 Aug 2023 11:30:30 -0400 Subject: [PATCH 03/29] feature(deep link) - add support for merging a received document with the existing doc (#618) As the first step in deep link support, this update supports full merging of the existing dom with an incoming deep link document. The structure of the incoming document remains the same as any other \ document structure with the following exceptions: - For any \ node, the addition of an attribute `merge="true"` will enable merging the navigator children. Omitting the attribute will cause any \ to replace an existing one of the same name - Merging will add any child \ nodes which don't exist and will update those which do - All `selected` values from the current document are reset to "false" to allow the incoming document to set a new selected path In the example below, the deep link provides the following changes: 1. The shifts-route receives a sub-navigator 2. The tabs-navigator receives an additional 'settings' tab 3. The root-navigator receives a new 'account-popup' route 4. The initial route is now `root-navigator->tabs-route->tabs-navigator->shifts->shifts-route->past-shifts` Because all incoming data indicated `merge="true"`, all of the changes were additive. Original: ```xml ``` Deep link: ```xml ``` Merged: ```xml ``` --- schema/core.xsd | 1 + src/core/components/hv-route/index.tsx | 18 ++- src/services/navigator/helpers.test.ts | 174 +++++++++++++++++++++++++ src/services/navigator/helpers.ts | 144 +++++++++++++++++++- src/services/navigator/index.ts | 1 + src/types-legacy.ts | 3 + 6 files changed, 335 insertions(+), 6 deletions(-) diff --git a/schema/core.xsd b/schema/core.xsd index 191c9a9dd..24952e720 100644 --- a/schema/core.xsd +++ b/schema/core.xsd @@ -1238,6 +1238,7 @@ + diff --git a/src/core/components/hv-route/index.tsx b/src/core/components/hv-route/index.tsx index 6b4962bb0..1ad931ea5 100644 --- a/src/core/components/hv-route/index.tsx +++ b/src/core/components/hv-route/index.tsx @@ -99,9 +99,21 @@ class HvRouteInner extends PureComponent { const url: string = this.getUrl(); const { doc } = await this.parser.loadDocument(url); - this.setState({ - doc, - error: undefined, + + // Set the state with the merged document + this.setState(state => { + const merged = NavigatorService.mergeDocument(doc, state.doc); + const root = Helpers.getFirstTag(merged, TypesLegacy.LOCAL_NAME.DOC); + if (!root) { + return { + doc: undefined, + error: new NavigatorService.HvRouteError('No root element found'), + }; + } + return { + doc: merged, + error: undefined, + }; }); } catch (err: unknown) { if (this.props.onError) { diff --git a/src/services/navigator/helpers.test.ts b/src/services/navigator/helpers.test.ts index 0e59aac03..e4042abf1 100644 --- a/src/services/navigator/helpers.test.ts +++ b/src/services/navigator/helpers.test.ts @@ -16,6 +16,7 @@ import { getUrlFromHref, isNavigationElement, isUrlFragment, + mergeDocument, validateUrl, } from './helpers'; import { DOMParser } from '@instawork/xmldom'; @@ -47,6 +48,67 @@ const screenDocSource = */ const blankDoc = ''; +/** + * Test merge original document + */ +const mergeOriginalDoc = ` + + + + + + + + + + + +`; + +/** + * Test merge document with merging disabled + * Expect this document to replace original + */ +const mergeSourceDisabledDoc = ` + + + + + + + + + + + + + + + +`; + +/** + * Test merge document with merging enabled + * Expect the merge to contain a merged document + */ +const mergeSourceEnabledDoc = ` + + + + + + + + + + + + + + + +`; + /** * Parser used to parse the document */ @@ -542,3 +604,115 @@ describe('buildRequest', () => { // - invalid path // - success }); + +describe('mergeDocuments', () => { + const originalDoc = parser.parseFromString(mergeOriginalDoc); + const origNavigators = originalDoc.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + 'navigator', + ); + const [origTabNavigator] = origNavigators.filter( + n => n.getAttribute('id') === 'tabs-navigator', + ); + + it('should contain a navigator called tabs-navigator', () => { + expect(origTabNavigator).toBeDefined(); + }); + + const origTabRoutes = getChildElements(origTabNavigator); + + it('should find 3 route elements on tab-navigator', () => { + expect(origTabRoutes.length).toEqual(3); + }); + + it('should not contain a sub navigator for shifts-route', () => { + const [origShiftRoute] = origTabRoutes.filter( + n => n.getAttribute('id') === 'shifts-route', + ); + expect(origShiftRoute.childNodes?.length).toEqual(0); + }); + + describe('merge documents with merge="false"', () => { + // With merging disabled, the merge source should replace the original + const mergeDoc = parser.parseFromString(mergeSourceDisabledDoc); + const outputDoc = mergeDocument(mergeDoc, originalDoc); + it('should merge successfully', () => { + expect(outputDoc).toBeDefined(); + }); + + const mergedNavigators = outputDoc.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + 'navigator', + ); + const [mergedTabNavigator] = mergedNavigators.filter( + n => n.getAttribute('id') === 'tabs-navigator', + ); + const mergedTabRoutes = getChildElements(mergedTabNavigator); + it('should find 2 route elements on tabs-navigator', () => { + expect(mergedTabRoutes.length).toEqual(2); + }); + + const [mergedshiftRoute] = mergedTabRoutes.filter( + n => n.getAttribute('id') === 'shifts-route', + ); + + const shiftNavigators = mergedshiftRoute.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + 'navigator', + ); + + it('should have one navigator under shifts-route', () => { + expect(shiftNavigators.length).toEqual(1); + }); + + const shiftNavRoutes = shiftNavigators[0].getElementsByTagNameNS( + Namespaces.HYPERVIEW, + 'nav-route', + ); + it('should find 2 route elements under shifts-navigator', () => { + expect(shiftNavRoutes.length).toEqual(2); + }); + }); + + describe('merge documents with merge="true"', () => { + // With merging enabled, the docs should be merged + const mergeDoc = parser.parseFromString(mergeSourceEnabledDoc); + const outputDoc = mergeDocument(mergeDoc, originalDoc); + it('should merge successfully', () => { + expect(outputDoc).toBeDefined(); + }); + + const mergedNavigators = outputDoc.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + 'navigator', + ); + const [mergedTabNavigator] = mergedNavigators.filter( + n => n.getAttribute('id') === 'tabs-navigator', + ); + const mergedTabRoutes = getChildElements(mergedTabNavigator); + it('should find 3 route elements on tabs-navigator', () => { + expect(mergedTabRoutes.length).toEqual(3); + }); + + const [mergedshiftRoute] = mergedTabRoutes.filter( + n => n.getAttribute('id') === 'shifts-route', + ); + + const shiftNavigators = mergedshiftRoute.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + 'navigator', + ); + + it('should have one navigator under shifts-route', () => { + expect(shiftNavigators.length).toEqual(1); + }); + + const shiftNavRoutes = shiftNavigators[0].getElementsByTagNameNS( + Namespaces.HYPERVIEW, + 'nav-route', + ); + it('should find 2 route elements under shifts-navigator', () => { + expect(shiftNavRoutes.length).toEqual(2); + }); + }); +}); diff --git a/src/services/navigator/helpers.ts b/src/services/navigator/helpers.ts index 33829f736..797833548 100644 --- a/src/services/navigator/helpers.ts +++ b/src/services/navigator/helpers.ts @@ -7,11 +7,20 @@ */ import * as Errors from './errors'; +import * as Helpers from 'hyperview/src/services/dom/helpers-legacy'; +import * as Namespaces from 'hyperview/src/services/namespaces'; import * as Types from './types'; import * as TypesLegacy from 'hyperview/src/types-legacy'; import * as UrlService from 'hyperview/src/services/url'; import { ANCHOR_ID_SEPARATOR } from './types'; +/** + * Type defining a map of + */ +type RouteMap = { + [key: string]: TypesLegacy.Element; +}; + /** * Get an array of all child elements of a node */ @@ -30,8 +39,9 @@ export const getChildElements = ( */ export const isNavigationElement = (element: TypesLegacy.Element): boolean => { return ( - element.localName === TypesLegacy.LOCAL_NAME.NAVIGATOR || - element.localName === TypesLegacy.LOCAL_NAME.NAV_ROUTE + element.namespaceURI === Namespaces.HYPERVIEW && + (element.localName === TypesLegacy.LOCAL_NAME.NAVIGATOR || + element.localName === TypesLegacy.LOCAL_NAME.NAV_ROUTE) ); }; @@ -50,7 +60,7 @@ export const getSelectedNavRouteElement = ( } const selectedChild = elements.find( - child => child.getAttribute('selected')?.toLowerCase() === 'true', + child => child.getAttribute('selected') === 'true', ); return selectedChild || elements[0]; @@ -301,3 +311,131 @@ export const buildRequest = ( return [navigation, lastPathId || routeId, params]; }; + +/** + * Create a map of from a list of nodes + */ +const nodesToMap = ( + nodes: TypesLegacy.NodeList, +): RouteMap => { + const map: RouteMap = {}; + if (!nodes) { + return map; + } + Array.from(nodes).forEach(node => { + if (node.nodeType === TypesLegacy.NODE_TYPE.ELEMENT_NODE) { + const element = node as TypesLegacy.Element; + if (isNavigationElement(element)) { + const id = element.getAttribute('id'); + if (id) { + map[id] = element; + } + } + } + }); + return map; +}; + +const KEY_MERGE = 'merge'; +const KEY_SELECTED = 'selected'; + +/** + * Merge the nodes from the new document into the current + * All attributes in the current are reset (selected, merge) + * If an id is found in both docs, the current node is updated + * If an id is found only in the new doc, the node is added to the current + * the 'merge' attribute on a navigator determines if the children are merged or replaced + */ +const mergeNodes = ( + current: TypesLegacy.Element, + newNodes: TypesLegacy.NodeList, +): void => { + if (!current || !current.childNodes || !newNodes || newNodes.length === 0) { + return; + } + + // Clean out current node attributes for 'merge' and 'selected' + Array.from(current.childNodes).forEach(node => { + const element = node as TypesLegacy.Element; + if (isNavigationElement(element)) { + if (element.localName === TypesLegacy.LOCAL_NAME.NAVIGATOR) { + element.setAttribute(KEY_MERGE, 'false'); + } else if (element.localName === TypesLegacy.LOCAL_NAME.NAV_ROUTE) { + element.setAttribute(KEY_SELECTED, 'false'); + } + } + }); + + const currentMap: RouteMap = nodesToMap(current.childNodes); + + Array.from(newNodes).forEach(node => { + if (node.nodeType === TypesLegacy.NODE_TYPE.ELEMENT_NODE) { + const newElement = node as TypesLegacy.Element; + if (isNavigationElement(newElement)) { + const id = newElement.getAttribute('id'); + if (id) { + const currentElement = currentMap[id] as TypesLegacy.Element; + if (currentElement) { + if (newElement.localName === TypesLegacy.LOCAL_NAME.NAVIGATOR) { + const isMergeable = newElement.getAttribute('merge') === 'true'; + if (isMergeable) { + currentElement.setAttribute(KEY_MERGE, 'true'); + mergeNodes(currentElement, newElement.childNodes); + } else { + current.replaceChild(newElement, currentElement); + } + } else if ( + newElement.localName === TypesLegacy.LOCAL_NAME.NAV_ROUTE + ) { + // Update the selected route + currentElement.setAttribute( + KEY_SELECTED, + newElement.getAttribute(KEY_SELECTED) || 'false', + ); + mergeNodes(currentElement, newElement.childNodes); + } + } else { + // Add new element + current.appendChild(newElement); + } + } + } + } + }); +}; + +/** + * Merge the new document into the current document + * Creates a clone to force a re-render + */ +export const mergeDocument = ( + newDoc: TypesLegacy.Document, + currentDoc?: TypesLegacy.Document, +): TypesLegacy.Document => { + if (!currentDoc) { + return newDoc; + } + if (!newDoc || !newDoc.childNodes) { + return currentDoc; + } + + // Create a clone of the current document + const composite = currentDoc.cloneNode(true); + const currentRoot = Helpers.getFirstTag( + composite, + TypesLegacy.LOCAL_NAME.DOC, + ); + + if (!currentRoot) { + throw new Errors.HvRouteError('No root element found in current document'); + } + + // Get the + const newRoot = Helpers.getFirstTag(newDoc, TypesLegacy.LOCAL_NAME.DOC); + if (!newRoot) { + throw new Errors.HvRouteError('No root element found in new document'); + } + + mergeNodes(currentRoot, newRoot.childNodes); + return composite; +}; diff --git a/src/services/navigator/index.ts b/src/services/navigator/index.ts index 97039e478..bcefc0ef7 100644 --- a/src/services/navigator/index.ts +++ b/src/services/navigator/index.ts @@ -128,5 +128,6 @@ export { getChildElements, getSelectedNavRouteElement, getUrlFromHref, + mergeDocument, } from './helpers'; export { ID_DYNAMIC, ID_MODAL, NAVIGATOR_TYPE } from './types'; diff --git a/src/types-legacy.ts b/src/types-legacy.ts index 4edbbd86a..e1280341b 100644 --- a/src/types-legacy.ts +++ b/src/types-legacy.ts @@ -93,7 +93,9 @@ export type Node = { readonly namespaceURI: NamespaceURI | null | undefined; readonly nextSibling: Node | null | undefined; readonly nodeType: NodeType; + appendChild: (newChild: Node) => Node; hasChildNodes: () => boolean; + replaceChild: (newChild: Node, oldChild: Node) => Node; }; /** @@ -110,6 +112,7 @@ export type Element = Node & { namespaceURI: NamespaceURI, localName: LocalName, ) => NodeList; + setAttribute: (name: DOMString, value: DOMString) => void; }; /** From ecc28d8e00c1dac2975733674a9c31bab7b01b3f Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Thu, 3 Aug 2023 16:32:41 -0400 Subject: [PATCH 04/29] chore (navigation) - Cleanup of navigator helpers (#622) - Moving the route search into helpers - Moving the local types into types.ts --- src/core/components/hv-route/index.tsx | 10 +----- src/services/navigator/helpers.ts | 44 +++++++++++++++----------- src/services/navigator/index.ts | 1 + src/services/navigator/types.ts | 9 ++++++ 4 files changed, 37 insertions(+), 27 deletions(-) diff --git a/src/core/components/hv-route/index.tsx b/src/core/components/hv-route/index.tsx index 1ad931ea5..5cf6f8019 100644 --- a/src/core/components/hv-route/index.tsx +++ b/src/core/components/hv-route/index.tsx @@ -386,15 +386,7 @@ const getNestedNavigator = ( return undefined; } - const routes = doc - .getElementsByTagNameNS( - Namespaces.HYPERVIEW, - TypesLegacy.LOCAL_NAME.NAV_ROUTE, - ) - .filter((n: TypesLegacy.Element) => { - return n.getAttribute('id') === id; - }); - const route = routes && routes.length > 0 ? routes[0] : undefined; + const route = NavigatorService.getRouteById(doc, id); if (route) { return ( Helpers.getFirstChildTag( diff --git a/src/services/navigator/helpers.ts b/src/services/navigator/helpers.ts index 797833548..51124f504 100644 --- a/src/services/navigator/helpers.ts +++ b/src/services/navigator/helpers.ts @@ -14,13 +14,6 @@ import * as TypesLegacy from 'hyperview/src/types-legacy'; import * as UrlService from 'hyperview/src/services/url'; import { ANCHOR_ID_SEPARATOR } from './types'; -/** - * Type defining a map of - */ -type RouteMap = { - [key: string]: TypesLegacy.Element; -}; - /** * Get an array of all child elements of a node */ @@ -230,6 +223,24 @@ export const getRouteId = ( return Types.ID_DYNAMIC; }; +/** + * Search for a route with the given id + */ +export const getRouteById = ( + doc: TypesLegacy.Document, + id: string, +): TypesLegacy.Element | undefined => { + const routes = doc + .getElementsByTagNameNS( + Namespaces.HYPERVIEW, + TypesLegacy.LOCAL_NAME.NAV_ROUTE, + ) + .filter((n: TypesLegacy.Element) => { + return n.getAttribute('id') === id; + }); + return routes && routes.length > 0 ? routes[0] : undefined; +}; + /** * Determine the action to perform based on the route params * Correct for a push action being introduced in @@ -317,8 +328,8 @@ export const buildRequest = ( */ const nodesToMap = ( nodes: TypesLegacy.NodeList, -): RouteMap => { - const map: RouteMap = {}; +): Types.RouteMap => { + const map: Types.RouteMap = {}; if (!nodes) { return map; } @@ -336,9 +347,6 @@ const nodesToMap = ( return map; }; -const KEY_MERGE = 'merge'; -const KEY_SELECTED = 'selected'; - /** * Merge the nodes from the new document into the current * All attributes in the current are reset (selected, merge) @@ -359,14 +367,14 @@ const mergeNodes = ( const element = node as TypesLegacy.Element; if (isNavigationElement(element)) { if (element.localName === TypesLegacy.LOCAL_NAME.NAVIGATOR) { - element.setAttribute(KEY_MERGE, 'false'); + element.setAttribute(Types.KEY_MERGE, 'false'); } else if (element.localName === TypesLegacy.LOCAL_NAME.NAV_ROUTE) { - element.setAttribute(KEY_SELECTED, 'false'); + element.setAttribute(Types.KEY_SELECTED, 'false'); } } }); - const currentMap: RouteMap = nodesToMap(current.childNodes); + const currentMap: Types.RouteMap = nodesToMap(current.childNodes); Array.from(newNodes).forEach(node => { if (node.nodeType === TypesLegacy.NODE_TYPE.ELEMENT_NODE) { @@ -379,7 +387,7 @@ const mergeNodes = ( if (newElement.localName === TypesLegacy.LOCAL_NAME.NAVIGATOR) { const isMergeable = newElement.getAttribute('merge') === 'true'; if (isMergeable) { - currentElement.setAttribute(KEY_MERGE, 'true'); + currentElement.setAttribute(Types.KEY_MERGE, 'true'); mergeNodes(currentElement, newElement.childNodes); } else { current.replaceChild(newElement, currentElement); @@ -389,8 +397,8 @@ const mergeNodes = ( ) { // Update the selected route currentElement.setAttribute( - KEY_SELECTED, - newElement.getAttribute(KEY_SELECTED) || 'false', + Types.KEY_SELECTED, + newElement.getAttribute(Types.KEY_SELECTED) || 'false', ); mergeNodes(currentElement, newElement.childNodes); } diff --git a/src/services/navigator/index.ts b/src/services/navigator/index.ts index bcefc0ef7..5eedd0fa3 100644 --- a/src/services/navigator/index.ts +++ b/src/services/navigator/index.ts @@ -126,6 +126,7 @@ export { isUrlFragment, cleanHrefFragment, getChildElements, + getRouteById, getSelectedNavRouteElement, getUrlFromHref, mergeDocument, diff --git a/src/services/navigator/types.ts b/src/services/navigator/types.ts index 668ebb094..4af192fbd 100644 --- a/src/services/navigator/types.ts +++ b/src/services/navigator/types.ts @@ -11,6 +11,8 @@ import * as TypesLegacy from 'hyperview/src/types-legacy'; export const ANCHOR_ID_SEPARATOR = '#'; export const ID_DYNAMIC = 'dynamic'; export const ID_MODAL = 'modal'; +export const KEY_MERGE = 'merge'; +export const KEY_SELECTED = 'selected'; /** * Definition of the available navigator types @@ -64,3 +66,10 @@ export type NavigationState = { type: string; history?: unknown[]; }; + +/** + * Type defining a map of + */ +export type RouteMap = { + [key: string]: TypesLegacy.Element; +}; From ee258e762e8e2f8c7e095dd7fd06f7b975682410 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Fri, 4 Aug 2023 16:58:28 -0400 Subject: [PATCH 05/29] feature(selection) - Track the current selection state of each nav-route (#623) Reflect the user's selections in the document - Use the 'focus' listener to allow each hv-route to set its selected state within the document - De-select any sibling routes to ensure the current is the only selected Note: I did not use 'blur' to have each route deselect itself as that would cause routes in the background to lose their state. In the video, as the user selects between the different tabs, the various \ children of `tabs-navigator` are shown to reflect current selection. When the `company` modal is popped up, the `company` \ shows selected while its sibling `tabs-route` becomes deselected. Note that the `tabs-navigator` children retain their correct selection state. Asana: https://app.asana.com/0/0/1205197138955014/f https://github.com/Instawork/hyperview/assets/127122858/0687558f-c500-419a-bd69-bbd0843ade72 --- src/core/components/hv-route/index.tsx | 11 ++++++++++ src/services/navigator/helpers.ts | 28 ++++++++++++++++++++++++++ src/services/navigator/index.ts | 1 + src/services/navigator/types.ts | 1 + src/types-legacy.ts | 1 + 5 files changed, 42 insertions(+) diff --git a/src/core/components/hv-route/index.tsx b/src/core/components/hv-route/index.tsx index 5cf6f8019..58ad2fa90 100644 --- a/src/core/components/hv-route/index.tsx +++ b/src/core/components/hv-route/index.tsx @@ -430,6 +430,17 @@ export default function HvRoute(props: Types.Props) { routeDocContext, ); + React.useEffect(() => { + if (props.navigation) { + const unsubscribe = props.navigation.addListener('focus', () => { + NavigatorService.setSelected(routeDocContext, props.route?.params?.id); + }); + + return unsubscribe; + } + return undefined; + }, [props.navigation, props.route?.params?.id, routeDocContext]); + return ( { + if (!routeDocContext || !id) { + return; + } + const route = getRouteById(routeDocContext, id); + if (route) { + // Reset all siblings + if (route.parentNode && route.parentNode.childNodes) { + Array.from(route.parentNode.childNodes).forEach( + (sibling: TypesLegacy.Node) => { + if (sibling.localName === TypesLegacy.LOCAL_NAME.NAV_ROUTE) { + (sibling as TypesLegacy.Element)?.setAttribute( + Types.KEY_SELECTED, + 'false', + ); + } + }, + ); + } + + // Set the selected route + route.setAttribute(Types.KEY_SELECTED, 'true'); + } +}; diff --git a/src/services/navigator/index.ts b/src/services/navigator/index.ts index 5eedd0fa3..5ba9f7d7b 100644 --- a/src/services/navigator/index.ts +++ b/src/services/navigator/index.ts @@ -130,5 +130,6 @@ export { getSelectedNavRouteElement, getUrlFromHref, mergeDocument, + setSelected, } from './helpers'; export { ID_DYNAMIC, ID_MODAL, NAVIGATOR_TYPE } from './types'; diff --git a/src/services/navigator/types.ts b/src/services/navigator/types.ts index 4af192fbd..3817a8281 100644 --- a/src/services/navigator/types.ts +++ b/src/services/navigator/types.ts @@ -39,6 +39,7 @@ export type NavigationProp = { goBack: () => void; getState: () => NavigationState; getParent: (id?: string) => NavigationProp | undefined; + addListener: (event: string, callback: () => void) => void; }; /** diff --git a/src/types-legacy.ts b/src/types-legacy.ts index e1280341b..b28cc07b2 100644 --- a/src/types-legacy.ts +++ b/src/types-legacy.ts @@ -93,6 +93,7 @@ export type Node = { readonly namespaceURI: NamespaceURI | null | undefined; readonly nextSibling: Node | null | undefined; readonly nodeType: NodeType; + parentNode: Node | null | undefined; appendChild: (newChild: Node) => Node; hasChildNodes: () => boolean; replaceChild: (newChild: Node, oldChild: Node) => Node; From 4ea3a44480fa4b57dbe3dbd6149baca5b9fc1b07 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Fri, 4 Aug 2023 17:15:06 -0400 Subject: [PATCH 06/29] chore(nav-routes) - Update to support adding all defined routes to navigators (#624) Previous implementation used 'dynamic' and 'modal' routes as the only two children of `stack` navigators. The urls were cached into a context and retrieved by id. This update allows each defined `\` to be added to its navigator. These known routes can retain their original url and presentation and reduce the complexity of navigating between them. One note about the implementation: the 'initialParams' passed into each route contain their actual url. When we navigate to a route through a behavior, the new params are merged with the existing. The url had to explicitly be deleted from the object as passing a url:undefined would overwrite the initial url value with an 'undefined' value. Asana: https://app.asana.com/0/0/1205197138955012/f --- schema/core.xsd | 1 + src/contexts/navigator-map.tsx | 15 ----- src/core/components/hv-navigator/index.tsx | 45 ++++++--------- src/core/components/hv-route/index.tsx | 24 ++------ src/core/components/hv-route/types.ts | 2 - src/services/navigator/helpers.test.ts | 65 +++++++++++++++------- src/services/navigator/helpers.ts | 43 +++++++------- src/services/navigator/index.ts | 2 +- src/services/navigator/types.ts | 1 + 9 files changed, 90 insertions(+), 108 deletions(-) diff --git a/schema/core.xsd b/schema/core.xsd index 24952e720..922cae7f6 100644 --- a/schema/core.xsd +++ b/schema/core.xsd @@ -1250,6 +1250,7 @@ + diff --git a/src/contexts/navigator-map.tsx b/src/contexts/navigator-map.tsx index b63e2c487..90f1404cb 100644 --- a/src/contexts/navigator-map.tsx +++ b/src/contexts/navigator-map.tsx @@ -10,8 +10,6 @@ import * as TypesLegacy from 'hyperview/src/types-legacy'; import React, { createContext, useState } from 'react'; export type NavigatorMapContextProps = { - setRoute: (key: string, route: string) => void; - getRoute: (key: string) => string | undefined; setPreload: (key: number, element: TypesLegacy.Element) => void; getPreload: (key: number) => TypesLegacy.Element | undefined; }; @@ -25,9 +23,7 @@ export type NavigatorMapContextProps = { */ export const NavigatorMapContext = createContext({ getPreload: () => undefined, - getRoute: () => '', setPreload: () => undefined, - setRoute: () => undefined, }); type Props = { children: React.ReactNode }; @@ -37,17 +33,8 @@ type Props = { children: React.ReactNode }; * store runtime information about the navigator and urls. */ export function NavigatorMapProvider(props: Props) { - const [routeMap] = useState>(new Map()); const [preloadMap] = useState>(new Map()); - const setRoute = (key: string, route: string) => { - routeMap.set(key, route); - }; - - const getRoute = (key: string): string | undefined => { - return routeMap.get(key); - }; - const setPreload = (key: number, element: TypesLegacy.Element) => { preloadMap.set(key, element); }; @@ -60,9 +47,7 @@ export function NavigatorMapProvider(props: Props) { {props.children} diff --git a/src/core/components/hv-navigator/index.tsx b/src/core/components/hv-navigator/index.tsx index 626e16879..73fd691b2 100644 --- a/src/core/components/hv-navigator/index.tsx +++ b/src/core/components/hv-navigator/index.tsx @@ -29,13 +29,16 @@ export default class HvNavigator extends PureComponent { buildScreen = ( id: string, type: TypesLegacy.DOMString, + href: TypesLegacy.DOMString | undefined, + isModal: boolean, ): React.ReactElement => { + const initialParams = { id, url: href }; if (type === NavigatorService.NAVIGATOR_TYPE.TAB) { return ( ); @@ -45,8 +48,9 @@ export default class HvNavigator extends PureComponent { ); } @@ -93,6 +97,12 @@ export default class HvNavigator extends PureComponent { `No id provided for ${navRoute.localName}`, ); } + const href: + | TypesLegacy.DOMString + | null + | undefined = navRoute.getAttribute('href'); + const isModal = + navRoute.getAttribute(NavigatorService.KEY_MODAL) === 'true'; // Check for nested navigators const nestedNavigator: TypesLegacy.Element | null = getFirstChildTag( @@ -100,25 +110,12 @@ export default class HvNavigator extends PureComponent { TypesLegacy.LOCAL_NAME.NAVIGATOR, ); - if (!nestedNavigator) { - const href: - | TypesLegacy.DOMString - | null - | undefined = navRoute.getAttribute('href'); - if (!href) { - throw new NavigatorService.HvNavigatorError( - `No href provided for route '${id}'`, - ); - } - const url = NavigatorService.getUrlFromHref( - href, - navigationContext?.entrypointUrl, + if (!nestedNavigator && !href) { + throw new NavigatorService.HvNavigatorError( + `No href provided for route '${id}'`, ); - - // Cache the url for the route by nav-route id - navigatorMapContext.setRoute(id, url); } - screens.push(buildScreen(id, type)); + screens.push(buildScreen(id, type, href || undefined, isModal)); } }); @@ -181,15 +178,10 @@ export default class HvNavigator extends PureComponent { const selected: | TypesLegacy.Element | undefined = NavigatorService.getSelectedNavRouteElement(props.element); - if (!selected) { - throw new NavigatorService.HvNavigatorError( - `No selected route defined for '${id}'`, - ); - } const selectedId: string | undefined = selected - .getAttribute('id') - ?.toString(); + ? selected.getAttribute('id')?.toString() + : undefined; const { buildScreens } = this; switch (type) { @@ -197,7 +189,6 @@ export default class HvNavigator extends PureComponent { return ( ({ header: undefined, headerMode: 'screen', diff --git a/src/core/components/hv-route/index.tsx b/src/core/components/hv-route/index.tsx index 58ad2fa90..4d1ec9166 100644 --- a/src/core/components/hv-route/index.tsx +++ b/src/core/components/hv-route/index.tsx @@ -348,31 +348,15 @@ class HvRouteInner extends PureComponent { const getRouteUrl = ( props: Types.Props, navigationContext: Types.NavigationContextProps, - navigatorMapContext: Types.NavigatorMapContextProps, ) => { // The initial hv-route element will use the entrypoint url if (props.navigation === undefined) { return navigationContext.entrypointUrl; } - // Use the passed url - if (props.route?.params?.url) { - if (NavigatorService.isUrlFragment(props.route?.params?.url)) { - // Look up the url from the route map where it would have been - // stored from the initial definition - return navigatorMapContext.getRoute( - NavigatorService.cleanHrefFragment(props.route?.params?.url), - ); - } - return props.route?.params?.url; - } - - // Look up by route id - if (props.route?.params?.id) { - return navigatorMapContext.getRoute(props.route?.params?.id); - } - - return undefined; + return props.route?.params?.url + ? NavigatorService.cleanHrefFragment(props.route?.params?.url) + : undefined; }; /** @@ -422,7 +406,7 @@ export default function HvRoute(props: Types.Props) { RouteDocContext.Context, ); - const url = getRouteUrl(props, navigationContext, navigatorMapContext); + const url = getRouteUrl(props, navigationContext); // Get the navigator element from the context const element: TypesLegacy.Element | undefined = getNestedNavigator( diff --git a/src/core/components/hv-route/types.ts b/src/core/components/hv-route/types.ts index f3de47c31..ffae6326d 100644 --- a/src/core/components/hv-route/types.ts +++ b/src/core/components/hv-route/types.ts @@ -39,7 +39,6 @@ export type NavigationContextProps = { }; export type NavigatorMapContextProps = { - getRoute: (key: string) => string | undefined; setPreload: (key: number, element: TypesLegacy.Element) => void; getPreload: (key: number) => TypesLegacy.Element | undefined; }; @@ -75,7 +74,6 @@ export type InnerRouteProps = { errorScreen?: ComponentType; loadingScreen?: ComponentType; handleBack?: ComponentType<{ children: ReactNode }>; - getRoute: (key: string) => string | undefined; setPreload: (key: number, element: TypesLegacy.Element) => void; getPreload: (key: number) => TypesLegacy.Element | undefined; element?: TypesLegacy.Element; diff --git a/src/services/navigator/helpers.test.ts b/src/services/navigator/helpers.test.ts index e4042abf1..73c2558e7 100644 --- a/src/services/navigator/helpers.test.ts +++ b/src/services/navigator/helpers.test.ts @@ -207,14 +207,14 @@ describe('getSelectedNavRouteElement', () => { const selected = getSelectedNavRouteElement(navigators[0]); expect(selected?.getAttribute('id')).toEqual('route2'); }); - it('should find route1 as selected', () => { + it('should find nothing as selected', () => { const doc = parser.parseFromString(navDocSourceAlt); const navigators = doc.getElementsByTagNameNS( Namespaces.HYPERVIEW, 'navigator', ); const selected = getSelectedNavRouteElement(navigators[0]); - expect(selected?.getAttribute('id')).toEqual('route1'); + expect(selected).toBeUndefined(); }); it('should not find an selected route', () => { const doc = parser.parseFromString( @@ -451,18 +451,18 @@ describe('buildParams', () => { }); describe('getRouteId', () => { - const urls = ['url', '/url', '#url', '', '#', undefined]; - describe('simple', () => { + describe('fragment', () => { + const urls = ['#url', '#']; describe('action:push', () => { urls.forEach(url => { - it(`should return type 'dynamic' from url with static: ${url}`, () => { - expect(getRouteId(TypesLegacy.NAV_ACTIONS.PUSH, url, true)).toEqual( + it(`should not return route 'dynamic' from url with fragment: ${url}`, () => { + expect(getRouteId(TypesLegacy.NAV_ACTIONS.PUSH, url)).not.toEqual( ID_DYNAMIC, ); }); - it(`should return type 'dynamic' from url with non-static: ${url}`, () => { - expect(getRouteId(TypesLegacy.NAV_ACTIONS.PUSH, url, false)).toEqual( - ID_DYNAMIC, + it(`should return route id with fragment: ${url}`, () => { + expect(getRouteId(TypesLegacy.NAV_ACTIONS.PUSH, url)).toEqual( + cleanHrefFragment(url), ); }); }); @@ -470,13 +470,36 @@ describe('getRouteId', () => { describe('action:new', () => { urls.forEach(url => { - it(`should return type 'modal' from url with static: ${url}`, () => { - expect(getRouteId(TypesLegacy.NAV_ACTIONS.NEW, url, true)).toEqual( + it(`should not return type 'modal' from url with fragment: ${url}`, () => { + expect(getRouteId(TypesLegacy.NAV_ACTIONS.NEW, url)).not.toEqual( ID_MODAL, ); }); - it(`should return type 'modal' from url with non-static: ${url}`, () => { - expect(getRouteId(TypesLegacy.NAV_ACTIONS.NEW, url, false)).toEqual( + it(`should return route id with fragment: ${url}`, () => { + expect(getRouteId(TypesLegacy.NAV_ACTIONS.NEW, url)).toEqual( + cleanHrefFragment(url), + ); + }); + }); + }); + }); + + describe('non-fragment', () => { + const urls = ['url', '/url', '', undefined]; + describe('action:push', () => { + urls.forEach(url => { + it(`should return type 'dynamic' from url with non-fragment: ${url}`, () => { + expect(getRouteId(TypesLegacy.NAV_ACTIONS.PUSH, url)).toEqual( + ID_DYNAMIC, + ); + }); + }); + }); + + describe('action:new', () => { + urls.forEach(url => { + it(`should return type 'modal' from url with non-fragment: ${url}`, () => { + expect(getRouteId(TypesLegacy.NAV_ACTIONS.NEW, url)).toEqual( ID_MODAL, ); }); @@ -492,19 +515,19 @@ describe('getRouteId', () => { ].forEach(action => { describe(`action:${action}`, () => { ['#url', '#'].forEach(url => { - it(`should return cleaned url with static: ${url}`, () => { - expect(getRouteId(action, url, true)).toEqual(url.slice(1)); + it(`should return cleaned url with fragment: ${url}`, () => { + expect(getRouteId(action, url)).toEqual(url.slice(1)); }); - it(`should return type 'dynamic' from url with non-static: ${url}`, () => { - expect(getRouteId(action, url, false)).toEqual(ID_DYNAMIC); + it(`should not return type 'dynamic' from url with fragment: ${url}`, () => { + expect(getRouteId(action, url)).not.toEqual(ID_DYNAMIC); }); }); ['url', '/url', '', undefined].forEach(url => { - it(`should return cleaned url with static: ${url}`, () => { - expect(getRouteId(action, url, true)).toEqual(ID_DYNAMIC); + it(`should return cleaned url with non-fragment: ${url}`, () => { + expect(getRouteId(action, url)).toEqual(ID_DYNAMIC); }); - it(`should return type 'dynamic' from url with non-static: ${url}`, () => { - expect(getRouteId(action, url, false)).toEqual(ID_DYNAMIC); + it(`should return type 'dynamic' from url with non-fragment: ${url}`, () => { + expect(getRouteId(action, url)).toEqual(ID_DYNAMIC); }); }); }); diff --git a/src/services/navigator/helpers.ts b/src/services/navigator/helpers.ts index a8b65c1b4..ef8b88d3b 100644 --- a/src/services/navigator/helpers.ts +++ b/src/services/navigator/helpers.ts @@ -39,7 +39,7 @@ export const isNavigationElement = (element: TypesLegacy.Element): boolean => { }; /** - * Get the route designated as 'selected' or the first route if none is marked + * Get the route designated as 'selected' */ export const getSelectedNavRouteElement = ( element: TypesLegacy.Element, @@ -56,7 +56,7 @@ export const getSelectedNavRouteElement = ( child => child.getAttribute('selected') === 'true', ); - return selectedChild || elements[0]; + return selectedChild; }; /** @@ -202,25 +202,19 @@ export const buildParams = ( }; /** - * Use the dynamic or modal route for dynamic actions - * isStatic represents a route which is already in the navigation state + * Use the dynamic or modal route for dynamic actions, otherwise use the given id */ export const getRouteId = ( action: TypesLegacy.NavAction, url: string | undefined, - isStatic: boolean, ): string => { - if (action === TypesLegacy.NAV_ACTIONS.PUSH) { - return Types.ID_DYNAMIC; - } - if (action === TypesLegacy.NAV_ACTIONS.NEW) { - return Types.ID_MODAL; - } - - if (url && isUrlFragment(url) && isStatic) { + if (url && isUrlFragment(url)) { return cleanHrefFragment(url); } - return Types.ID_DYNAMIC; + + return action === TypesLegacy.NAV_ACTIONS.NEW + ? Types.ID_MODAL + : Types.ID_DYNAMIC; }; /** @@ -291,18 +285,23 @@ export const buildRequest = ( validateUrl(action, routeParams); const [navigation, path] = getNavigatorAndPath(routeParams.targetId, nav); + + const cleanedParams: TypesLegacy.NavigationRouteParams = { ...routeParams }; + if (cleanedParams.url && isUrlFragment(cleanedParams.url)) { + // When a fragment is used, the original url is used for the route + // setting url to undefined will overwrite the value, so the url has to be + // deleted to allow merging the params while retaining the original url + delete cleanedParams.url; + } + if (!navigation) { - return [undefined, '', routeParams]; + return [undefined, '', cleanedParams]; } - // Static routes are those found in the current state. Tab navigators are always static. - const isStatic: boolean = - (path !== undefined && path.length > 0) || - navigation.getState().type !== Types.NAVIGATOR_TYPE.STACK; - const routeId = getRouteId(action, routeParams.url, isStatic); + const routeId = getRouteId(action, routeParams.url); if (!path || !path.length) { - return [navigation, routeId, routeParams]; + return [navigation, routeId, cleanedParams]; } // The first path id the screen which will receive the initial request @@ -317,7 +316,7 @@ export const buildRequest = ( | TypesLegacy.NavigationRouteParams = buildParams( routeId, path, - routeParams, + cleanedParams, ); return [navigation, lastPathId || routeId, params]; diff --git a/src/services/navigator/index.ts b/src/services/navigator/index.ts index 5ba9f7d7b..d51a2cde3 100644 --- a/src/services/navigator/index.ts +++ b/src/services/navigator/index.ts @@ -132,4 +132,4 @@ export { mergeDocument, setSelected, } from './helpers'; -export { ID_DYNAMIC, ID_MODAL, NAVIGATOR_TYPE } from './types'; +export { ID_DYNAMIC, ID_MODAL, KEY_MODAL, NAVIGATOR_TYPE } from './types'; diff --git a/src/services/navigator/types.ts b/src/services/navigator/types.ts index 3817a8281..4687fd553 100644 --- a/src/services/navigator/types.ts +++ b/src/services/navigator/types.ts @@ -13,6 +13,7 @@ export const ID_DYNAMIC = 'dynamic'; export const ID_MODAL = 'modal'; export const KEY_MERGE = 'merge'; export const KEY_SELECTED = 'selected'; +export const KEY_MODAL = 'modal'; /** * Definition of the available navigator types From 91748270056b4954a7fbf522d8aa1b1785565dd5 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Fri, 4 Aug 2023 18:11:23 -0400 Subject: [PATCH 07/29] fix(hvroute) - Prioritize the source of data for a route (#625) When an is initially defined to load its own data via url, it will store its in state and will pass it down to its children. When a deep link is processed a provides additional information for the route, the route must abandon its local state.doc and rely on the element passed in from its parent. Initial doc: ``` xmldoc ``` The `` representing `shifts-route` will have loaded its own `` and will have stored it in its state. Deep link: ``` xmldoc ``` The deep link has provided additional context for `shifts-route` by now providing a sub-navigator `shifts` and the relevant routes. The `tabs-navigator` remains unchanged; it still has one route `shifts-route`, which means the `` representing `shifts-route` is not unmounted. However, that route contains a `` in state which is now invalid. This code change allows the `` to prioritize data received from its parent over any doc it has in state. Asana: https://app.asana.com/0/0/1205197143810570/f --- src/core/components/hv-route/index.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/core/components/hv-route/index.tsx b/src/core/components/hv-route/index.tsx index 4d1ec9166..38a9c2501 100644 --- a/src/core/components/hv-route/index.tsx +++ b/src/core/components/hv-route/index.tsx @@ -231,6 +231,11 @@ class HvRouteInner extends PureComponent { }, }; + // If an element is passed, do not pass the doc + const doc = this.props.element + ? undefined + : this.state.doc?.cloneNode(true); + return ( {formatter => ( @@ -239,7 +244,7 @@ class HvRouteInner extends PureComponent { behaviors={this.props.behaviors} closeModal={this.navLogic.closeModal} components={this.props.components} - doc={this.state.doc?.cloneNode(true)} + doc={doc} elementErrorComponent={this.props.elementErrorComponent} entrypointUrl={this.props.entrypointUrl} errorScreen={this.props.errorScreen} @@ -279,8 +284,9 @@ class HvRouteInner extends PureComponent { } if (renderElement.localName === TypesLegacy.LOCAL_NAME.NAVIGATOR) { - if (this.state.doc) { + if (!this.props.element && this.state.doc) { // The provides doc access to nested navigators + // only pass it when the doc is available and is not being overridden by an element return ( From 7cfda684cc1e28a2609e298ee7a1f874f0282d62 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Tue, 8 Aug 2023 14:47:35 -0400 Subject: [PATCH 08/29] chore(modal) - Change iOS presentation to use fullscreen (#626) Using the `cardStyleInterpolator` option to force iOS to render modals at full screen while retaining their intended animation. | Default | Updated iOS | Updated Android | | ------ | ----- | ----- | | ![default](https://github.com/Instawork/hyperview/assets/127122858/d7a3d4c7-459b-453e-b454-37e1b56ec62f) | ![iOS](https://github.com/Instawork/hyperview/assets/127122858/239bad17-48fe-40e6-86d4-e4cb1bad4603) | ![android](https://github.com/Instawork/hyperview/assets/127122858/135dd21b-4725-4efa-94eb-5859ac983a94) | Asana: https://app.asana.com/0/0/1205197143810575/f --- src/core/components/hv-navigator/index.tsx | 13 +++++++++++-- src/services/navigator/imports.ts | 5 ++++- src/services/navigator/index.ts | 6 +++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/core/components/hv-navigator/index.tsx b/src/core/components/hv-navigator/index.tsx index 73fd691b2..a36ad5e35 100644 --- a/src/core/components/hv-navigator/index.tsx +++ b/src/core/components/hv-navigator/index.tsx @@ -50,7 +50,12 @@ export default class HvNavigator extends PureComponent { component={this.props.routeComponent} initialParams={initialParams} name={id} - options={{ presentation: isModal ? 'modal' : 'card' }} + options={{ + cardStyleInterpolator: isModal + ? NavigatorService.CardStyleInterpolators.forVerticalIOS + : undefined, + presentation: isModal ? 'modal' : 'card', + }} /> ); } @@ -142,7 +147,11 @@ export default class HvNavigator extends PureComponent { // empty object required because hv-screen doesn't check for undefined param initialParams={{}} name={NavigatorService.ID_MODAL} - options={{ presentation: 'modal' }} + options={{ + cardStyleInterpolator: + NavigatorService.CardStyleInterpolators.forVerticalIOS, + presentation: 'modal', + }} />, ); } diff --git a/src/services/navigator/imports.ts b/src/services/navigator/imports.ts index c31f18ce6..c9b88f991 100644 --- a/src/services/navigator/imports.ts +++ b/src/services/navigator/imports.ts @@ -11,5 +11,8 @@ */ export { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; -export { createStackNavigator } from '@react-navigation/stack'; +export { + CardStyleInterpolators, + createStackNavigator, +} from '@react-navigation/stack'; export { CommonActions, StackActions } from '@react-navigation/native'; diff --git a/src/services/navigator/index.ts b/src/services/navigator/index.ts index d51a2cde3..1aa236006 100644 --- a/src/services/navigator/index.ts +++ b/src/services/navigator/index.ts @@ -120,7 +120,11 @@ export class Navigator { } export type { NavigationProp, Route } from './types'; -export { createStackNavigator, createBottomTabNavigator } from './imports'; +export { + CardStyleInterpolators, + createStackNavigator, + createBottomTabNavigator, +} from './imports'; export { HvRouteError, HvNavigatorError, HvRenderError } from './errors'; export { isUrlFragment, From d8844d31d8f0cd4e4e4d299028f517f910afaab0 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Tue, 8 Aug 2023 14:47:51 -0400 Subject: [PATCH 09/29] fix(state) - override the state.doc (#632) Changed approach to use `getDerivedStateFromProps` to drive the state.doc value --- src/core/components/hv-route/index.tsx | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/core/components/hv-route/index.tsx b/src/core/components/hv-route/index.tsx index 38a9c2501..e3c6b7f1d 100644 --- a/src/core/components/hv-route/index.tsx +++ b/src/core/components/hv-route/index.tsx @@ -57,6 +57,19 @@ class HvRouteInner extends PureComponent { this.componentRegistry = Components.getRegistry(this.props.components); } + /** + * Override the state to clear the doc when an element is passed + */ + static getDerivedStateFromProps( + props: Types.InnerRouteProps, + state: Types.State, + ) { + if (props.element) { + return { ...state, doc: undefined }; + } + return state; + } + componentDidMount() { this.parser = new DomService.Parser( this.props.fetch, @@ -231,11 +244,6 @@ class HvRouteInner extends PureComponent { }, }; - // If an element is passed, do not pass the doc - const doc = this.props.element - ? undefined - : this.state.doc?.cloneNode(true); - return ( {formatter => ( @@ -244,7 +252,7 @@ class HvRouteInner extends PureComponent { behaviors={this.props.behaviors} closeModal={this.navLogic.closeModal} components={this.props.components} - doc={doc} + doc={this.state.doc?.cloneNode(true)} elementErrorComponent={this.props.elementErrorComponent} entrypointUrl={this.props.entrypointUrl} errorScreen={this.props.errorScreen} @@ -284,7 +292,7 @@ class HvRouteInner extends PureComponent { } if (renderElement.localName === TypesLegacy.LOCAL_NAME.NAVIGATOR) { - if (!this.props.element && this.state.doc) { + if (this.state.doc) { // The provides doc access to nested navigators // only pass it when the doc is available and is not being overridden by an element return ( From a0b27c00f8c412f1d6d9c6b88103e4971e7330c6 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Wed, 9 Aug 2023 09:59:40 -0400 Subject: [PATCH 10/29] chore(cleanup) - performance improvements (#637) - use the more performant `getFirstChildTag` since all these locations know their structure - simplified the usage of the internal component to not require its own props type --- src/core/components/hv-route/index.tsx | 31 +++++++++++--------------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/src/core/components/hv-route/index.tsx b/src/core/components/hv-route/index.tsx index e3c6b7f1d..1812d1911 100644 --- a/src/core/components/hv-route/index.tsx +++ b/src/core/components/hv-route/index.tsx @@ -20,13 +20,7 @@ import * as Stylesheets from 'hyperview/src/services/stylesheets'; import * as Types from './types'; import * as TypesLegacy from 'hyperview/src/types-legacy'; import * as UrlService from 'hyperview/src/services/url'; -import React, { - ComponentType, - JSXElementConstructor, - PureComponent, - ReactNode, - useContext, -} from 'react'; +import React, { JSXElementConstructor, PureComponent, useContext } from 'react'; import HvNavigator from 'hyperview/src/core/components/hv-navigator'; import HvScreen from 'hyperview/src/core/components/hv-screen'; import LoadError from 'hyperview/src/core/components/load-error'; @@ -116,7 +110,10 @@ class HvRouteInner extends PureComponent { // Set the state with the merged document this.setState(state => { const merged = NavigatorService.mergeDocument(doc, state.doc); - const root = Helpers.getFirstTag(merged, TypesLegacy.LOCAL_NAME.DOC); + const root = Helpers.getFirstChildTag( + merged, + TypesLegacy.LOCAL_NAME.DOC, + ); if (!root) { return { doc: undefined, @@ -148,7 +145,7 @@ class HvRouteInner extends PureComponent { } // Get the element - const root: TypesLegacy.Element | null = Helpers.getFirstTag( + const root: TypesLegacy.Element | null = Helpers.getFirstChildTag( this.state.doc, TypesLegacy.LOCAL_NAME.DOC, ); @@ -157,7 +154,7 @@ class HvRouteInner extends PureComponent { } // Get the first child as or - const screenElement: TypesLegacy.Element | null = Helpers.getFirstTag( + const screenElement: TypesLegacy.Element | null = Helpers.getFirstChildTag( root, TypesLegacy.LOCAL_NAME.SCREEN, ); @@ -165,7 +162,7 @@ class HvRouteInner extends PureComponent { return screenElement; } - const navigatorElement: TypesLegacy.Element | null = Helpers.getFirstTag( + const navigatorElement: TypesLegacy.Element | null = Helpers.getFirstChildTag( root, TypesLegacy.LOCAL_NAME.NAVIGATOR, ); @@ -278,9 +275,7 @@ class HvRouteInner extends PureComponent { /** * Evaluate the element and render the appropriate component */ - Route = (props: { - handleBack?: ComponentType<{ children: ReactNode }>; - }): React.ReactElement => { + Route = (): React.ReactElement => { const renderElement: TypesLegacy.Element | null = this.getRenderElement(); if (!renderElement) { @@ -307,11 +302,11 @@ class HvRouteInner extends PureComponent { const { Screen } = this; if (renderElement.localName === TypesLegacy.LOCAL_NAME.SCREEN) { - if (props.handleBack) { + if (this.props.handleBack) { return ( - + - + ); } return ; @@ -334,7 +329,7 @@ class HvRouteInner extends PureComponent { } const { Route } = this; - return ; + return ; }; render() { From b204efbc473e2115fe5ae90c6c84dee4d495471e Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Wed, 9 Aug 2023 13:28:39 -0400 Subject: [PATCH 11/29] chore(refactor) - small improvements to navigator code organization (#640) A few code refactors which will help the next step: 1. Created a helper to determine if a route id is one of the generated 'dynamic/modal' routes 2. Centralized the `screenOptions` for stack navigators to reduce redundant code 3. Created a better `getId` function for getting the id of dynamic and defined routes 4. Moved the creation of dynamic routes into its own function 5. Re-use `buildScreen` when building dynamic routes to avoid redundant code --- src/core/components/hv-navigator/index.tsx | 106 ++++++++++++--------- src/core/components/hv-navigator/types.ts | 9 ++ src/services/navigator/helpers.ts | 7 ++ src/services/navigator/index.ts | 1 + 4 files changed, 78 insertions(+), 45 deletions(-) diff --git a/src/core/components/hv-navigator/index.tsx b/src/core/components/hv-navigator/index.tsx index a36ad5e35..8b4b2c914 100644 --- a/src/core/components/hv-navigator/index.tsx +++ b/src/core/components/hv-navigator/index.tsx @@ -23,6 +23,34 @@ const Stack = NavigatorService.createStackNavigator(); const BottomTab = NavigatorService.createBottomTabNavigator(); export default class HvNavigator extends PureComponent { + /** + * Encapsulated options for the stack screenOptions + */ + stackScreenOptions = ( + route: Types.ScreenParams, + ): Types.StackScreenOptions => ({ + headerMode: 'screen', + headerShown: SHOW_NAVIGATION_UI, + title: this.getId(route.params), + }); + + /** + * Logic to determine the nav route id + */ + getId = (params: Types.RouteParams): string => { + if (!params) { + throw new NavigatorService.HvNavigatorError('No params found for route'); + } + if (params.id) { + if (NavigatorService.isDynamicId(params.id)) { + // Dynamic screens use their url as id + return params.url || params.id; + } + return params.id; + } + return params.url; + }; + /** * Build an individual tab screen */ @@ -32,7 +60,9 @@ export default class HvNavigator extends PureComponent { href: TypesLegacy.DOMString | undefined, isModal: boolean, ): React.ReactElement => { - const initialParams = { id, url: href }; + const initialParams = NavigatorService.isDynamicId(id) + ? {} + : { id, url: href }; if (type === NavigatorService.NAVIGATOR_TYPE.TAB) { return ( { this.getId(params)} initialParams={initialParams} name={id} options={{ @@ -64,6 +95,33 @@ export default class HvNavigator extends PureComponent { ); }; + /** + * Build the dynamic and modal screens for a stack navigator + */ + buildDynamics = (): React.ReactElement[] => { + const screens: React.ReactElement[] = []; + // Dynamic is used to display all routes in stack which are presented as cards + screens.push( + this.buildScreen( + NavigatorService.ID_DYNAMIC, + NavigatorService.NAVIGATOR_TYPE.STACK, + undefined, + false, + ), + ); + + // Modal is used to display all routes in stack which are presented as modals + screens.push( + this.buildScreen( + NavigatorService.ID_MODAL, + NavigatorService.NAVIGATOR_TYPE.STACK, + undefined, + true, + ), + ); + return screens; + }; + /** * Build all screens from received routes */ @@ -126,34 +184,7 @@ export default class HvNavigator extends PureComponent { // Add the dynamic stack screens if (type === NavigatorService.NAVIGATOR_TYPE.STACK) { - // Dynamic is used to display all routes in stack which are presented as cards - screens.push( - params.url} - // empty object required because hv-screen doesn't check for undefined param - initialParams={{}} - name={NavigatorService.ID_DYNAMIC} - />, - ); - - // Modal is used to display all routes in stack which are presented as modals - screens.push( - params.url} - // empty object required because hv-screen doesn't check for undefined param - initialParams={{}} - name={NavigatorService.ID_MODAL} - options={{ - cardStyleInterpolator: - NavigatorService.CardStyleInterpolators.forVerticalIOS, - presentation: 'modal', - }} - />, - ); + screens.push(...this.buildDynamics()); } return screens; }; @@ -170,16 +201,6 @@ export default class HvNavigator extends PureComponent { throw new NavigatorService.HvNavigatorError('No id found for navigator'); } - const navigationContext: NavigationContext.NavigationContextProps | null = useContext( - NavigationContext.Context, - ); - const navigatorMapContext: NavigatorMapContext.NavigatorMapContextProps | null = useContext( - NavigatorMapContext.NavigatorMapContext, - ); - if (!navigationContext || !navigatorMapContext) { - throw new NavigatorService.HvRouteError('No context found'); - } - const type: | TypesLegacy.DOMString | null @@ -198,12 +219,7 @@ export default class HvNavigator extends PureComponent { return ( ({ - header: undefined, - headerMode: 'screen', - headerShown: SHOW_NAVIGATION_UI, - title: route.params?.url || id, - })} + screenOptions={({ route }) => this.stackScreenOptions(route)} > {buildScreens(props.element, type)} diff --git a/src/core/components/hv-navigator/types.ts b/src/core/components/hv-navigator/types.ts index a61f565b8..ccac62bb1 100644 --- a/src/core/components/hv-navigator/types.ts +++ b/src/core/components/hv-navigator/types.ts @@ -31,3 +31,12 @@ export type Props = { element: TypesLegacy.Element; routeComponent: FC; }; + +/** + * Options used for a stack navigator's screenOptions + */ +export type StackScreenOptions = { + headerMode: 'float' | 'screen' | undefined; + headerShown: boolean; + title: string | undefined; +}; diff --git a/src/services/navigator/helpers.ts b/src/services/navigator/helpers.ts index ef8b88d3b..7ec665984 100644 --- a/src/services/navigator/helpers.ts +++ b/src/services/navigator/helpers.ts @@ -14,6 +14,13 @@ import * as TypesLegacy from 'hyperview/src/types-legacy'; import * as UrlService from 'hyperview/src/services/url'; import { ANCHOR_ID_SEPARATOR } from './types'; +/** + * Dynamic and modal routes are not defined in the document + */ +export const isDynamicId = (id: string): boolean => { + return id === Types.ID_DYNAMIC || id === Types.ID_MODAL; +}; + /** * Get an array of all child elements of a node */ diff --git a/src/services/navigator/index.ts b/src/services/navigator/index.ts index 1aa236006..017b19fa3 100644 --- a/src/services/navigator/index.ts +++ b/src/services/navigator/index.ts @@ -127,6 +127,7 @@ export { } from './imports'; export { HvRouteError, HvNavigatorError, HvRenderError } from './errors'; export { + isDynamicId, isUrlFragment, cleanHrefFragment, getChildElements, From 06beb940a88e08d6d856a82f63fdd25432870566 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Wed, 9 Aug 2023 15:05:45 -0400 Subject: [PATCH 12/29] chore(modal) - add a stack navigator to modals (#641) When a modal is opened, there may be additional navigation which happens within its content. If the modal implements its own stack, those navigation actions can occur within the view of the modal. They can be closed without having to back out of all the screens. In the following videos, the 'company' view is defined as a modal. Without the stack navigator, the 'edit' action replaces the 'company' view and the user has to go back before closing the modal. With the stack navigator, the user can close the modal at any point. Note: default react-navigation UI is being used in these videos to simplify the navigation. | No stack | With stack | | ---- | ---- | | ![nostack](https://github.com/Instawork/hyperview/assets/127122858/8fbf265d-2720-4879-812b-7579205291c5) | ![withstack](https://github.com/Instawork/hyperview/assets/127122858/fadd5271-02f0-412c-9a48-051830ec0957) | Asana: https://app.asana.com/0/1204008699308084/1205225573228523/f --- src/core/components/hv-navigator/index.tsx | 101 ++++++++++++++++----- src/core/components/hv-navigator/types.ts | 4 +- src/core/components/hv-route/index.tsx | 52 ++++++++--- src/core/components/hv-route/types.ts | 1 + 4 files changed, 121 insertions(+), 37 deletions(-) diff --git a/src/core/components/hv-navigator/index.tsx b/src/core/components/hv-navigator/index.tsx index 8b4b2c914..42844ce84 100644 --- a/src/core/components/hv-navigator/index.tsx +++ b/src/core/components/hv-navigator/index.tsx @@ -62,7 +62,7 @@ export default class HvNavigator extends PureComponent { ): React.ReactElement => { const initialParams = NavigatorService.isDynamicId(id) ? {} - : { id, url: href }; + : { id, isModal, url: href }; if (type === NavigatorService.NAVIGATOR_TYPE.TAB) { return ( { * Build the dynamic and modal screens for a stack navigator */ buildDynamics = (): React.ReactElement[] => { + const { buildScreen } = this; const screens: React.ReactElement[] = []; // Dynamic is used to display all routes in stack which are presented as cards screens.push( - this.buildScreen( + buildScreen( NavigatorService.ID_DYNAMIC, NavigatorService.NAVIGATOR_TYPE.STACK, undefined, @@ -112,7 +113,7 @@ export default class HvNavigator extends PureComponent { // Modal is used to display all routes in stack which are presented as modals screens.push( - this.buildScreen( + buildScreen( NavigatorService.ID_MODAL, NavigatorService.NAVIGATOR_TYPE.STACK, undefined, @@ -140,7 +141,7 @@ export default class HvNavigator extends PureComponent { throw new NavigatorService.HvRouteError('No context found'); } - const { buildScreen } = this; + const { buildDynamics, buildScreen } = this; const elements: TypesLegacy.Element[] = NavigatorService.getChildElements( element, ); @@ -184,7 +185,7 @@ export default class HvNavigator extends PureComponent { // Add the dynamic stack screens if (type === NavigatorService.NAVIGATOR_TYPE.STACK) { - screens.push(...this.buildDynamics()); + screens.push(...buildDynamics()); } return screens; }; @@ -193,35 +194,40 @@ export default class HvNavigator extends PureComponent { * Build the required navigator from the xml element */ Navigator = (props: Types.Props): React.ReactElement => { - const id: - | TypesLegacy.DOMString - | null - | undefined = props.element.getAttribute('id'); + const { element } = props; + if (!element) { + throw new NavigatorService.HvNavigatorError( + 'No element found for navigator', + ); + } + + const id: TypesLegacy.DOMString | null | undefined = element.getAttribute( + 'id', + ); if (!id) { throw new NavigatorService.HvNavigatorError('No id found for navigator'); } - const type: - | TypesLegacy.DOMString - | null - | undefined = props.element.getAttribute('type'); + const type: TypesLegacy.DOMString | null | undefined = element.getAttribute( + 'type', + ); const selected: | TypesLegacy.Element - | undefined = NavigatorService.getSelectedNavRouteElement(props.element); + | undefined = NavigatorService.getSelectedNavRouteElement(element); const selectedId: string | undefined = selected ? selected.getAttribute('id')?.toString() : undefined; - const { buildScreens } = this; + const { buildScreens, stackScreenOptions } = this; switch (type) { case NavigatorService.NAVIGATOR_TYPE.STACK: return ( this.stackScreenOptions(route)} + screenOptions={({ route }) => stackScreenOptions(route)} > - {buildScreens(props.element, type)} + {buildScreens(element, type)} ); case NavigatorService.NAVIGATOR_TYPE.TAB: @@ -236,7 +242,7 @@ export default class HvNavigator extends PureComponent { }} tabBar={undefined} > - {buildScreens(props.element, type)} + {buildScreens(element, type)} ); default: @@ -246,14 +252,63 @@ export default class HvNavigator extends PureComponent { ); }; + /** + * Build a stack navigator for a modal + */ + ModalNavigator = (props: Types.ScreenParams): React.ReactElement => { + const { params } = props; + if (!params) { + throw new NavigatorService.HvNavigatorError( + 'No params found for modal screen', + ); + } + + if (!params.id) { + throw new NavigatorService.HvNavigatorError( + 'No id found for modal screen', + ); + } + + const id = `stack-${params.id}`; + const screenId = `modal-screen-${params.id}`; + const { buildScreen, buildDynamics, stackScreenOptions } = this; + + // Generate a simple structure for the modal + const screens: React.ReactElement[] = []; + screens.push( + buildScreen( + screenId, + NavigatorService.NAVIGATOR_TYPE.STACK, + params?.url || undefined, + false, + ), + ); + screens.push(...buildDynamics()); + + return ( + stackScreenOptions(route)} + > + {screens} + + ); + }; + render() { - const { Navigator } = this; + const { ModalNavigator, Navigator } = this; + + const { isModal } = this.props.params || { isModal: false }; return ( - + {isModal && this.props.params ? ( + + ) : ( + + )} ); } diff --git a/src/core/components/hv-navigator/types.ts b/src/core/components/hv-navigator/types.ts index ccac62bb1..95e8a68e0 100644 --- a/src/core/components/hv-navigator/types.ts +++ b/src/core/components/hv-navigator/types.ts @@ -12,6 +12,7 @@ import { FC } from 'react'; export type RouteParams = { id?: string; url: string; + isModal?: boolean; }; export type ParamTypes = Record; @@ -28,7 +29,8 @@ export type NavigatorParams = { * All of the props used by hv-navigator */ export type Props = { - element: TypesLegacy.Element; + element?: TypesLegacy.Element; + params?: RouteParams; routeComponent: FC; }; diff --git a/src/core/components/hv-route/index.tsx b/src/core/components/hv-route/index.tsx index 1812d1911..a88c219e5 100644 --- a/src/core/components/hv-route/index.tsx +++ b/src/core/components/hv-route/index.tsx @@ -136,7 +136,7 @@ class HvRouteInner extends PureComponent { } }; - getRenderElement = (): TypesLegacy.Element | null => { + getRenderElement = (): TypesLegacy.Element | undefined => { if (this.props.element) { return this.props.element; } @@ -276,32 +276,51 @@ class HvRouteInner extends PureComponent { * Evaluate the element and render the appropriate component */ Route = (): React.ReactElement => { - const renderElement: TypesLegacy.Element | null = this.getRenderElement(); + const { isModal } = this.props.route?.params || { isModal: false }; - if (!renderElement) { - throw new NavigatorService.HvRenderError('No element found'); - } + const renderElement: TypesLegacy.Element | undefined = isModal + ? undefined + : this.getRenderElement(); + + if (!isModal) { + if (!renderElement) { + throw new NavigatorService.HvRenderError('No element found'); + } - if (renderElement.namespaceURI !== Namespaces.HYPERVIEW) { - throw new NavigatorService.HvRenderError('Invalid namespace'); + if (renderElement.namespaceURI !== Namespaces.HYPERVIEW) { + throw new NavigatorService.HvRenderError('Invalid namespace'); + } } - if (renderElement.localName === TypesLegacy.LOCAL_NAME.NAVIGATOR) { + if ( + isModal || + renderElement?.localName === TypesLegacy.LOCAL_NAME.NAVIGATOR + ) { if (this.state.doc) { // The provides doc access to nested navigators // only pass it when the doc is available and is not being overridden by an element return ( - + ); } // Without a doc, the navigator shares the higher level context - return ; + return ( + + ); } const { Screen } = this; - if (renderElement.localName === TypesLegacy.LOCAL_NAME.SCREEN) { + if (renderElement?.localName === TypesLegacy.LOCAL_NAME.SCREEN) { if (this.props.handleBack) { return ( @@ -319,7 +338,10 @@ class HvRouteInner extends PureComponent { * View shown when the document is loaded */ Content = (): React.ReactElement => { - if (this.props.element === undefined) { + if ( + this.props.element === undefined && + !this.props.route?.params?.isModal + ) { if (!this.props.url) { throw new NavigatorService.HvRouteError('No url received'); } @@ -338,7 +360,11 @@ class HvRouteInner extends PureComponent { if (this.state.error) { return ; } - if (this.props.element || this.state.doc) { + if ( + this.props.element || + this.state.doc || + this.props.route?.params?.isModal + ) { return ; } return ; diff --git a/src/core/components/hv-route/types.ts b/src/core/components/hv-route/types.ts index ffae6326d..0e9242653 100644 --- a/src/core/components/hv-route/types.ts +++ b/src/core/components/hv-route/types.ts @@ -19,6 +19,7 @@ type RouteParams = { id?: string; url: string; preloadScreen?: number; + isModal?: boolean; }; export type NavigationContextProps = { From 0a728dd658cc31b2b53a3b793b0b9874b8a5b911 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Wed, 9 Aug 2023 22:56:02 -0400 Subject: [PATCH 13/29] chore(dynamic) - refactor 'dynamic' to 'card' (#642) Replaced all uses of 'dynamic' as a type of route to 'card' to better reflect the implementation details. --- src/core/components/hv-navigator/index.tsx | 30 ++++++++++++---------- src/services/navigator/helpers.test.ts | 20 +++++++-------- src/services/navigator/helpers.ts | 10 ++++---- src/services/navigator/index.ts | 4 +-- src/services/navigator/types.ts | 2 +- 5 files changed, 34 insertions(+), 32 deletions(-) diff --git a/src/core/components/hv-navigator/index.tsx b/src/core/components/hv-navigator/index.tsx index 42844ce84..7afb50707 100644 --- a/src/core/components/hv-navigator/index.tsx +++ b/src/core/components/hv-navigator/index.tsx @@ -42,8 +42,8 @@ export default class HvNavigator extends PureComponent { throw new NavigatorService.HvNavigatorError('No params found for route'); } if (params.id) { - if (NavigatorService.isDynamicId(params.id)) { - // Dynamic screens use their url as id + if (NavigatorService.isDynamicRoute(params.id)) { + // Dynamic routes use their url as id return params.url || params.id; } return params.id; @@ -60,7 +60,7 @@ export default class HvNavigator extends PureComponent { href: TypesLegacy.DOMString | undefined, isModal: boolean, ): React.ReactElement => { - const initialParams = NavigatorService.isDynamicId(id) + const initialParams = NavigatorService.isDynamicRoute(id) ? {} : { id, isModal, url: href }; if (type === NavigatorService.NAVIGATOR_TYPE.TAB) { @@ -85,7 +85,9 @@ export default class HvNavigator extends PureComponent { cardStyleInterpolator: isModal ? NavigatorService.CardStyleInterpolators.forVerticalIOS : undefined, - presentation: isModal ? 'modal' : 'card', + presentation: isModal + ? NavigatorService.ID_MODAL + : NavigatorService.ID_CARD, }} /> ); @@ -96,22 +98,21 @@ export default class HvNavigator extends PureComponent { }; /** - * Build the dynamic and modal screens for a stack navigator + * Build the card and modal screens for a stack navigator */ - buildDynamics = (): React.ReactElement[] => { + buildDynamicScreens = (): React.ReactElement[] => { const { buildScreen } = this; const screens: React.ReactElement[] = []; - // Dynamic is used to display all routes in stack which are presented as cards + screens.push( buildScreen( - NavigatorService.ID_DYNAMIC, + NavigatorService.ID_CARD, NavigatorService.NAVIGATOR_TYPE.STACK, undefined, false, ), ); - // Modal is used to display all routes in stack which are presented as modals screens.push( buildScreen( NavigatorService.ID_MODAL, @@ -141,13 +142,14 @@ export default class HvNavigator extends PureComponent { throw new NavigatorService.HvRouteError('No context found'); } - const { buildDynamics, buildScreen } = this; + const { buildDynamicScreens, buildScreen } = this; const elements: TypesLegacy.Element[] = NavigatorService.getChildElements( element, ); // For tab navigators, the screens are appended - // For stack navigators, the dynamic screens are added later + // For stack navigators, defined routes are appened, + // the dynamic screens are added later // This iteration will also process nested navigators // and retrieve additional urls from child routes elements.forEach((navRoute: TypesLegacy.Element) => { @@ -185,7 +187,7 @@ export default class HvNavigator extends PureComponent { // Add the dynamic stack screens if (type === NavigatorService.NAVIGATOR_TYPE.STACK) { - screens.push(...buildDynamics()); + screens.push(...buildDynamicScreens()); } return screens; }; @@ -271,7 +273,7 @@ export default class HvNavigator extends PureComponent { const id = `stack-${params.id}`; const screenId = `modal-screen-${params.id}`; - const { buildScreen, buildDynamics, stackScreenOptions } = this; + const { buildScreen, buildDynamicScreens, stackScreenOptions } = this; // Generate a simple structure for the modal const screens: React.ReactElement[] = []; @@ -283,7 +285,7 @@ export default class HvNavigator extends PureComponent { false, ), ); - screens.push(...buildDynamics()); + screens.push(...buildDynamicScreens()); return ( { const urls = ['#url', '#']; describe('action:push', () => { urls.forEach(url => { - it(`should not return route 'dynamic' from url with fragment: ${url}`, () => { + it(`should not return route 'card' from url with fragment: ${url}`, () => { expect(getRouteId(TypesLegacy.NAV_ACTIONS.PUSH, url)).not.toEqual( - ID_DYNAMIC, + ID_CARD, ); }); it(`should return route id with fragment: ${url}`, () => { @@ -488,9 +488,9 @@ describe('getRouteId', () => { const urls = ['url', '/url', '', undefined]; describe('action:push', () => { urls.forEach(url => { - it(`should return type 'dynamic' from url with non-fragment: ${url}`, () => { + it(`should return type 'card' from url with non-fragment: ${url}`, () => { expect(getRouteId(TypesLegacy.NAV_ACTIONS.PUSH, url)).toEqual( - ID_DYNAMIC, + ID_CARD, ); }); }); @@ -518,16 +518,16 @@ describe('getRouteId', () => { it(`should return cleaned url with fragment: ${url}`, () => { expect(getRouteId(action, url)).toEqual(url.slice(1)); }); - it(`should not return type 'dynamic' from url with fragment: ${url}`, () => { - expect(getRouteId(action, url)).not.toEqual(ID_DYNAMIC); + it(`should not return type 'card' from url with fragment: ${url}`, () => { + expect(getRouteId(action, url)).not.toEqual(ID_CARD); }); }); ['url', '/url', '', undefined].forEach(url => { it(`should return cleaned url with non-fragment: ${url}`, () => { - expect(getRouteId(action, url)).toEqual(ID_DYNAMIC); + expect(getRouteId(action, url)).toEqual(ID_CARD); }); - it(`should return type 'dynamic' from url with non-fragment: ${url}`, () => { - expect(getRouteId(action, url)).toEqual(ID_DYNAMIC); + it(`should return type 'card' from url with non-fragment: ${url}`, () => { + expect(getRouteId(action, url)).toEqual(ID_CARD); }); }); }); diff --git a/src/services/navigator/helpers.ts b/src/services/navigator/helpers.ts index 7ec665984..42272a122 100644 --- a/src/services/navigator/helpers.ts +++ b/src/services/navigator/helpers.ts @@ -15,10 +15,10 @@ import * as UrlService from 'hyperview/src/services/url'; import { ANCHOR_ID_SEPARATOR } from './types'; /** - * Dynamic and modal routes are not defined in the document + * Card and modal routes are not defined in the document */ -export const isDynamicId = (id: string): boolean => { - return id === Types.ID_DYNAMIC || id === Types.ID_MODAL; +export const isDynamicRoute = (id: string): boolean => { + return id === Types.ID_CARD || id === Types.ID_MODAL; }; /** @@ -209,7 +209,7 @@ export const buildParams = ( }; /** - * Use the dynamic or modal route for dynamic actions, otherwise use the given id + * Use the card or modal route for dynamic actions, otherwise use the given id */ export const getRouteId = ( action: TypesLegacy.NavAction, @@ -221,7 +221,7 @@ export const getRouteId = ( return action === TypesLegacy.NAV_ACTIONS.NEW ? Types.ID_MODAL - : Types.ID_DYNAMIC; + : Types.ID_CARD; }; /** diff --git a/src/services/navigator/index.ts b/src/services/navigator/index.ts index 017b19fa3..c623764a9 100644 --- a/src/services/navigator/index.ts +++ b/src/services/navigator/index.ts @@ -127,7 +127,7 @@ export { } from './imports'; export { HvRouteError, HvNavigatorError, HvRenderError } from './errors'; export { - isDynamicId, + isDynamicRoute, isUrlFragment, cleanHrefFragment, getChildElements, @@ -137,4 +137,4 @@ export { mergeDocument, setSelected, } from './helpers'; -export { ID_DYNAMIC, ID_MODAL, KEY_MODAL, NAVIGATOR_TYPE } from './types'; +export { ID_CARD, ID_MODAL, KEY_MODAL, NAVIGATOR_TYPE } from './types'; diff --git a/src/services/navigator/types.ts b/src/services/navigator/types.ts index 4687fd553..60d751e4e 100644 --- a/src/services/navigator/types.ts +++ b/src/services/navigator/types.ts @@ -9,7 +9,7 @@ import * as TypesLegacy from 'hyperview/src/types-legacy'; export const ANCHOR_ID_SEPARATOR = '#'; -export const ID_DYNAMIC = 'dynamic'; +export const ID_CARD = 'card'; export const ID_MODAL = 'modal'; export const KEY_MERGE = 'merge'; export const KEY_SELECTED = 'selected'; From 2f34bec7a70b96b8679da9c98e35118715906560 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Mon, 14 Aug 2023 08:19:18 -0400 Subject: [PATCH 14/29] chore(destructuring) - remove prop and component destructuring (#643) Remove instances of `const { someFunction } = this` and `const { someProp } = props` --- src/core/components/hv-navigator/index.tsx | 63 ++++++++++------------ src/core/components/hv-route/index.tsx | 11 ++-- src/services/navigator/helpers.ts | 5 +- 3 files changed, 37 insertions(+), 42 deletions(-) diff --git a/src/core/components/hv-navigator/index.tsx b/src/core/components/hv-navigator/index.tsx index 7afb50707..b817103d9 100644 --- a/src/core/components/hv-navigator/index.tsx +++ b/src/core/components/hv-navigator/index.tsx @@ -101,11 +101,10 @@ export default class HvNavigator extends PureComponent { * Build the card and modal screens for a stack navigator */ buildDynamicScreens = (): React.ReactElement[] => { - const { buildScreen } = this; const screens: React.ReactElement[] = []; screens.push( - buildScreen( + this.buildScreen( NavigatorService.ID_CARD, NavigatorService.NAVIGATOR_TYPE.STACK, undefined, @@ -114,7 +113,7 @@ export default class HvNavigator extends PureComponent { ); screens.push( - buildScreen( + this.buildScreen( NavigatorService.ID_MODAL, NavigatorService.NAVIGATOR_TYPE.STACK, undefined, @@ -142,7 +141,6 @@ export default class HvNavigator extends PureComponent { throw new NavigatorService.HvRouteError('No context found'); } - const { buildDynamicScreens, buildScreen } = this; const elements: TypesLegacy.Element[] = NavigatorService.getChildElements( element, ); @@ -181,13 +179,13 @@ export default class HvNavigator extends PureComponent { `No href provided for route '${id}'`, ); } - screens.push(buildScreen(id, type, href || undefined, isModal)); + screens.push(this.buildScreen(id, type, href || undefined, isModal)); } }); // Add the dynamic stack screens if (type === NavigatorService.NAVIGATOR_TYPE.STACK) { - screens.push(...buildDynamicScreens()); + screens.push(...this.buildDynamicScreens()); } return screens; }; @@ -196,40 +194,40 @@ export default class HvNavigator extends PureComponent { * Build the required navigator from the xml element */ Navigator = (props: Types.Props): React.ReactElement => { - const { element } = props; - if (!element) { + if (!props.element) { throw new NavigatorService.HvNavigatorError( 'No element found for navigator', ); } - const id: TypesLegacy.DOMString | null | undefined = element.getAttribute( - 'id', - ); + const id: + | TypesLegacy.DOMString + | null + | undefined = props.element.getAttribute('id'); if (!id) { throw new NavigatorService.HvNavigatorError('No id found for navigator'); } - const type: TypesLegacy.DOMString | null | undefined = element.getAttribute( - 'type', - ); + const type: + | TypesLegacy.DOMString + | null + | undefined = props.element.getAttribute('type'); const selected: | TypesLegacy.Element - | undefined = NavigatorService.getSelectedNavRouteElement(element); + | undefined = NavigatorService.getSelectedNavRouteElement(props.element); const selectedId: string | undefined = selected ? selected.getAttribute('id')?.toString() : undefined; - const { buildScreens, stackScreenOptions } = this; switch (type) { case NavigatorService.NAVIGATOR_TYPE.STACK: return ( stackScreenOptions(route)} + screenOptions={({ route }) => this.stackScreenOptions(route)} > - {buildScreens(element, type)} + {this.buildScreens(props.element, type)} ); case NavigatorService.NAVIGATOR_TYPE.TAB: @@ -244,7 +242,7 @@ export default class HvNavigator extends PureComponent { }} tabBar={undefined} > - {buildScreens(element, type)} + {this.buildScreens(props.element, type)} ); default: @@ -258,39 +256,37 @@ export default class HvNavigator extends PureComponent { * Build a stack navigator for a modal */ ModalNavigator = (props: Types.ScreenParams): React.ReactElement => { - const { params } = props; - if (!params) { + if (!props.params) { throw new NavigatorService.HvNavigatorError( 'No params found for modal screen', ); } - if (!params.id) { + if (!props.params.id) { throw new NavigatorService.HvNavigatorError( 'No id found for modal screen', ); } - const id = `stack-${params.id}`; - const screenId = `modal-screen-${params.id}`; - const { buildScreen, buildDynamicScreens, stackScreenOptions } = this; + const id = `stack-${props.params.id}`; + const screenId = `modal-screen-${props.params.id}`; // Generate a simple structure for the modal const screens: React.ReactElement[] = []; screens.push( - buildScreen( + this.buildScreen( screenId, NavigatorService.NAVIGATOR_TYPE.STACK, - params?.url || undefined, + props.params?.url || undefined, false, ), ); - screens.push(...buildDynamicScreens()); + screens.push(...this.buildDynamicScreens()); return ( stackScreenOptions(route)} + screenOptions={({ route }) => this.stackScreenOptions(route)} > {screens} @@ -298,15 +294,12 @@ export default class HvNavigator extends PureComponent { }; render() { - const { ModalNavigator, Navigator } = this; - - const { isModal } = this.props.params || { isModal: false }; return ( - {isModal && this.props.params ? ( - + {this.props.params && this.props.params.isModal ? ( + ) : ( - diff --git a/src/core/components/hv-route/index.tsx b/src/core/components/hv-route/index.tsx index a88c219e5..eb2f1fd00 100644 --- a/src/core/components/hv-route/index.tsx +++ b/src/core/components/hv-route/index.tsx @@ -276,7 +276,11 @@ class HvRouteInner extends PureComponent { * Evaluate the element and render the appropriate component */ Route = (): React.ReactElement => { - const { isModal } = this.props.route?.params || { isModal: false }; + const { Screen } = this; + + const isModal = this.props.route?.params.isModal + ? this.props.route.params.isModal + : false; const renderElement: TypesLegacy.Element | undefined = isModal ? undefined @@ -318,7 +322,6 @@ class HvRouteInner extends PureComponent { /> ); } - const { Screen } = this; if (renderElement?.localName === TypesLegacy.LOCAL_NAME.SCREEN) { if (this.props.handleBack) { @@ -355,10 +358,10 @@ class HvRouteInner extends PureComponent { }; render() { - const { Error, Load, Content } = this; + const { Content, Error, Load } = this; try { if (this.state.error) { - return ; + return ; } if ( this.props.element || diff --git a/src/services/navigator/helpers.ts b/src/services/navigator/helpers.ts index 42272a122..e36236951 100644 --- a/src/services/navigator/helpers.ts +++ b/src/services/navigator/helpers.ts @@ -130,11 +130,10 @@ export const findPath = ( return path; } - const { routes } = state; - if (!routes) { + if (!state.routes) { return path; } - routes.every(route => { + state.routes.every(route => { if (route.name === targetId) { path.unshift(route.name); return false; From 98a1db5dbcee16f483c5c92bf64af9ab4696d89f Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Mon, 14 Aug 2023 13:30:08 -0400 Subject: [PATCH 15/29] chore(dom) - reflect navigation state in dom (#651) The initial document may contain multiple elements in a stack. These will be pushed onto the stack immediately. As they are closed they should be removed from the dom to reflect the current state. Note: card and modal routes pushed onto the stack by user activity will not be added to the dom to reflect state. Asana: https://app.asana.com/0/0/1205225573228534/f --- Example: closing the 'company' modal. Before: ```XML ``` After: ```XML ``` --- src/core/components/hv-route/index.tsx | 28 ++++++++++++++++++++++---- src/services/navigator/helpers.ts | 20 ++++++++++++++++++ src/services/navigator/index.ts | 1 + src/services/navigator/types.ts | 2 +- src/types-legacy.ts | 1 + 5 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/core/components/hv-route/index.tsx b/src/core/components/hv-route/index.tsx index eb2f1fd00..3eb778694 100644 --- a/src/core/components/hv-route/index.tsx +++ b/src/core/components/hv-route/index.tsx @@ -452,13 +452,33 @@ export default function HvRoute(props: Types.Props) { routeDocContext, ); + // Use the focus event to set the selected route React.useEffect(() => { if (props.navigation) { - const unsubscribe = props.navigation.addListener('focus', () => { - NavigatorService.setSelected(routeDocContext, props.route?.params?.id); - }); + const unsubscribeFocus: () => void = props.navigation.addListener( + 'focus', + () => { + NavigatorService.setSelected( + routeDocContext, + props.route?.params?.id, + ); + }, + ); + + const unsubscribeRemove: () => void = props.navigation.addListener( + 'beforeRemove', + () => { + NavigatorService.removeStackRoute( + routeDocContext, + props.route?.params?.id, + ); + }, + ); - return unsubscribe; + return () => { + unsubscribeFocus(); + unsubscribeRemove(); + }; } return undefined; }, [props.navigation, props.route?.params?.id, routeDocContext]); diff --git a/src/services/navigator/helpers.ts b/src/services/navigator/helpers.ts index e36236951..46d77711c 100644 --- a/src/services/navigator/helpers.ts +++ b/src/services/navigator/helpers.ts @@ -480,3 +480,23 @@ export const setSelected = ( route.setAttribute(Types.KEY_SELECTED, 'true'); } }; + +/** + * Remove a stack route from the document + */ +export const removeStackRoute = ( + doc: TypesLegacy.Document | undefined, + id: string | undefined, +) => { + if (!doc || !id) { + return; + } + const route = getRouteById(doc, id); + if (route && route.parentNode) { + const parentNode = route.parentNode as TypesLegacy.Element; + const type = parentNode.getAttribute('type'); + if (type === Types.NAVIGATOR_TYPE.STACK) { + route.parentNode.removeChild(route); + } + } +}; diff --git a/src/services/navigator/index.ts b/src/services/navigator/index.ts index c623764a9..333a3cfe3 100644 --- a/src/services/navigator/index.ts +++ b/src/services/navigator/index.ts @@ -135,6 +135,7 @@ export { getSelectedNavRouteElement, getUrlFromHref, mergeDocument, + removeStackRoute, setSelected, } from './helpers'; export { ID_CARD, ID_MODAL, KEY_MODAL, NAVIGATOR_TYPE } from './types'; diff --git a/src/services/navigator/types.ts b/src/services/navigator/types.ts index 60d751e4e..eaac5c6e1 100644 --- a/src/services/navigator/types.ts +++ b/src/services/navigator/types.ts @@ -40,7 +40,7 @@ export type NavigationProp = { goBack: () => void; getState: () => NavigationState; getParent: (id?: string) => NavigationProp | undefined; - addListener: (event: string, callback: () => void) => void; + addListener: (event: string, callback: () => void) => () => void; }; /** diff --git a/src/types-legacy.ts b/src/types-legacy.ts index b28cc07b2..729e27b8b 100644 --- a/src/types-legacy.ts +++ b/src/types-legacy.ts @@ -96,6 +96,7 @@ export type Node = { parentNode: Node | null | undefined; appendChild: (newChild: Node) => Node; hasChildNodes: () => boolean; + removeChild: (oldChild: Node) => Node; replaceChild: (newChild: Node, oldChild: Node) => Node; }; From 84be399fd525762cb3813f4fb0c77a8dc2141048 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Thu, 17 Aug 2023 12:31:13 -0400 Subject: [PATCH 16/29] chore(cleanup) - improved setup for tab navigator options (#656) Making the tab navigator setup more closely resemble the stack navigator --- src/core/components/hv-navigator/index.tsx | 17 +++++++++++------ src/core/components/hv-navigator/types.ts | 9 +++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/core/components/hv-navigator/index.tsx b/src/core/components/hv-navigator/index.tsx index b817103d9..bcd835af0 100644 --- a/src/core/components/hv-navigator/index.tsx +++ b/src/core/components/hv-navigator/index.tsx @@ -20,7 +20,7 @@ import { getFirstChildTag } from 'hyperview/src/services/dom/helpers-legacy'; const SHOW_NAVIGATION_UI = false; const Stack = NavigatorService.createStackNavigator(); -const BottomTab = NavigatorService.createBottomTabNavigator(); +const BottomTab = NavigatorService.createBottomTabNavigator(); export default class HvNavigator extends PureComponent { /** @@ -34,6 +34,15 @@ export default class HvNavigator extends PureComponent { title: this.getId(route.params), }); + /** + * Encapsulated options for the tab screenOptions + */ + tabScreenOptions = (route: Types.ScreenParams): Types.TabScreenOptions => ({ + headerShown: SHOW_NAVIGATION_UI, + tabBarStyle: { display: SHOW_NAVIGATION_UI ? 'flex' : 'none' }, + title: this.getId(route.params), + }); + /** * Logic to determine the nav route id */ @@ -236,11 +245,7 @@ export default class HvNavigator extends PureComponent { backBehavior="none" id={id} initialRouteName={selectedId} - screenOptions={{ - headerShown: SHOW_NAVIGATION_UI, - tabBarStyle: { display: SHOW_NAVIGATION_UI ? 'flex' : 'none' }, - }} - tabBar={undefined} + screenOptions={({ route }) => this.tabScreenOptions(route)} > {this.buildScreens(props.element, type)} diff --git a/src/core/components/hv-navigator/types.ts b/src/core/components/hv-navigator/types.ts index 95e8a68e0..2314b65ef 100644 --- a/src/core/components/hv-navigator/types.ts +++ b/src/core/components/hv-navigator/types.ts @@ -42,3 +42,12 @@ export type StackScreenOptions = { headerShown: boolean; title: string | undefined; }; + +/** + * Options used for a tab navigator's screenOptions + */ +export type TabScreenOptions = { + headerShown: boolean; + tabBarStyle: { display: 'flex' | 'none' | undefined }; + title: string | undefined; +}; From 9ae80209a653a013dfa5e9b7a754d47f8b3d26b5 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Tue, 22 Aug 2023 11:27:37 -0400 Subject: [PATCH 17/29] chore(cleanup) - passing props (#657) No need to pass separate props object into the components --- src/core/components/hv-navigator/index.tsx | 35 +++++++++++----------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/core/components/hv-navigator/index.tsx b/src/core/components/hv-navigator/index.tsx index bcd835af0..6a2e51fef 100644 --- a/src/core/components/hv-navigator/index.tsx +++ b/src/core/components/hv-navigator/index.tsx @@ -202,8 +202,8 @@ export default class HvNavigator extends PureComponent { /** * Build the required navigator from the xml element */ - Navigator = (props: Types.Props): React.ReactElement => { - if (!props.element) { + Navigator = (): React.ReactElement => { + if (!this.props.element) { throw new NavigatorService.HvNavigatorError( 'No element found for navigator', ); @@ -212,7 +212,7 @@ export default class HvNavigator extends PureComponent { const id: | TypesLegacy.DOMString | null - | undefined = props.element.getAttribute('id'); + | undefined = this.props.element.getAttribute('id'); if (!id) { throw new NavigatorService.HvNavigatorError('No id found for navigator'); } @@ -220,10 +220,12 @@ export default class HvNavigator extends PureComponent { const type: | TypesLegacy.DOMString | null - | undefined = props.element.getAttribute('type'); + | undefined = this.props.element.getAttribute('type'); const selected: | TypesLegacy.Element - | undefined = NavigatorService.getSelectedNavRouteElement(props.element); + | undefined = NavigatorService.getSelectedNavRouteElement( + this.props.element, + ); const selectedId: string | undefined = selected ? selected.getAttribute('id')?.toString() @@ -236,7 +238,7 @@ export default class HvNavigator extends PureComponent { id={id} screenOptions={({ route }) => this.stackScreenOptions(route)} > - {this.buildScreens(props.element, type)} + {this.buildScreens(this.props.element, type)} ); case NavigatorService.NAVIGATOR_TYPE.TAB: @@ -247,7 +249,7 @@ export default class HvNavigator extends PureComponent { initialRouteName={selectedId} screenOptions={({ route }) => this.tabScreenOptions(route)} > - {this.buildScreens(props.element, type)} + {this.buildScreens(this.props.element, type)} ); default: @@ -260,21 +262,21 @@ export default class HvNavigator extends PureComponent { /** * Build a stack navigator for a modal */ - ModalNavigator = (props: Types.ScreenParams): React.ReactElement => { - if (!props.params) { + ModalNavigator = (): React.ReactElement => { + if (!this.props.params) { throw new NavigatorService.HvNavigatorError( 'No params found for modal screen', ); } - if (!props.params.id) { + if (!this.props.params.id) { throw new NavigatorService.HvNavigatorError( 'No id found for modal screen', ); } - const id = `stack-${props.params.id}`; - const screenId = `modal-screen-${props.params.id}`; + const id = `stack-${this.props.params.id}`; + const screenId = `modal-screen-${this.props.params.id}`; // Generate a simple structure for the modal const screens: React.ReactElement[] = []; @@ -282,7 +284,7 @@ export default class HvNavigator extends PureComponent { this.buildScreen( screenId, NavigatorService.NAVIGATOR_TYPE.STACK, - props.params?.url || undefined, + this.props.params?.url || undefined, false, ), ); @@ -302,12 +304,9 @@ export default class HvNavigator extends PureComponent { return ( {this.props.params && this.props.params.isModal ? ( - + ) : ( - + )} ); From 31af54bad430c0d7d41e2bda07037b0a322b869c Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Wed, 20 Sep 2023 19:43:27 -0400 Subject: [PATCH 18/29] chore(navigation) - cleanup attribute retrieval (#686) Using constants for consistency --- src/services/navigator/helpers.ts | 13 +++++++------ src/services/navigator/types.ts | 2 ++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/services/navigator/helpers.ts b/src/services/navigator/helpers.ts index 46d77711c..d5b7557a8 100644 --- a/src/services/navigator/helpers.ts +++ b/src/services/navigator/helpers.ts @@ -60,7 +60,7 @@ export const getSelectedNavRouteElement = ( } const selectedChild = elements.find( - child => child.getAttribute('selected') === 'true', + child => child.getAttribute(Types.KEY_SELECTED) === 'true', ); return selectedChild; @@ -236,7 +236,7 @@ export const getRouteById = ( TypesLegacy.LOCAL_NAME.NAV_ROUTE, ) .filter((n: TypesLegacy.Element) => { - return n.getAttribute('id') === id; + return n.getAttribute(Types.KEY_ID) === id; }); return routes && routes.length > 0 ? routes[0] : undefined; }; @@ -342,7 +342,7 @@ const nodesToMap = ( if (node.nodeType === TypesLegacy.NODE_TYPE.ELEMENT_NODE) { const element = node as TypesLegacy.Element; if (isNavigationElement(element)) { - const id = element.getAttribute('id'); + const id = element.getAttribute(Types.KEY_ID); if (id) { map[id] = element; } @@ -385,12 +385,13 @@ const mergeNodes = ( if (node.nodeType === TypesLegacy.NODE_TYPE.ELEMENT_NODE) { const newElement = node as TypesLegacy.Element; if (isNavigationElement(newElement)) { - const id = newElement.getAttribute('id'); + const id = newElement.getAttribute(Types.KEY_ID); if (id) { const currentElement = currentMap[id] as TypesLegacy.Element; if (currentElement) { if (newElement.localName === TypesLegacy.LOCAL_NAME.NAVIGATOR) { - const isMergeable = newElement.getAttribute('merge') === 'true'; + const isMergeable = + newElement.getAttribute(Types.KEY_MERGE) === 'true'; if (isMergeable) { currentElement.setAttribute(Types.KEY_MERGE, 'true'); mergeNodes(currentElement, newElement.childNodes); @@ -494,7 +495,7 @@ export const removeStackRoute = ( const route = getRouteById(doc, id); if (route && route.parentNode) { const parentNode = route.parentNode as TypesLegacy.Element; - const type = parentNode.getAttribute('type'); + const type = parentNode.getAttribute(Types.KEY_TYPE); if (type === Types.NAVIGATOR_TYPE.STACK) { route.parentNode.removeChild(route); } diff --git a/src/services/navigator/types.ts b/src/services/navigator/types.ts index eaac5c6e1..74ea0ff20 100644 --- a/src/services/navigator/types.ts +++ b/src/services/navigator/types.ts @@ -14,6 +14,8 @@ export const ID_MODAL = 'modal'; export const KEY_MERGE = 'merge'; export const KEY_SELECTED = 'selected'; export const KEY_MODAL = 'modal'; +export const KEY_ID = 'id'; +export const KEY_TYPE = 'type'; /** * Definition of the available navigator types From 67f38c0aa731e6f4f48de9b51caa7813682f2281 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Tue, 26 Sep 2023 08:21:55 -0400 Subject: [PATCH 19/29] chore (context) - merge document contexts (#690) - Merged the required new functionality from `route-doc` into the newly added `DocContext` - Updated the implementation of HvRoute to provide the getter and setter - Cleaned up usage and naming of the value --------- Co-authored-by: Florent Bonomo --- src/components/hv-list/index.js | 2 +- src/components/hv-section-list/index.js | 2 +- src/contexts/index.ts | 3 ++- src/contexts/route-doc.tsx | 18 ------------------ src/core/components/hv-route/index.tsx | 24 +++++++++++++----------- src/core/components/hv-screen/index.js | 4 +++- src/services/navigator/helpers.ts | 6 +++--- 7 files changed, 23 insertions(+), 36 deletions(-) delete mode 100644 src/contexts/route-doc.tsx diff --git a/src/components/hv-list/index.js b/src/components/hv-list/index.js index d162e5972..1aa3120ef 100644 --- a/src/components/hv-list/index.js +++ b/src/components/hv-list/index.js @@ -77,7 +77,7 @@ export default class HvList extends PureComponent { console.warn('[behaviors/scroll]: missing "target" attribute'); return; } - const doc: ?Document = this.context(); + const doc: ?Document = this.context.getDoc(); const targetElement: ?Element = doc?.getElementById(targetId); if (!targetElement) { return; diff --git a/src/components/hv-section-list/index.js b/src/components/hv-section-list/index.js index c589f7924..13c7fe8d8 100644 --- a/src/components/hv-section-list/index.js +++ b/src/components/hv-section-list/index.js @@ -118,7 +118,7 @@ export default class HvSectionList extends PureComponent< console.warn('[behaviors/scroll]: missing "target" attribute'); return; } - const doc: ?Document = this.context(); + const doc: ?Document = this.context.getDoc(); const targetElement: ?Element = doc?.getElementById(targetId); if (!targetElement) { return; diff --git a/src/contexts/index.ts b/src/contexts/index.ts index e58e6d72b..5ff53880b 100644 --- a/src/contexts/index.ts +++ b/src/contexts/index.ts @@ -25,5 +25,6 @@ export const RefreshControlComponentContext = React.createContext< >(undefined); export const DocContext = React.createContext<{ - getDoc: () => TypesLegacy.Document; + getDoc: () => TypesLegacy.Document | undefined; + setDoc?: (doc: TypesLegacy.Document) => void; } | null>(null); diff --git a/src/contexts/route-doc.tsx b/src/contexts/route-doc.tsx deleted file mode 100644 index 80d95eec0..000000000 --- a/src/contexts/route-doc.tsx +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright (c) Garuda Labs, Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import * as TypesLegacy from 'hyperview/src/types-legacy'; -import { createContext } from 'react'; - -export type RouteDocContextProps = { - doc?: TypesLegacy.Document; -}; - -export const Context = createContext( - undefined, -); diff --git a/src/core/components/hv-route/index.tsx b/src/core/components/hv-route/index.tsx index 3eb778694..0fc562c44 100644 --- a/src/core/components/hv-route/index.tsx +++ b/src/core/components/hv-route/index.tsx @@ -15,7 +15,6 @@ import * as NavigationContext from 'hyperview/src/contexts/navigation'; import * as NavigatorMapContext from 'hyperview/src/contexts/navigator-map'; import * as NavigatorService from 'hyperview/src/services/navigator'; import * as Render from 'hyperview/src/services/render'; -import * as RouteDocContext from 'hyperview/src/contexts/route-doc'; import * as Stylesheets from 'hyperview/src/services/stylesheets'; import * as Types from './types'; import * as TypesLegacy from 'hyperview/src/types-legacy'; @@ -301,16 +300,21 @@ class HvRouteInner extends PureComponent { renderElement?.localName === TypesLegacy.LOCAL_NAME.NAVIGATOR ) { if (this.state.doc) { - // The provides doc access to nested navigators + // The provides doc access to nested navigators // only pass it when the doc is available and is not being overridden by an element return ( - + this.state.doc, + setDoc: (doc: TypesLegacy.Document) => this.setState({ doc }), + }} + > - + ); } // Without a doc, the navigator shares the higher level context @@ -440,16 +444,14 @@ export default function HvRoute(props: Types.Props) { throw new NavigatorService.HvRouteError('No context found'); } - const routeDocContext: TypesLegacy.Document | undefined = useContext( - RouteDocContext.Context, - ); + const docContext = useContext(Contexts.DocContext); const url = getRouteUrl(props, navigationContext); // Get the navigator element from the context const element: TypesLegacy.Element | undefined = getNestedNavigator( props.route?.params?.id, - routeDocContext, + docContext?.getDoc(), ); // Use the focus event to set the selected route @@ -459,7 +461,7 @@ export default function HvRoute(props: Types.Props) { 'focus', () => { NavigatorService.setSelected( - routeDocContext, + docContext?.getDoc(), props.route?.params?.id, ); }, @@ -469,7 +471,7 @@ export default function HvRoute(props: Types.Props) { 'beforeRemove', () => { NavigatorService.removeStackRoute( - routeDocContext, + docContext?.getDoc(), props.route?.params?.id, ); }, @@ -481,7 +483,7 @@ export default function HvRoute(props: Types.Props) { }; } return undefined; - }, [props.navigation, props.route?.params?.id, routeDocContext]); + }, [props.navigation, props.route?.params?.id, docContext]); return ( this.doc}> + this.doc, + }}> {screenElement} {elementErrorComponent ? (React.createElement(elementErrorComponent, { error: this.state.elementError, onPressReload: () => this.reload() })) : null} diff --git a/src/services/navigator/helpers.ts b/src/services/navigator/helpers.ts index d5b7557a8..8da57c0f5 100644 --- a/src/services/navigator/helpers.ts +++ b/src/services/navigator/helpers.ts @@ -455,13 +455,13 @@ export const mergeDocument = ( }; export const setSelected = ( - routeDocContext: TypesLegacy.Document | undefined, + doc: TypesLegacy.Document | undefined, id: string | undefined, ) => { - if (!routeDocContext || !id) { + if (!doc || !id) { return; } - const route = getRouteById(routeDocContext, id); + const route = getRouteById(doc, id); if (route) { // Reset all siblings if (route.parentNode && route.parentNode.childNodes) { From af14f07c865bf1403ee32002893069e77c587496 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Wed, 27 Sep 2023 13:51:33 -0400 Subject: [PATCH 20/29] feature(deep link) - provide deep link support for navigation (#691) This is a working prototype of deep links with custom navigators. - For stack navigators, the custom navigator provides an injection of a custom router which overrides the behavior of initial state and updates for new routes - In the override, all routes are pushed onto the stack - For tab navigators, the custom navigator calls a 'navigate' whenever the initial route name prop changes This functionality requires mobile branch https://github.com/Instawork/mobile/tree/hardin/deep-link-url-wip **Tabs** With the default behavior, the initial state is correct: shifts-route (middle tab), and second-shifts (right sub tab). However, the deep link has no effect. The current tab selection remains. Deep link ```xml ``` | initial state | deep link | | -- | -- | | ![initstate](https://github.com/Instawork/hyperview/assets/127122858/9fcb9021-eb6c-40cb-a3f9-ba7759a678d5) | ![deeplink](https://github.com/Instawork/hyperview/assets/127122858/3bf36ccd-50aa-4bb7-8c9b-2167d8ac2512) | With an override navigator, the deep link is able to change the selection in both the main tab and the sub-tab navigators. | initial state | deep link | | -- | -- | | ![initstate](https://github.com/Instawork/hyperview/assets/127122858/9fcb9021-eb6c-40cb-a3f9-ba7759a678d5) | ![deeplink](https://github.com/Instawork/hyperview/assets/127122858/9fd88766-cb00-4880-9ff0-849c3cc9ded1) | **Stacks** With the default behavior, the stack navigator pushes only one screen onto the stack: the one with a naming matching the "initialRouteName" property value. Deep links provide no additional functionality. Initial state (index.xml) ```xml ``` Deep link ```xml ``` Given this initial state, the desired outcome is to have the "company" modal on top of the tabs. | initial state | deep link | | -- | -- | | ![initstate](https://github.com/Instawork/hyperview/assets/127122858/22b801b8-b3c6-45d4-a91a-ce93dea950c0) | ![deeplink](https://github.com/Instawork/hyperview/assets/127122858/b30fe8d7-c437-4970-a4bc-9d146107d84b) | After overriding the navigator and router, the stack is able to push all screens onto the stack in the initial state and is able to push screens onto the stack when a deep link is received. NOTE: There is currently a bug causing the stack initial state to open and immediately close the modal screen. It is caused by the overrides of tabs and stack working against each other. A separate task will be created to look into fixes. | initial state | deep link | | -- | -- | | ![initstate](https://github.com/Instawork/hyperview/assets/127122858/b95b81f4-68ec-4bad-a728-0c7a15604fb9) | ![deeplink](https://github.com/Instawork/hyperview/assets/127122858/370e9d77-a8b2-4216-b5a6-63c05bd08e0e) | Asana: https://app.asana.com/0/1204008699308084/1205129681586820/f --- src/core/components/hv-navigator/index.tsx | 6 +- src/core/components/navigator-stack/index.tsx | 60 ++++++++++++++++ .../components/navigator-stack/router.tsx | 72 +++++++++++++++++++ src/core/components/navigator-stack/types.ts | 68 ++++++++++++++++++ src/core/components/navigator-tab/index.tsx | 65 +++++++++++++++++ src/core/components/navigator-tab/types.tsx | 69 ++++++++++++++++++ 6 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 src/core/components/navigator-stack/index.tsx create mode 100644 src/core/components/navigator-stack/router.tsx create mode 100644 src/core/components/navigator-stack/types.ts create mode 100644 src/core/components/navigator-tab/index.tsx create mode 100644 src/core/components/navigator-tab/types.tsx diff --git a/src/core/components/hv-navigator/index.tsx b/src/core/components/hv-navigator/index.tsx index 6a2e51fef..023e8a84c 100644 --- a/src/core/components/hv-navigator/index.tsx +++ b/src/core/components/hv-navigator/index.tsx @@ -12,6 +12,8 @@ import * as NavigatorService from 'hyperview/src/services/navigator'; import * as Types from './types'; import * as TypesLegacy from 'hyperview/src/types-legacy'; import React, { PureComponent, useContext } from 'react'; +import { createCustomStackNavigator } from 'hyperview/src/core/components/navigator-stack'; +import { createCustomTabNavigator } from 'hyperview/src/core/components/navigator-tab'; import { getFirstChildTag } from 'hyperview/src/services/dom/helpers-legacy'; /** @@ -19,8 +21,8 @@ import { getFirstChildTag } from 'hyperview/src/services/dom/helpers-legacy'; */ const SHOW_NAVIGATION_UI = false; -const Stack = NavigatorService.createStackNavigator(); -const BottomTab = NavigatorService.createBottomTabNavigator(); +const Stack = createCustomStackNavigator(); +const BottomTab = createCustomTabNavigator(); export default class HvNavigator extends PureComponent { /** diff --git a/src/core/components/navigator-stack/index.tsx b/src/core/components/navigator-stack/index.tsx new file mode 100644 index 000000000..dcb8507e1 --- /dev/null +++ b/src/core/components/navigator-stack/index.tsx @@ -0,0 +1,60 @@ +/** + * Copyright (c) Garuda Labs, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import * as CustomStackRouter from 'hyperview/src/core/components/navigator-stack/router'; +import * as React from 'react'; +import * as Types from './types'; + +import { + StackActionHelpers, + StackNavigationState, + createNavigatorFactory, + useNavigationBuilder, +} from '@react-navigation/native'; +import { + StackNavigationEventMap, + StackNavigationOptions, + StackView, +} from '@react-navigation/stack'; + +const CustomStackNavigator = (props: Types.Props) => { + const { + state, + descriptors, + navigation, + NavigationContent, + } = useNavigationBuilder< + StackNavigationState, + Types.StackOptions, + StackActionHelpers, + StackNavigationOptions, + StackNavigationEventMap + >(CustomStackRouter.Router, { + children: props.children, + id: props.id, + initialRouteName: props.initialRouteName, + screenOptions: props.screenOptions, + }); + + return ( + + + + ); +}; + +export const createCustomStackNavigator = createNavigatorFactory< + Readonly, + StackNavigationOptions, + Types.EventMapBase, + typeof CustomStackNavigator +>(CustomStackNavigator); diff --git a/src/core/components/navigator-stack/router.tsx b/src/core/components/navigator-stack/router.tsx new file mode 100644 index 000000000..21fd2763b --- /dev/null +++ b/src/core/components/navigator-stack/router.tsx @@ -0,0 +1,72 @@ +/** + * Copyright (c) Garuda Labs, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import * as NavigatorService from 'hyperview/src/services/navigator'; +import * as Types from './types'; +import { StackNavigationState, StackRouter } from '@react-navigation/native'; + +/** + * Provides a custom stack router that allows us to set the initial route + */ +export const Router = (stackOptions: Types.StackOptions) => { + const router = StackRouter(stackOptions); + + return { + ...router, + + getInitialState(options: Types.RouterConfigOptions) { + const initState = router.getInitialState(options); + + return mutateState(initState, { ...options, routeKeyChanges: [] }); + }, + + getStateForRouteNamesChange( + state: StackNavigationState, + options: Types.RouterRenameOptions, + ) { + const changeState = router.getStateForRouteNamesChange(state, options); + return mutateState(changeState, options); + }, + }; +}; + +/** + * Inject all routes into the state and set the index to the last route + */ +const mutateState = ( + state: StackNavigationState, + options: Types.RouterRenameOptions, +) => { + const routes = Array.from(state.routes); + + const filteredNames = options.routeNames.filter((name: string) => { + return ( + !options.routeKeyChanges?.includes(name) && + !NavigatorService.isDynamicRoute(name) + ); + }); + + filteredNames.forEach((name: string) => { + if (options.routeParamList) { + const params = options.routeParamList[name] || {}; + if (!routes.find(route => route.name === name)) { + routes.push({ + key: name, + name, + params, + }); + } + } + }); + + return { + ...state, + index: routes.length - 1, + routes, + }; +}; diff --git a/src/core/components/navigator-stack/types.ts b/src/core/components/navigator-stack/types.ts new file mode 100644 index 000000000..a4e7bf558 --- /dev/null +++ b/src/core/components/navigator-stack/types.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) Garuda Labs, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import * as React from 'react'; +import { StackNavigationOptions } from '@react-navigation/stack'; + +export type Props = { + id: string; + children?: React.ReactNode; + initialRouteName?: string; + screenOptions?: StackNavigationOptions; +}; + +export type ParamListBase = Record; + +export type RouterConfigOptions = { + routeNames: string[]; + routeParamList: Record; + routeGetIdList: Record< + string, + | ((options: { params?: Record }) => string | undefined) + | undefined + >; +}; + +export type RouterRenameOptions = RouterConfigOptions & { + routeKeyChanges: string[]; +}; + +export type StackOptions = { + id?: string; + initialRouteName?: string; +}; + +/** + * Minimal representation of the 'NavigationState' used by react-navigation + */ +export type NavigationState = { + index: number; + key: string; + routeNames: string[]; + routes: Route[]; + stale: false; + type: string; + history?: unknown[]; +}; + +/** + * Minimal representation of the 'Route' used by react-navigation + */ +export type Route< + RouteName extends string, + Params extends object | undefined = object | undefined +> = { + key: string; + name: RouteName; + params: Params; + state?: NavigationState; +}; + +export type EventMapBase = { + data?: any; +}; diff --git a/src/core/components/navigator-tab/index.tsx b/src/core/components/navigator-tab/index.tsx new file mode 100644 index 000000000..b7d99ade5 --- /dev/null +++ b/src/core/components/navigator-tab/index.tsx @@ -0,0 +1,65 @@ +/** + * Copyright (c) Garuda Labs, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import * as React from 'react'; +import * as Types from './types'; +import { + BottomTabNavigationEventMap, + BottomTabNavigationOptions, + BottomTabView, +} from '@react-navigation/bottom-tabs'; +import { + TabActionHelpers, + TabNavigationState, + TabRouter, + TabRouterOptions, + createNavigatorFactory, + useNavigationBuilder, +} from '@react-navigation/native'; + +const CustomTabNavigator = (props: Types.Props) => { + const { + state, + descriptors, + navigation, + NavigationContent, + } = useNavigationBuilder< + TabNavigationState, + TabRouterOptions, + TabActionHelpers, + BottomTabNavigationOptions, + BottomTabNavigationEventMap + >(TabRouter, { + backBehavior: props.backBehavior, + children: props.children, + id: props.id, + initialRouteName: props.initialRouteName, + screenOptions: props.screenOptions, + }); + + React.useEffect(() => { + navigation.navigate(props.initialRouteName); + }, [props.initialRouteName, navigation]); + + return ( + + + + ); +}; + +export const createCustomTabNavigator = createNavigatorFactory< + Readonly, + object, + Types.EventMapBase, + typeof CustomTabNavigator +>(CustomTabNavigator); diff --git a/src/core/components/navigator-tab/types.tsx b/src/core/components/navigator-tab/types.tsx new file mode 100644 index 000000000..ff8210598 --- /dev/null +++ b/src/core/components/navigator-tab/types.tsx @@ -0,0 +1,69 @@ +/** + * Copyright (c) Garuda Labs, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import * as React from 'react'; +import { BottomTabNavigationOptions } from '@react-navigation/bottom-tabs'; + +export type Props = { + id: string; + backBehavior: 'none' | 'initialRoute' | 'history' | 'order'; + initialRouteName: string; + children: React.ReactNode; + screenOptions: BottomTabNavigationOptions; +}; + +export type ParamListBase = Record; + +export type RouterConfigOptions = { + routeNames: string[]; + routeParamList: Record; + routeGetIdList: Record< + string, + | ((options: { params?: Record }) => string | undefined) + | undefined + >; +}; + +export type RouterRenameOptions = RouterConfigOptions & { + routeKeyChanges: string[]; +}; + +export type TabOptions = { + id?: string; + initialRouteName?: string; +}; + +/** + * Minimal representation of the 'NavigationState' used by react-navigation + */ +export type NavigationState = { + index: number; + key: string; + routeNames: string[]; + routes: Route[]; + stale: false; + type: string; + history?: unknown[]; +}; + +/** + * Minimal representation of the 'Route' used by react-navigation + */ +export type Route< + RouteName extends string, + Params extends object | undefined = object | undefined +> = { + key: string; + name: RouteName; + params: Params; + state?: NavigationState; +}; + +export type EventMapBase = { + data?: any; +}; From 0d35642948f01a86de09f15e650cf662166b386a Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Mon, 2 Oct 2023 18:35:30 -0400 Subject: [PATCH 21/29] chore(bug) - fix navigation (#698) Fix for issue where a tab navigation is auto closing any stack modals. Asana: https://app.asana.com/0/1204008699308084/1205572796301526/f --- src/core/components/navigator-tab/index.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/core/components/navigator-tab/index.tsx b/src/core/components/navigator-tab/index.tsx index b7d99ade5..8cf054115 100644 --- a/src/core/components/navigator-tab/index.tsx +++ b/src/core/components/navigator-tab/index.tsx @@ -43,7 +43,13 @@ const CustomTabNavigator = (props: Types.Props) => { }); React.useEffect(() => { - navigation.navigate(props.initialRouteName); + const curState = navigation.getState(); + const foundIndex = curState.routes.findIndex( + route => route.name === props.initialRouteName, + ); + if (foundIndex > -1) { + navigation.reset({ ...curState, index: foundIndex }); + } }, [props.initialRouteName, navigation]); return ( From 0e4ac0d534a4d17565786e24671b63ac8ceaa320 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Thu, 12 Oct 2023 16:59:15 -0400 Subject: [PATCH 22/29] chore(navigation): cleanup after merging master (#722) Resolving CI issues found after merging master with TS migration into integration branch --- src/core/components/navigator-stack/types.ts | 1 + src/core/components/navigator-tab/types.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/core/components/navigator-stack/types.ts b/src/core/components/navigator-stack/types.ts index a4e7bf558..7f269bd02 100644 --- a/src/core/components/navigator-stack/types.ts +++ b/src/core/components/navigator-stack/types.ts @@ -64,5 +64,6 @@ export type Route< }; export type EventMapBase = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any data?: any; }; diff --git a/src/core/components/navigator-tab/types.tsx b/src/core/components/navigator-tab/types.tsx index ff8210598..221e372b5 100644 --- a/src/core/components/navigator-tab/types.tsx +++ b/src/core/components/navigator-tab/types.tsx @@ -65,5 +65,6 @@ export type Route< }; export type EventMapBase = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any data?: any; }; From 077e3a537a9b2619be7ec2e5717a6cd1c3887fa8 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Tue, 17 Oct 2023 17:00:31 -0400 Subject: [PATCH 23/29] chore(behaviors): centralize behavior trigger code (#726) Moving the behavior trigger code out of `hyper-ref` into a shared location to allow access by other components Asana: https://app.asana.com/0/1204008699308084/1205741965789631/f --- src/core/hyper-ref/index.tsx | 101 ++++++++------------------------ src/core/hyper-ref/types.ts | 18 ------ src/services/behaviors/index.ts | 86 ++++++++++++++++++++++++++- src/types.ts | 17 ++++++ 4 files changed, 127 insertions(+), 95 deletions(-) diff --git a/src/core/hyper-ref/index.tsx b/src/core/hyper-ref/index.tsx index 636ee2ee6..95cbbd3ee 100644 --- a/src/core/hyper-ref/index.tsx +++ b/src/core/hyper-ref/index.tsx @@ -6,28 +6,24 @@ * */ +import * as Behaviors from 'hyperview/src/services/behaviors'; import * as Dom from 'hyperview/src/services/dom'; import * as Events from 'hyperview/src/services/events'; import * as Namespaces from 'hyperview/src/services/namespaces'; import * as Render from 'hyperview/src/services/render'; import { - ACTIONS, + BEHAVIOR_ATTRIBUTES, LOCAL_NAME, - NAV_ACTIONS, PRESS_TRIGGERS, TRIGGERS, - UPDATE_ACTIONS, } from 'hyperview/src/types'; -import { ATTRIBUTES, PRESS_TRIGGERS_PROP_NAMES } from './types'; import type { HvComponentOnUpdate, HvComponentOptions, - NavAction, PressTrigger, StyleSheet, StyleSheets, Trigger, - UpdateAction, } from 'hyperview/src/types'; import type { PressHandlers, PressPropName, Props, State } from './types'; import React, { PureComponent } from 'react'; @@ -37,10 +33,12 @@ import { Text, TouchableOpacity, } from 'react-native'; +import { PRESS_TRIGGERS_PROP_NAMES } from './types'; import VisibilityDetectingView from 'hyperview/src/VisibilityDetectingView'; import { XMLSerializer } from '@instawork/xmldom'; import { X_RESPONSE_STALE_REASON } from 'hyperview/src/services/dom/types'; import { createTestProps } from 'hyperview/src/services'; + /** * Wrapper to handle UI events * Stop propagation and prevent default client behavior @@ -107,7 +105,9 @@ export default class HyperRef extends PureComponent { updateStyle = () => { // Retrieve and cache style - const styleAttr = this.props.element.getAttribute(ATTRIBUTES.HREF_STYLE); + const styleAttr = this.props.element.getAttribute( + BEHAVIOR_ATTRIBUTES.HREF_STYLE, + ); this.style = styleAttr ? styleAttr.split(' ').map(s => this.props.stylesheets.regular[s]) : null; @@ -116,7 +116,7 @@ export default class HyperRef extends PureComponent { onEventDispatch = (eventName: string) => { const behaviorElements = Dom.getBehaviorElements(this.props.element); const onEventBehaviors = behaviorElements.filter(e => { - if (e.getAttribute(ATTRIBUTES.TRIGGER) === TRIGGERS.ON_EVENT) { + if (e.getAttribute(BEHAVIOR_ATTRIBUTES.TRIGGER) === TRIGGERS.ON_EVENT) { const currentAttributeEventName: | string | null @@ -138,7 +138,7 @@ export default class HyperRef extends PureComponent { return false; }); onEventBehaviors.forEach(behaviorElement => { - const handler = this.createActionHandler( + const handler = Behaviors.createActionHandler( behaviorElement, this.props.onUpdate, ); @@ -160,63 +160,16 @@ export default class HyperRef extends PureComponent { }); }; - createActionHandler = ( - behaviorElement: Element, - onUpdate: HvComponentOnUpdate, - ) => { - const action = - behaviorElement.getAttribute(ATTRIBUTES.ACTION) || NAV_ACTIONS.PUSH; - if (Object.values(NAV_ACTIONS).indexOf(action as NavAction) >= 0) { - return (element: Element) => { - const href = behaviorElement.getAttribute(ATTRIBUTES.HREF); - const targetId = behaviorElement.getAttribute(ATTRIBUTES.TARGET); - const showIndicatorId = behaviorElement.getAttribute( - ATTRIBUTES.SHOW_DURING_LOAD, - ); - const delay = behaviorElement.getAttribute(ATTRIBUTES.DELAY); - onUpdate(href, action, element, { delay, showIndicatorId, targetId }); - }; - } - if ( - action === ACTIONS.RELOAD || - Object.values(UPDATE_ACTIONS).indexOf(action as UpdateAction) >= 0 - ) { - return (element: Element) => { - const href = behaviorElement.getAttribute(ATTRIBUTES.HREF); - const verb = behaviorElement.getAttribute(ATTRIBUTES.VERB); - const targetId = behaviorElement.getAttribute(ATTRIBUTES.TARGET); - const showIndicatorIds = behaviorElement.getAttribute( - ATTRIBUTES.SHOW_DURING_LOAD, - ); - const hideIndicatorIds = behaviorElement.getAttribute( - ATTRIBUTES.HIDE_DURING_LOAD, - ); - const delay = behaviorElement.getAttribute(ATTRIBUTES.DELAY); - const once = behaviorElement.getAttribute(ATTRIBUTES.ONCE); - onUpdate(href, action, element, { - behaviorElement, - delay, - hideIndicatorIds, - once, - showIndicatorIds, - targetId, - verb, - }); - }; - } - // Custom behavior - return (element: Element) => - onUpdate(null, action, element, { behaviorElement, custom: true }); - }; - getBehaviorElements = (trigger: Trigger): Element[] => { return this.behaviorElements.filter( - e => e.getAttribute(ATTRIBUTES.TRIGGER) === trigger, + e => e.getAttribute(BEHAVIOR_ATTRIBUTES.TRIGGER) === trigger, ); }; getStyle = (): StyleSheet | null | undefined => { - const styleAttr = this.props.element.getAttribute(ATTRIBUTES.HREF_STYLE); + const styleAttr = this.props.element.getAttribute( + BEHAVIOR_ATTRIBUTES.HREF_STYLE, + ); return styleAttr ? styleAttr.split(' ').map(s => this.props.stylesheets.regular[s]) : null; @@ -233,24 +186,21 @@ export default class HyperRef extends PureComponent { ); loadBehaviors = loadBehaviors.concat(loadStaleBehaviors); } - loadBehaviors.forEach(behaviorElement => { - const handler = this.createActionHandler( - behaviorElement, + + if (loadBehaviors.length > 0) { + Behaviors.triggerBehaviors( + this.props.element, + loadBehaviors, this.props.onUpdate, ); - if (behaviorElement.getAttribute(ATTRIBUTES.IMMEDIATE) === 'true') { - handler(this.props.element); - } else { - setTimeout(() => handler(this.props.element), 0); - } - }); + } }; TouchableView = ({ children }: { children: JSX.Element }): JSX.Element => { const behaviors = this.behaviorElements.filter( e => PRESS_TRIGGERS.indexOf( - (e.getAttribute(ATTRIBUTES.TRIGGER) || + (e.getAttribute(BEHAVIOR_ATTRIBUTES.TRIGGER) || TRIGGERS.PRESS) as PressTrigger, ) >= 0, ); @@ -268,10 +218,11 @@ export default class HyperRef extends PureComponent { behaviors.forEach(behaviorElement => { const trigger = - behaviorElement.getAttribute(ATTRIBUTES.TRIGGER) || TRIGGERS.PRESS; + behaviorElement.getAttribute(BEHAVIOR_ATTRIBUTES.TRIGGER) || + TRIGGERS.PRESS; const triggerPropName = PRESS_TRIGGERS_PROP_NAMES[trigger as PressTrigger]; - const handler = this.createActionHandler( + const handler = Behaviors.createActionHandler( behaviorElement, this.props.onUpdate, ); @@ -387,7 +338,7 @@ export default class HyperRef extends PureComponent { return children; } const refreshHandlers = behaviors.map(behaviorElement => - this.createActionHandler(behaviorElement, this.props.onUpdate), + Behaviors.createActionHandler(behaviorElement, this.props.onUpdate), ); const onRefresh = () => refreshHandlers.forEach(h => h(this.props.element)); @@ -412,7 +363,7 @@ export default class HyperRef extends PureComponent { // the DOM might have been mutated since. this.getBehaviorElements(TRIGGERS.VISIBLE) .map(behaviorElement => - this.createActionHandler(behaviorElement, this.props.onUpdate), + Behaviors.createActionHandler(behaviorElement, this.props.onUpdate), ) .forEach(h => h(this.props.element)); }; @@ -422,7 +373,7 @@ export default class HyperRef extends PureComponent { // and the internal state needs to be reset. const id = this.props.element.getAttribute('id') || - Object.values(ATTRIBUTES) + Object.values(BEHAVIOR_ATTRIBUTES) .reduce((acc: string[], name: string) => { const value = this.props.element.getAttribute(name); return value ? [...acc, `${name}:${value}`] : acc; diff --git a/src/core/hyper-ref/types.ts b/src/core/hyper-ref/types.ts index 2cbb47ec9..7b818b47c 100644 --- a/src/core/hyper-ref/types.ts +++ b/src/core/hyper-ref/types.ts @@ -26,24 +26,6 @@ export type State = { refreshing: boolean; }; -export const ATTRIBUTES = { - ACTION: 'action', - DELAY: 'delay', - EVENT_NAME: 'event-name', - HIDE_DURING_LOAD: 'hide-during-load', - HREF: 'href', - HREF_STYLE: 'href-style', - IMMEDIATE: 'immediate', - NEW_VALUE: 'new-value', - ONCE: 'once', - SHOW_DURING_LOAD: 'show-during-load', - TARGET: 'target', - TRIGGER: 'trigger', - VERB: 'verb', -} as const; - -export type Attribute = typeof ATTRIBUTES[keyof typeof ATTRIBUTES]; - export type PressHandlers = { onLongPress?: () => void; onPressIn?: () => void; diff --git a/src/services/behaviors/index.ts b/src/services/behaviors/index.ts index 39ad14faf..0b3ecb3dd 100644 --- a/src/services/behaviors/index.ts +++ b/src/services/behaviors/index.ts @@ -7,8 +7,17 @@ */ import * as Dom from 'hyperview/src/services/dom'; -import type { HvComponentOnUpdate, UpdateAction } from 'hyperview/src/types'; -import { ACTIONS } from 'hyperview/src/types'; +import { + ACTIONS, + BEHAVIOR_ATTRIBUTES, + NAV_ACTIONS, + UPDATE_ACTIONS, +} from 'hyperview/src/types'; +import type { + HvComponentOnUpdate, + NavAction, + UpdateAction, +} from 'hyperview/src/types'; import { shallowCloneToRoot } from 'hyperview/src/services'; /** @@ -109,6 +118,9 @@ export const performUpdate = ( return shallowCloneToRoot(targetElement); }; +/** + * Trigger all behaviors matching the given name + */ export const trigger = ( name: string, element: Element, @@ -138,3 +150,73 @@ export const trigger = ( }); }); }; + +/** + * Trigger a set of pre-filtered behaviors + */ +export const triggerBehaviors = ( + element: Element, + behaviors: Element[], + onUpdate: HvComponentOnUpdate, +) => { + behaviors.forEach(behaviorElement => { + const handler = createActionHandler(behaviorElement, onUpdate); + if ( + behaviorElement.getAttribute(BEHAVIOR_ATTRIBUTES.IMMEDIATE) === 'true' + ) { + handler(element); + } else { + setTimeout(() => handler(element), 0); + } + }); +}; + +export const createActionHandler = ( + behaviorElement: Element, + onUpdate: HvComponentOnUpdate, +) => { + const action = + behaviorElement.getAttribute(BEHAVIOR_ATTRIBUTES.ACTION) || + NAV_ACTIONS.PUSH; + if (Object.values(NAV_ACTIONS).indexOf(action as NavAction) >= 0) { + return (element: Element) => { + const href = behaviorElement.getAttribute(BEHAVIOR_ATTRIBUTES.HREF); + const targetId = behaviorElement.getAttribute(BEHAVIOR_ATTRIBUTES.TARGET); + const showIndicatorId = behaviorElement.getAttribute( + BEHAVIOR_ATTRIBUTES.SHOW_DURING_LOAD, + ); + const delay = behaviorElement.getAttribute(BEHAVIOR_ATTRIBUTES.DELAY); + onUpdate(href, action, element, { delay, showIndicatorId, targetId }); + }; + } + if ( + action === ACTIONS.RELOAD || + Object.values(UPDATE_ACTIONS).indexOf(action as UpdateAction) >= 0 + ) { + return (element: Element) => { + const href = behaviorElement.getAttribute(BEHAVIOR_ATTRIBUTES.HREF); + const verb = behaviorElement.getAttribute(BEHAVIOR_ATTRIBUTES.VERB); + const targetId = behaviorElement.getAttribute(BEHAVIOR_ATTRIBUTES.TARGET); + const showIndicatorIds = behaviorElement.getAttribute( + BEHAVIOR_ATTRIBUTES.SHOW_DURING_LOAD, + ); + const hideIndicatorIds = behaviorElement.getAttribute( + BEHAVIOR_ATTRIBUTES.HIDE_DURING_LOAD, + ); + const delay = behaviorElement.getAttribute(BEHAVIOR_ATTRIBUTES.DELAY); + const once = behaviorElement.getAttribute(BEHAVIOR_ATTRIBUTES.ONCE); + onUpdate(href, action, element, { + behaviorElement, + delay, + hideIndicatorIds, + once, + showIndicatorIds, + targetId, + verb, + }); + }; + } + // Custom behavior + return (element: Element) => + onUpdate(null, action, element, { behaviorElement, custom: true }); +}; diff --git a/src/types.ts b/src/types.ts index 20cbb28e6..52b3611c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -255,6 +255,23 @@ export type BehaviorRegistry = { [key: string]: HvBehavior; }; +// https://hyperview.org/docs/reference_behavior_attributes +export const BEHAVIOR_ATTRIBUTES = { + ACTION: 'action', + DELAY: 'delay', + EVENT_NAME: 'event-name', + HIDE_DURING_LOAD: 'hide-during-load', + HREF: 'href', + HREF_STYLE: 'href-style', + IMMEDIATE: 'immediate', + NEW_VALUE: 'new-value', + ONCE: 'once', + SHOW_DURING_LOAD: 'show-during-load', + TARGET: 'target', + TRIGGER: 'trigger', + VERB: 'verb', +} as const; + // https://hyperview.org/docs/reference_behavior_attributes#trigger export const TRIGGERS = Object.freeze({ DESELECT: 'deselect', From bcee0802313f28a4e3f005763c85fb093c015635 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Thu, 19 Oct 2023 13:03:31 -0400 Subject: [PATCH 24/29] chore(on update): lift onUpdate and its methods from hv-screen to hv-root (#727) Follow the commits for the individual steps taken - moved the 'once' handling methods into /services/behaviors - updated `BehaviorOptions` to properly reflect the state of the data - created new `RootOnUpdate` and `OnUpdateCallbacks` types - added `onUpdate` to all props which occur from `hv-root` to `hv-screen` - ported the `onUpdate` method and all of its related methods from `hv-screen` to `hv-root` - use the callbacks prop to inject any of the instance-specific callbacks needed for the `onUpdate` - inject the new `onUpdate` into the props Asana: https://app.asana.com/0/1204008699308084/1205741965789634/f --------- Co-authored-by: Florent Bonomo --- src/behaviors/index.ts | 2 + src/contexts/navigation.ts | 8 +- src/core/components/hv-root/index.tsx | 490 ++++++++++++++++++++++++- src/core/components/hv-route/index.tsx | 1 + src/core/components/hv-route/types.ts | 9 +- src/core/components/hv-screen/index.js | 333 ++--------------- src/core/components/hv-screen/types.ts | 1 + src/services/behaviors/index.ts | 16 + src/types.ts | 29 +- 9 files changed, 572 insertions(+), 317 deletions(-) diff --git a/src/behaviors/index.ts b/src/behaviors/index.ts index 410d51174..b3702441e 100644 --- a/src/behaviors/index.ts +++ b/src/behaviors/index.ts @@ -44,4 +44,6 @@ export { setIndicatorsBeforeLoad, performUpdate, setIndicatorsAfterLoad, + isOncePreviouslyApplied, + setRanOnce, } from 'hyperview/src/services/behaviors'; diff --git a/src/contexts/navigation.ts b/src/contexts/navigation.ts index 65e753461..3aa98b42e 100644 --- a/src/contexts/navigation.ts +++ b/src/contexts/navigation.ts @@ -5,7 +5,12 @@ * LICENSE file in the root directory of this source tree. * */ -import type { Fetch, HvBehavior, HvComponent } from 'hyperview/src/types'; +import type { + Fetch, + HvBehavior, + HvComponent, + HvComponentOnUpdate, +} from 'hyperview/src/types'; import React, { ComponentType, ReactNode } from 'react'; @@ -18,6 +23,7 @@ export type NavigationContextProps = { onError?: (error: Error) => void; onParseAfter?: (url: string) => void; onParseBefore?: (url: string) => void; + onUpdate: HvComponentOnUpdate; url?: string; behaviors?: HvBehavior[]; components?: HvComponent[]; diff --git a/src/core/components/hv-root/index.tsx b/src/core/components/hv-root/index.tsx index 6a5fd129f..e2209da23 100644 --- a/src/core/components/hv-root/index.tsx +++ b/src/core/components/hv-root/index.tsx @@ -6,15 +6,35 @@ * */ +import * as Behaviors from 'hyperview/src/behaviors'; +import * as Components from 'hyperview/src/services/components'; import * as Contexts from 'hyperview/src/contexts'; +import * as Dom from 'hyperview/src/services/dom'; +import * as Events from 'hyperview/src/services/events'; import * as HvScreenProps from 'hyperview/src/core/components/hv-screen/types'; import * as NavContexts from 'hyperview/src/contexts/navigation'; +import * as Navigation from 'hyperview/src/services/navigation'; import * as Render from 'hyperview/src/services/render'; import * as Services from 'hyperview/src/services'; - +import * as Stylesheets from 'hyperview/src/services/stylesheets'; +import * as UrlService from 'hyperview/src/services/url'; +import * as Xml from 'hyperview/src/services/xml'; +import { + ACTIONS, + BehaviorRegistry, + ComponentRegistry, + DOMString, + HvComponentOptions, + NAV_ACTIONS, + NavAction, + OnUpdateCallbacks, + UPDATE_ACTIONS, + UpdateAction, +} from 'hyperview/src/types'; import React, { PureComponent } from 'react'; import HvRoute from 'hyperview/src/core/components/hv-route'; import HvScreen from 'hyperview/src/core/components/hv-screen'; +import { Linking } from 'react-native'; /** * Provides routing to the correct path based on the state passed in @@ -28,6 +48,472 @@ export default class Hyperview extends PureComponent { static renderElement = Render.renderElement; + behaviorRegistry: BehaviorRegistry; + + componentRegistry: ComponentRegistry; + + formComponentRegistry: ComponentRegistry; + + parser: Dom.Parser; + + constructor(props: HvScreenProps.Props) { + super(props); + + this.parser = new Dom.Parser( + this.props.fetch, + this.props.onParseBefore, + this.props.onParseAfter, + ); + + this.behaviorRegistry = Behaviors.getRegistry(this.props.behaviors); + this.componentRegistry = Components.getRegistry(this.props.components); + this.formComponentRegistry = Components.getFormRegistry( + this.props.components, + ); + } + + /** + * Reload if an error occured. + * @param opt_href: Optional string href to use when reloading the screen. If not provided, + * the screen's current URL will be used. + */ + reload = ( + optHref: DOMString | null | undefined, + opts: HvComponentOptions, + onUpdateCallbacks: OnUpdateCallbacks, + ) => { + const isBlankHref = + optHref === null || + optHref === undefined || + optHref === '#' || + optHref === ''; + const stateUrl = onUpdateCallbacks.getState().url; + const url = isBlankHref + ? stateUrl + : UrlService.getUrlFromHref(optHref, stateUrl || ''); + + if (!url) { + return; + } + + const options = opts || {}; + const { + behaviorElement, + showIndicatorIds, + hideIndicatorIds, + once, + onEnd, + } = options; + + const showIndicatorIdList = showIndicatorIds + ? Xml.splitAttributeList(showIndicatorIds) + : []; + const hideIndicatorIdList = hideIndicatorIds + ? Xml.splitAttributeList(hideIndicatorIds) + : []; + + if (once && behaviorElement) { + if (behaviorElement.getAttribute('ran-once')) { + // This action is only supposed to run once, and it already ran, + // so there's nothing more to do. + if (typeof onEnd === 'function') { + onEnd(); + } + return; + } + Behaviors.setRanOnce(behaviorElement); + } + + let newRoot = onUpdateCallbacks.getDoc(); + if (showIndicatorIdList || hideIndicatorIdList) { + newRoot = Behaviors.setIndicatorsBeforeLoad( + showIndicatorIdList, + hideIndicatorIdList, + newRoot, + ); + } + + // Re-render the modifications + onUpdateCallbacks.setNeedsLoad(); + onUpdateCallbacks.setState({ + doc: newRoot, + elementError: null, + error: null, + url, + }); + }; + + /** + * Fetches the provided reference. + * - If the references is an id reference (starting with #), + * returns a clone of that element. + * - If the reference is a full URL, fetches the URL. + * - If the reference is a path, fetches the path from the host of the URL + * used to render the screen. + * Returns a promise that resolves to a DOM element. + */ + fetchElement = async ( + href: DOMString | null | undefined, + method: DOMString | null | undefined = Dom.HTTP_METHODS.GET, + root: Document, + formData: FormData | null | undefined, + onUpdateCallbacks: OnUpdateCallbacks, + ): Promise => { + if (!href) { + throw new Error('No href passed to fetchElement'); + } + + if (href[0] === '#') { + const element = root.getElementById(href.slice(1)); + if (element) { + return element.cloneNode(true) as Element; + } + throw new Error(`Element with id ${href} not found in document`); + } + + try { + const url = UrlService.getUrlFromHref( + href, + onUpdateCallbacks.getState().url || '', + ); + const httpMethod: Dom.HttpMethod = method as Dom.HttpMethod; + const { doc, staleHeaderType } = await this.parser.loadElement( + url, + formData || null, + httpMethod, + ); + if (staleHeaderType) { + // We are doing this to ensure that we keep the screen stale until a `reload` happens + onUpdateCallbacks.setState({ staleHeaderType }); + } + onUpdateCallbacks.clearElementError(); + return doc.documentElement; + } catch (err: Error | unknown) { + if (this.props.onError) { + this.props.onError(err as Error); + } + onUpdateCallbacks.setState({ elementError: err as Error }); + } + return null; + }; + + onUpdate = ( + href: DOMString | null | undefined, + action: DOMString | null | undefined, + element: Element, + options: HvComponentOptions, + ) => { + if (!options.onUpdateCallbacks) { + console.warn('onUpdate requires an onUpdateCallbacks object'); + return; + } + const navAction: NavAction = action as NavAction; + const updateAction: UpdateAction = action as UpdateAction; + + if (action === ACTIONS.RELOAD) { + this.reload(href, options, options.onUpdateCallbacks); + } else if (action === ACTIONS.DEEP_LINK && href) { + Linking.openURL(href); + } else if (navAction && Object.values(NAV_ACTIONS).includes(navAction)) { + const navigation = options.onUpdateCallbacks.getNavigation(); + if (navigation) { + const { behaviorElement, delay, newElement, targetId } = options; + const delayVal: number = +(delay || ''); + navigation.setUrl(options.onUpdateCallbacks.getState().url || ''); + navigation.setDocument(options.onUpdateCallbacks.getDoc()); + navigation.navigate( + href || Navigation.ANCHOR_ID_SEPARATOR, + navAction, + element, + this.formComponentRegistry, + { + behaviorElement: behaviorElement || undefined, + delay: delayVal, + newElement: newElement || undefined, + targetId: targetId || undefined, + }, + options.onUpdateCallbacks.registerPreload, + ); + } + } else if ( + updateAction && + Object.values(UPDATE_ACTIONS).includes(updateAction) + ) { + this.onUpdateFragment( + href, + updateAction, + element, + options, + options.onUpdateCallbacks, + ); + } else if (action === ACTIONS.SWAP) { + this.onSwap(element, options.newElement, options.onUpdateCallbacks); + } else if (action === ACTIONS.DISPATCH_EVENT) { + const { behaviorElement } = options; + if (!behaviorElement) { + console.warn('dispatch-event requires a behaviorElement'); + return; + } + const eventName = behaviorElement.getAttribute('event-name'); + const trigger = behaviorElement.getAttribute('trigger'); + const delay = behaviorElement.getAttribute('delay'); + + if (Behaviors.isOncePreviouslyApplied(behaviorElement)) { + return; + } + + Behaviors.setRanOnce(behaviorElement); + + // Check for event loop formation + if (trigger === 'on-event') { + throw new Error( + 'trigger="on-event" and action="dispatch-event" cannot be used on the same element', + ); + } + if (!eventName) { + throw new Error( + 'dispatch-event requires an event-name attribute to be present', + ); + } + + const dispatch = () => { + Events.dispatch(eventName); + }; + + if (delay) { + setTimeout(dispatch, parseInt(delay, 10)); + } else { + dispatch(); + } + } else { + const { behaviorElement } = options; + this.onCustomUpdate(behaviorElement, options.onUpdateCallbacks); + } + }; + + /** + * Handler for behaviors on the screen. + * @param href {string} A reference to the XML to fetch. Can be local (via id reference prepended + * by #) or a + * remote resource. + * @param action {string} The name of the action to perform with the returned XML. + * @param element {Element} The XML DOM element triggering the behavior. + * @param options {Object} Optional attributes: + * - verb: The HTTP method to use for the request + * - targetId: An id reference of the element to apply the action to. Defaults to currentElement + * if not provided. + * - showIndicatorIds: Space-separated list of id references to show during the fetch. + * - hideIndicatorIds: Space-separated list of id references to hide during the fetch. + * - delay: Minimum time to wait to fetch the resource. Indicators will be shown/hidden during + * this time. + * - once: If true, the action should only trigger once. If already triggered, onUpdate will be + * a no-op. + * - onEnd: Callback to run when the resource is fetched. + * - behaviorElement: The behavior element triggering the behavior. Can be different from + * the currentElement. + * @param onUpdateCallbacks {OnUpdateCallbacks} Callbacks to pass state + * back to the update handlers + */ + onUpdateFragment = ( + href: DOMString | null | undefined, + action: UpdateAction, + element: Element, + options: HvComponentOptions, + onUpdateCallbacks: OnUpdateCallbacks, + ) => { + const opts = options || {}; + const { + behaviorElement, + verb, + targetId, + showIndicatorIds, + hideIndicatorIds, + delay, + once, + onEnd, + } = opts; + + const showIndicatorIdList = showIndicatorIds + ? Xml.splitAttributeList(showIndicatorIds) + : []; + const hideIndicatorIdList = hideIndicatorIds + ? Xml.splitAttributeList(hideIndicatorIds) + : []; + + const formData = Services.getFormData(element, this.formComponentRegistry); + + if (once && behaviorElement) { + if (behaviorElement.getAttribute('ran-once')) { + // This action is only supposed to run once, and it already ran, + // so there's nothing more to do. + if (typeof onEnd === 'function') { + onEnd(); + } + return; + } + Behaviors.setRanOnce(behaviorElement); + } + + let newRoot: Document = onUpdateCallbacks.getDoc(); + newRoot = Behaviors.setIndicatorsBeforeLoad( + showIndicatorIdList, + hideIndicatorIdList, + newRoot, + ); + // Re-render the modifications + onUpdateCallbacks.setState({ + doc: newRoot, + }); + + // Fetch the resource, then perform the action on the target and undo indicators. + const fetchAndUpdate = () => + this.fetchElement(href, verb, newRoot, formData, onUpdateCallbacks).then( + newElement => { + // If a target is specified and exists, use it. Otherwise, the action target defaults + // to the element triggering the action. + let targetElement = targetId + ? onUpdateCallbacks.getDoc()?.getElementById(targetId) + : element; + if (!targetElement) { + targetElement = element; + } + + if (newElement) { + newRoot = Behaviors.performUpdate( + action, + targetElement, + newElement as Element, + ); + } else { + // When fetch fails, make sure to get the latest version of + // the doc to avoid any race conditions + newRoot = onUpdateCallbacks.getDoc(); + } + newRoot = Behaviors.setIndicatorsAfterLoad( + showIndicatorIdList, + hideIndicatorIdList, + newRoot, + ); + // Re-render the modifications + onUpdateCallbacks.setState({ + doc: newRoot, + }); + + if (typeof onEnd === 'function') { + onEnd(); + } + }, + ); + + if (delay) { + /** + * Delayed behaviors will only trigger after a given amount of time. + * During that time, the DOM may change and the triggering element may no longer + * be in the document. When that happens, we don't want to trigger the behavior after the time + * elapses. To track this, we store the timeout id (generated by setTimeout) on the triggering + * element, and then look it up in the document after the elapsed time. + * If the timeout id is not present, we update the indicators but don't execute the behavior. + */ + const delayMs = parseInt(delay, 10); + const timeoutId = setTimeout(() => { + // Check the current doc for an element with the same timeout ID + const timeoutElement = Services.getElementByTimeoutId( + onUpdateCallbacks.getDoc(), + timeoutId.toString(), + ); + if (timeoutElement) { + // Element with the same ID exists, we can execute the behavior + Services.removeTimeoutId(timeoutElement); + fetchAndUpdate(); + } else { + // Element with the same ID does not exist, + // we don't execute the behavior and undo the indicators. + newRoot = Behaviors.setIndicatorsAfterLoad( + showIndicatorIdList, + hideIndicatorIdList, + onUpdateCallbacks.getDoc(), + ); + onUpdateCallbacks.setState({ + doc: newRoot, + }); + if (typeof onEnd === 'function') { + onEnd(); + } + } + }, delayMs); + // Store the timeout ID + Services.setTimeoutId(element, timeoutId.toString()); + } else { + // If there's no delay, fetch immediately and update the doc when done. + fetchAndUpdate(); + } + }; + + /** + * Used internally to update the state of things like select forms. + */ + onSwap = ( + currentElement: Element, + newElement: Element | null | undefined, + onUpdateCallbacks: OnUpdateCallbacks, + ) => { + const parentElement = currentElement.parentNode as Element; + if (!parentElement || !newElement) { + return; + } + parentElement.replaceChild(newElement, currentElement); + const newRoot = Services.shallowCloneToRoot(parentElement); + onUpdateCallbacks.setState({ + doc: newRoot, + }); + }; + + /** + * Extensions for custom behaviors. + */ + onCustomUpdate = ( + behaviorElement: Element | null | undefined, + onUpdateCallbacks: OnUpdateCallbacks, + ) => { + if (!behaviorElement) { + console.warn('Custom behavior requires a behaviorElement'); + return; + } + const action = behaviorElement.getAttribute('action'); + if (!action) { + console.warn('Custom behavior requires an action attribute'); + return; + } + const behavior = this.behaviorRegistry[action]; + + if (Behaviors.isOncePreviouslyApplied(behaviorElement)) { + return; + } + + Behaviors.setRanOnce(behaviorElement); + + if (behavior) { + const updateRoot = (newRoot: Document, updateStylesheet = false) => { + return updateStylesheet + ? onUpdateCallbacks.setState({ + doc: newRoot, + styles: Stylesheets.createStylesheets(newRoot), + }) + : onUpdateCallbacks.setState({ doc: newRoot }); + }; + const getRoot = () => onUpdateCallbacks.getDoc(); + behavior.callback( + behaviorElement, + onUpdateCallbacks.getOnUpdate(), + getRoot, + updateRoot, + ); + } else { + // No behavior detected. + console.warn(`No behavior registered for action "${action}"`); + } + }; + render() { if (this.props.navigation) { // Externally provided navigation will use the provided navigation and action callbacks @@ -51,6 +537,7 @@ export default class Hyperview extends PureComponent { onError={this.props.onError} onParseAfter={this.props.onParseAfter} onParseBefore={this.props.onParseBefore} + onUpdate={this.onUpdate} openModal={this.props.openModal} push={this.props.push} refreshControl={this.props.refreshControl} @@ -79,6 +566,7 @@ export default class Hyperview extends PureComponent { onError: this.props.onError, onParseAfter: this.props.onParseAfter, onParseBefore: this.props.onParseBefore, + onUpdate: this.onUpdate, }} > diff --git a/src/core/components/hv-route/index.tsx b/src/core/components/hv-route/index.tsx index 78a05a67a..9b0e4fedf 100644 --- a/src/core/components/hv-route/index.tsx +++ b/src/core/components/hv-route/index.tsx @@ -270,6 +270,7 @@ class HvRouteInner extends PureComponent { onError={this.props.onError} onParseAfter={this.props.onParseAfter} onParseBefore={this.props.onParseBefore} + onUpdate={this.props.onUpdate} openModal={this.navLogic.openModal} push={this.navLogic.push} registerPreload={this.registerPreload} diff --git a/src/core/components/hv-route/types.ts b/src/core/components/hv-route/types.ts index b56eff4f9..48ca388c6 100644 --- a/src/core/components/hv-route/types.ts +++ b/src/core/components/hv-route/types.ts @@ -8,7 +8,12 @@ import * as NavigatorService from 'hyperview/src/services/navigator'; import { ComponentType, ReactNode } from 'react'; -import { Fetch, HvBehavior, HvComponent } from 'hyperview/src/types'; +import { + Fetch, + HvBehavior, + HvComponent, + HvComponentOnUpdate, +} from 'hyperview/src/types'; import type { Props as ErrorProps } from 'hyperview/src/core/components/load-error'; import type { Props as LoadingProps } from 'hyperview/src/core/components/loading'; @@ -27,6 +32,7 @@ export type NavigationContextProps = { fetch: Fetch; onParseAfter?: (url: string) => void; onParseBefore?: (url: string) => void; + onUpdate: HvComponentOnUpdate; url?: string; behaviors?: HvBehavior[]; components?: HvComponent[]; @@ -63,6 +69,7 @@ export type InnerRouteProps = { onError?: (error: Error) => void; onParseAfter?: (url: string) => void; onParseBefore?: (url: string) => void; + onUpdate: HvComponentOnUpdate; behaviors?: HvBehavior[]; components?: HvComponent[]; elementErrorComponent?: ComponentType; diff --git a/src/core/components/hv-screen/index.js b/src/core/components/hv-screen/index.js index 102dd90f2..d24138afd 100644 --- a/src/core/components/hv-screen/index.js +++ b/src/core/components/hv-screen/index.js @@ -15,16 +15,12 @@ import * as Events from 'hyperview/src/services/events'; import * as Namespaces from 'hyperview/src/services/namespaces'; import * as Render from 'hyperview/src/services/render'; import * as Stylesheets from 'hyperview/src/services/stylesheets'; -import * as UrlService from 'hyperview/src/services/url'; -import * as Xml from 'hyperview/src/services/xml'; -import { ACTIONS, NAV_ACTIONS, UPDATE_ACTIONS } from 'hyperview/src/types'; -// eslint-disable-next-line instawork/import-services -import Navigation, { ANCHOR_ID_SEPARATOR } from 'hyperview/src/services/navigation'; -import { createProps, createStyleProp, getElementByTimeoutId, getFormData, later, removeTimeoutId, setTimeoutId, shallowCloneToRoot } from 'hyperview/src/services'; -import { Linking } from 'react-native'; +import { createProps, createStyleProp, later } from 'hyperview/src/services'; import LoadElementError from '../load-element-error'; import LoadError from 'hyperview/src/core/components/load-error'; import Loading from 'hyperview/src/core/components/loading'; +// eslint-disable-next-line instawork/import-services +import Navigation from 'hyperview/src/services/navigation'; import React from 'react'; // eslint-disable-next-line instawork/pure-components @@ -41,7 +37,6 @@ export default class HvScreen extends React.Component { super(props); this.onUpdate = this.onUpdate.bind(this); - this.reload = this.reload.bind(this); this.updateActions = ['replace', 'replace-inner', 'append', 'prepend']; this.parser = new Dom.Parser( @@ -236,60 +231,6 @@ export default class HvScreen extends React.Component { } } - /** - * Reload if an error occured. - * @param opt_href: Optional string href to use when reloading the screen. If not provided, - * the screen's current URL will be used. - */ - reload = (optHref, opts) => { - const isBlankHref = - optHref === null || - optHref === undefined || - optHref === '#' || - optHref === ''; - const url = isBlankHref - ? this.state.url // eslint-disable-line react/no-access-state-in-setstate - : UrlService.getUrlFromHref(optHref, this.state.url); // eslint-disable-line react/no-access-state-in-setstate - - if (!url) { - return; - } - - const options = opts || {}; - const { - behaviorElement, showIndicatorIds, hideIndicatorIds, once, onEnd, - } = options; - - const showIndicatorIdList = showIndicatorIds ? Xml.splitAttributeList(showIndicatorIds) : []; - const hideIndicatorIdList = hideIndicatorIds ? Xml.splitAttributeList(hideIndicatorIds) : []; - - if (once) { - if (behaviorElement.getAttribute('ran-once')) { - // This action is only supposed to run once, and it already ran, - // so there's nothing more to do. - if (typeof onEnd === 'function') { - onEnd(); - } - return; - } - behaviorElement.setAttribute('ran-once', 'true'); - } - - let newRoot = this.doc; - if (showIndicatorIdList || hideIndicatorIdList){ - newRoot = Behaviors.setIndicatorsBeforeLoad(showIndicatorIdList, hideIndicatorIdList, newRoot); - } - - // Re-render the modifications - this.needsLoad = true; - this.setState({ - doc: newRoot, - elementError: null, - error: null, - url, - }); - } - /** * Renders the XML doc into React components. Shows blank screen until the XML doc is available. */ @@ -332,22 +273,6 @@ export default class HvScreen extends React.Component { ); } - /** - * Checks if `once` is previously applied. - */ - isOncePreviouslyApplied = (behaviorElement) => { - const once = behaviorElement.getAttribute('once'); - const ranOnce = behaviorElement.getAttribute('ran-once'); - if (once === 'true' && ranOnce === 'true') { - return true; - } - return false; - } - - setRanOnce = (behaviorElement) => { - behaviorElement.setAttribute('ran-once', 'true'); - } - /** * Returns a navigation object similar to the one provided by React Navigation, * but connected to props injected by the parent app. @@ -360,44 +285,6 @@ export default class HvScreen extends React.Component { push: this.props.push, }) - /** - * Fetches the provided reference. - * - If the references is an id reference (starting with #), - * returns a clone of that element. - * - If the reference is a full URL, fetches the URL. - * - If the reference is a path, fetches the path from the host of the URL - * used to render the screen. - * Returns a promise that resolves to a DOM element. - */ - fetchElement = async (href, method, root, formData) => { - if (href[0] === '#') { - const element = root.getElementById(href.slice(1)); - if (element) { - return element.cloneNode(true); - } - throw new Error(`Element with id ${href} not found in document`); - } - - try { - const url = UrlService.getUrlFromHref(href, this.state.url, method); - const { doc, staleHeaderType } = await this.parser.loadElement(url, formData, method); - if (staleHeaderType) { - // We are doing this to ensure that we keep the screen stale until a `reload` happens - this.setState({ staleHeaderType }); - } - if (this.state.elementError) { - this.setState({ elementError: null }); - } - return doc.documentElement; - } catch (err) { - if (this.props.onError) { - this.props.onError(err); - } - this.setState({ elementError: err }); - } - return null; - } - registerPreload = (id, element) => { if (this.props.registerPreload){ this.props.registerPreload(id, element); @@ -408,205 +295,27 @@ export default class HvScreen extends React.Component { * */ onUpdate = (href, action, currentElement, opts) => { - if (action === ACTIONS.RELOAD) { - this.reload(href, opts); - } else if (action === ACTIONS.DEEP_LINK) { - Linking.openURL(href); - } else if (Object.values(NAV_ACTIONS).includes(action)) { - this.navigation.setUrl(this.state.url); - this.navigation.setDocument(this.doc); - this.navigation.navigate(href || ANCHOR_ID_SEPARATOR, action, currentElement, this.formComponentRegistry, opts, this.registerPreload); - } else if (Object.values(UPDATE_ACTIONS).includes(action)) { - this.onUpdateFragment(href, action, currentElement, opts); - } else if (action === ACTIONS.SWAP) { - this.onSwap(currentElement, opts.newElement); - } else if (action === ACTIONS.DISPATCH_EVENT) { - const { behaviorElement } = opts; - const eventName = behaviorElement.getAttribute('event-name'); - const trigger = behaviorElement.getAttribute('trigger'); - const delay = behaviorElement.getAttribute('delay'); - - if (this.isOncePreviouslyApplied(behaviorElement)) { - return; - } - - this.setRanOnce(behaviorElement); - - // Check for event loop formation - if (trigger === 'on-event') { - throw new Error('trigger="on-event" and action="dispatch-event" cannot be used on the same element'); - } - if (!eventName) { - throw new Error('dispatch-event requires an event-name attribute to be present'); - } - - const dispatch = () => { - Events.dispatch(eventName); - } - - if (delay) { - setTimeout(dispatch, parseInt(delay, 10)); - } else { - dispatch(); - } - } else { - const { behaviorElement } = opts; - this.onCustomUpdate(behaviorElement); - } - } - - /** - * Handler for behaviors on the screen. - * @param href {string} A reference to the XML to fetch. Can be local (via id reference prepended - * by #) or a - * remote resource. - * @param action {string} The name of the action to perform with the returned XML. - * @param currentElement {Element} The XML DOM element triggering the behavior. - * @param options {Object} Optional attributes: - * - verb: The HTTP method to use for the request - * - targetId: An id reference of the element to apply the action to. Defaults to currentElement - * if not provided. - * - showIndicatorIds: Space-separated list of id references to show during the fetch. - * - hideIndicatorIds: Space-separated list of id references to hide during the fetch. - * - delay: Minimum time to wait to fetch the resource. Indicators will be shown/hidden during - * this time. - * - once: If true, the action should only trigger once. If already triggered, onUpdate will be - * a no-op. - * - onEnd: Callback to run when the resource is fetched. - * - behaviorElement: The behavior element triggering the behavior. Can be different from - * the currentElement. - */ - onUpdateFragment = (href, action, currentElement, opts) => { - const options = opts || {}; - const { - behaviorElement, verb, targetId, showIndicatorIds, hideIndicatorIds, delay, once, onEnd, - } = options; - - const showIndicatorIdList = showIndicatorIds ? Xml.splitAttributeList(showIndicatorIds) : []; - const hideIndicatorIdList = hideIndicatorIds ? Xml.splitAttributeList(hideIndicatorIds) : []; - - const formData = getFormData(currentElement, this.formComponentRegistry); - - if (once) { - if (behaviorElement.getAttribute('ran-once')) { - // This action is only supposed to run once, and it already ran, - // so there's nothing more to do. - if (typeof onEnd === 'function') { - onEnd(); - } - return; - } - behaviorElement.setAttribute('ran-once', 'true'); - - } - - let newRoot = this.doc; - newRoot = Behaviors.setIndicatorsBeforeLoad(showIndicatorIdList, hideIndicatorIdList, newRoot); - // Re-render the modifications - this.setState({ - doc: newRoot, - }); - - // Fetch the resource, then perform the action on the target and undo indicators. - const fetchAndUpdate = () => this.fetchElement(href, verb, newRoot, formData) - .then((newElement) => { - // If a target is specified and exists, use it. Otherwise, the action target defaults - // to the element triggering the action. - let targetElement = targetId ? this.doc?.getElementById(targetId) : currentElement; - if (!targetElement) { - targetElement = currentElement; - } - - if (newElement) { - newRoot = Behaviors.performUpdate(action, targetElement, newElement); - } else { - // When fetch fails, make sure to get the latest version of the doc to avoid any race conditions - newRoot = this.doc; - } - newRoot = Behaviors.setIndicatorsAfterLoad(showIndicatorIdList, hideIndicatorIdList, newRoot); - // Re-render the modifications - this.setState({ - doc: newRoot, - }); - - if (typeof onEnd === 'function') { - onEnd(); - } - }); - - if (delay) { - /** - * Delayed behaviors will only trigger after a given amount of time. - * During that time, the DOM may change and the triggering element may no longer - * be in the document. When that happens, we don't want to trigger the behavior after the time - * elapses. To track this, we store the timeout id (generated by setTimeout) on the triggering - * element, and then look it up in the document after the elapsed time. If the timeout id is not - * present, we update the indicators but don't execute the behavior. - */ - const delayMs = parseInt(delay, 10); - let timeoutId = null; - timeoutId = setTimeout(() => { - // Check the current doc for an element with the same timeout ID - const timeoutElement = getElementByTimeoutId(this.doc, timeoutId.toString()); - if (timeoutElement) { - // Element with the same ID exists, we can execute the behavior - removeTimeoutId(timeoutElement); - fetchAndUpdate(); - } else { - // Element with the same ID does not exist, we don't execute the behavior and undo the indicators. - newRoot = Behaviors.setIndicatorsAfterLoad(showIndicatorIdList, hideIndicatorIdList, this.doc); - this.setState({ - doc: newRoot, - }); - if (typeof onEnd === 'function') { - onEnd(); + this.props.onUpdate(href, action, currentElement, {...opts, + onUpdateCallbacks: { + clearElementError: () => { + if (this.state.elementError) { + this.setState({ elementError: null }); } - } - }, delayMs); - // Store the timeout ID - setTimeoutId(currentElement, timeoutId.toString()); - } else { - // If there's no delay, fetch immediately and update the doc when done. - fetchAndUpdate(); - } - } - - /** - * Used internally to update the state of things like select forms. - */ - onSwap = (currentElement, newElement) => { - const parentElement = currentElement.parentNode; - parentElement.replaceChild(newElement, currentElement); - const newRoot = shallowCloneToRoot(parentElement); - this.setState({ - doc: newRoot, + }, + getDoc: () => this.doc, + getNavigation: () => this.navigation, + getOnUpdate: () => this.onUpdate, + getState: () => this.state, + registerPreload: (id, element)=>this.registerPreload(id, element), + setNeedsLoad: () => { + this.needsLoad = true + }, + setState: (state) => { + this.setState(state) + }, + } }); } - - /** - * Extensions for custom behaviors. - */ - onCustomUpdate = (behaviorElement) => { - const action = behaviorElement.getAttribute('action'); - const behavior = this.behaviorRegistry[action]; - - if (this.isOncePreviouslyApplied(behaviorElement)) { - return; - } - - this.setRanOnce(behaviorElement); - - if (behavior) { - const updateRoot = (newRoot, updateStylesheet = false) => updateStylesheet - ? this.setState({ doc: newRoot, styles: Stylesheets.createStylesheets(newRoot) }) - : this.setState({ doc: newRoot }); - const getRoot = () => this.doc; - behavior.callback(behaviorElement, this.onUpdate, getRoot, updateRoot); - } else { - // No behavior detected. - console.warn(`No behavior registered for action "${action}"`); - } - } } export * from 'hyperview/src/types'; diff --git a/src/core/components/hv-screen/types.ts b/src/core/components/hv-screen/types.ts index a0df5c078..e1f6f16ad 100644 --- a/src/core/components/hv-screen/types.ts +++ b/src/core/components/hv-screen/types.ts @@ -31,6 +31,7 @@ export type Props = { onError?: (error: Error) => void; onParseAfter?: (url: string) => void; onParseBefore?: (url: string) => void; + onUpdate: Types.HvComponentOnUpdate; url?: string; back?: ( params: TypesLegacy.NavigationRouteParams | object | undefined, diff --git a/src/services/behaviors/index.ts b/src/services/behaviors/index.ts index 0b3ecb3dd..90f20f352 100644 --- a/src/services/behaviors/index.ts +++ b/src/services/behaviors/index.ts @@ -220,3 +220,19 @@ export const createActionHandler = ( return (element: Element) => onUpdate(null, action, element, { behaviorElement, custom: true }); }; + +/** + * Set the `ran-once` attribute to true. + */ +export const setRanOnce = (behaviorElement: Element) => { + behaviorElement.setAttribute('ran-once', 'true'); +}; + +/** + * Checks if `once` is previously applied. + */ +export const isOncePreviouslyApplied = (behaviorElement: Element): boolean => { + const once = behaviorElement.getAttribute('once'); + const ranOnce = behaviorElement.getAttribute('ran-once'); + return once === 'true' && ranOnce === 'true'; +}; diff --git a/src/types.ts b/src/types.ts index 52b3611c2..26e49b8e3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,10 @@ * LICENSE file in the root directory of this source tree. * */ + +import * as Stylesheets from './services/stylesheets'; +import Navigation from './services/navigation'; + import type React from 'react'; import type { XResponseStaleReason } from './services/dom/types'; @@ -202,6 +206,7 @@ export type HvComponentOptions = { custom?: boolean; newElement?: Element | null | undefined; showIndicatorId?: string | null | undefined; + onUpdateCallbacks?: OnUpdateCallbacks; }; export type HvComponentOnUpdate = ( @@ -369,8 +374,8 @@ export const UPDATE_ACTIONS = { export type UpdateAction = typeof UPDATE_ACTIONS[keyof typeof UPDATE_ACTIONS]; export type BehaviorOptions = { - newElement: Element; - behaviorElement: Element; + newElement?: Element; + behaviorElement?: Element; showIndicatorId?: string; delay?: number; targetId?: string; @@ -400,3 +405,23 @@ export type Fetch = ( input: RequestInfo | URL, init?: RequestInit | undefined, ) => Promise; + +export type OnUpdateCallbacks = { + clearElementError: () => void; + getNavigation: () => Navigation; + getOnUpdate: () => HvComponentOnUpdate; + getDoc: () => Document; + registerPreload: (id: number, element: Element) => void; + setNeedsLoad: () => void; + getState: () => ScreenState; + setState: (state: ScreenState) => void; +}; + +export type ScreenState = { + doc?: Document | null | undefined; + elementError?: Error | null | undefined; + error?: Error | null | undefined; + staleHeaderType?: 'stale-if-error' | null | undefined; + styles?: Stylesheets.StyleSheets | null | undefined; + url?: string | null | undefined; +}; From 638146e94ec90fb993dedb7e3d34496c527e87fb Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Thu, 19 Oct 2023 17:00:56 -0400 Subject: [PATCH 25/29] chore(document): account for possible null doc in state (#728) Both `hv-screen` and `hv-route` have the potential to have a null `doc` in state. This can happen from a bad load, or from an override document being provided by a parent component. The code used in `onUpdate` and its related methods were not accounting for the possibility of a null `doc'. - Updated `ScreenState` to reflect the values of the state as assigned in `hv-screen` - Updated callback signature to allow `getDoc` to return null - Updated `hv-root` implementation of `onUpdate` and related to account for null doc - Updated `HvGetRoot` to account for null doc - Updated behavior implementations which used `HvGetRoot` to account for null doc Asana: https://app.asana.com/0/0/1205741965789643/f --- src/behaviors/hv-hide/index.ts | 21 +++--- src/behaviors/hv-select-all/index.ts | 21 +++--- src/behaviors/hv-set-value/index.ts | 21 +++--- src/behaviors/hv-show/index.ts | 21 +++--- src/behaviors/hv-toggle/index.ts | 21 +++--- src/behaviors/hv-unselect-all/index.ts | 21 +++--- src/core/components/hv-root/index.tsx | 89 +++++++++++++++----------- src/types.ts | 16 ++--- 8 files changed, 131 insertions(+), 100 deletions(-) diff --git a/src/behaviors/hv-hide/index.ts b/src/behaviors/hv-hide/index.ts index 512621f6f..180414169 100644 --- a/src/behaviors/hv-hide/index.ts +++ b/src/behaviors/hv-hide/index.ts @@ -35,8 +35,8 @@ export default { ); const hideElement = () => { - const doc: Document = getRoot(); - const targetElement: Element | null | undefined = doc.getElementById( + const doc: Document | null = getRoot(); + const targetElement: Element | null | undefined = doc?.getElementById( targetId, ); if (!targetElement) { @@ -65,13 +65,16 @@ export default { hideElement(); } else { // If there's a delay, first trigger the indicators before the hide - const newRoot = Behaviors.setIndicatorsBeforeLoad( - showIndicatorIds, - hideIndicatorIds, - getRoot(), - ); - // Update the DOM to reflect the new state of the indicators. - updateRoot(newRoot); + const doc: Document | null = getRoot(); + if (doc) { + const newRoot = Behaviors.setIndicatorsBeforeLoad( + showIndicatorIds, + hideIndicatorIds, + doc, + ); + // Update the DOM to reflect the new state of the indicators. + updateRoot(newRoot); + } // Wait for the delay then hide the target. later(delay).then(hideElement).catch(hideElement); } diff --git a/src/behaviors/hv-select-all/index.ts b/src/behaviors/hv-select-all/index.ts index 69636121b..6e72e7482 100644 --- a/src/behaviors/hv-select-all/index.ts +++ b/src/behaviors/hv-select-all/index.ts @@ -37,8 +37,8 @@ export default { ); const selectAll = () => { - const doc: Document = getRoot(); - const targetElement: Element | null | undefined = doc.getElementById( + const doc: Document | null = getRoot(); + const targetElement: Element | null | undefined = doc?.getElementById( targetId, ); if (!targetElement) { @@ -66,13 +66,16 @@ export default { selectAll(); } else { // If there's a delay, first trigger the indicators before the select-all. - const newRoot = Behaviors.setIndicatorsBeforeLoad( - showIndicatorIds, - hideIndicatorIds, - getRoot(), - ); - // Update the DOM to reflect the new state of the indicators. - updateRoot(newRoot); + const doc: Document | null = getRoot(); + if (doc) { + const newRoot = Behaviors.setIndicatorsBeforeLoad( + showIndicatorIds, + hideIndicatorIds, + doc, + ); + // Update the DOM to reflect the new state of the indicators. + updateRoot(newRoot); + } // Wait for the delay then select-all the target. later(delay).then(selectAll).catch(selectAll); } diff --git a/src/behaviors/hv-set-value/index.ts b/src/behaviors/hv-set-value/index.ts index 811653662..e7aebf663 100644 --- a/src/behaviors/hv-set-value/index.ts +++ b/src/behaviors/hv-set-value/index.ts @@ -38,8 +38,8 @@ export default { ); const setValue = () => { - const doc: Document = getRoot(); - const targetElement: Element | null | undefined = doc.getElementById( + const doc: Document | null = getRoot(); + const targetElement: Element | null | undefined = doc?.getElementById( targetId, ); if (!targetElement) { @@ -68,13 +68,16 @@ export default { setValue(); } else { // If there's a delay, first trigger the indicators before the show. - const newRoot = Behaviors.setIndicatorsBeforeLoad( - showIndicatorIds, - hideIndicatorIds, - getRoot(), - ); - // Update the DOM to reflect the new state of the indicators. - updateRoot(newRoot); + const doc: Document | null = getRoot(); + if (doc) { + const newRoot = Behaviors.setIndicatorsBeforeLoad( + showIndicatorIds, + hideIndicatorIds, + doc, + ); + // Update the DOM to reflect the new state of the indicators. + updateRoot(newRoot); + } // Wait for the delay then show the target. later(delay).then(setValue).catch(setValue); } diff --git a/src/behaviors/hv-show/index.ts b/src/behaviors/hv-show/index.ts index 9caa6de8c..f2360b657 100644 --- a/src/behaviors/hv-show/index.ts +++ b/src/behaviors/hv-show/index.ts @@ -35,8 +35,8 @@ export default { ); const showElement = () => { - const doc: Document = getRoot(); - const targetElement: Element | null | undefined = doc.getElementById( + const doc: Document | null = getRoot(); + const targetElement: Element | null | undefined = doc?.getElementById( targetId, ); if (!targetElement) { @@ -65,13 +65,16 @@ export default { showElement(); } else { // If there's a delay, first trigger the indicators before the show. - const newRoot = Behaviors.setIndicatorsBeforeLoad( - showIndicatorIds, - hideIndicatorIds, - getRoot(), - ); - // Update the DOM to reflect the new state of the indicators. - updateRoot(newRoot); + const doc: Document | null = getRoot(); + if (doc) { + const newRoot = Behaviors.setIndicatorsBeforeLoad( + showIndicatorIds, + hideIndicatorIds, + doc, + ); + // Update the DOM to reflect the new state of the indicators. + updateRoot(newRoot); + } // Wait for the delay then show the target. later(delay).then(showElement).catch(showElement); } diff --git a/src/behaviors/hv-toggle/index.ts b/src/behaviors/hv-toggle/index.ts index 664cf734b..60c34290f 100644 --- a/src/behaviors/hv-toggle/index.ts +++ b/src/behaviors/hv-toggle/index.ts @@ -35,8 +35,8 @@ export default { ); const toggleElement = () => { - const doc: Document = getRoot(); - const targetElement: Element | null | undefined = doc.getElementById( + const doc: Document | null = getRoot(); + const targetElement: Element | null | undefined = doc?.getElementById( targetId, ); if (!targetElement) { @@ -68,13 +68,16 @@ export default { toggleElement(); } else { // If there's a delay, first trigger the indicators before the toggle. - const newRoot = Behaviors.setIndicatorsBeforeLoad( - showIndicatorIds, - hideIndicatorIds, - getRoot(), - ); - // Update the DOM to reflect the new state of the indicators. - updateRoot(newRoot); + const doc: Document | null = getRoot(); + if (doc) { + const newRoot = Behaviors.setIndicatorsBeforeLoad( + showIndicatorIds, + hideIndicatorIds, + doc, + ); + // Update the DOM to reflect the new state of the indicators. + updateRoot(newRoot); + } // Wait for the delay then toggle the target. later(delay).then(toggleElement).catch(toggleElement); } diff --git a/src/behaviors/hv-unselect-all/index.ts b/src/behaviors/hv-unselect-all/index.ts index 569f942e6..6a010d7f3 100644 --- a/src/behaviors/hv-unselect-all/index.ts +++ b/src/behaviors/hv-unselect-all/index.ts @@ -37,8 +37,8 @@ export default { ); const unselectAll = () => { - const doc: Document = getRoot(); - const targetElement: Element | null | undefined = doc.getElementById( + const doc: Document | null = getRoot(); + const targetElement: Element | null | undefined = doc?.getElementById( targetId, ); if (!targetElement) { @@ -66,13 +66,16 @@ export default { unselectAll(); } else { // If there's a delay, first trigger the indicators before the unselect-all. - const newRoot = Behaviors.setIndicatorsBeforeLoad( - showIndicatorIds, - hideIndicatorIds, - getRoot(), - ); - // Update the DOM to reflect the new state of the indicators. - updateRoot(newRoot); + const doc: Document | null = getRoot(); + if (doc) { + const newRoot = Behaviors.setIndicatorsBeforeLoad( + showIndicatorIds, + hideIndicatorIds, + doc, + ); + // Update the DOM to reflect the new state of the indicators. + updateRoot(newRoot); + } // Wait for the delay then unselect-all the target. later(delay).then(unselectAll).catch(unselectAll); } diff --git a/src/core/components/hv-root/index.tsx b/src/core/components/hv-root/index.tsx index e2209da23..ac93a14c7 100644 --- a/src/core/components/hv-root/index.tsx +++ b/src/core/components/hv-root/index.tsx @@ -125,7 +125,7 @@ export default class Hyperview extends PureComponent { } let newRoot = onUpdateCallbacks.getDoc(); - if (showIndicatorIdList || hideIndicatorIdList) { + if (newRoot && (showIndicatorIdList || hideIndicatorIdList)) { newRoot = Behaviors.setIndicatorsBeforeLoad( showIndicatorIdList, hideIndicatorIdList, @@ -216,11 +216,12 @@ export default class Hyperview extends PureComponent { Linking.openURL(href); } else if (navAction && Object.values(NAV_ACTIONS).includes(navAction)) { const navigation = options.onUpdateCallbacks.getNavigation(); - if (navigation) { + const doc = options.onUpdateCallbacks.getDoc(); + if (navigation && doc) { const { behaviorElement, delay, newElement, targetId } = options; const delayVal: number = +(delay || ''); navigation.setUrl(options.onUpdateCallbacks.getState().url || ''); - navigation.setDocument(options.onUpdateCallbacks.getDoc()); + navigation.setDocument(doc); navigation.navigate( href || Navigation.ANCHOR_ID_SEPARATOR, navAction, @@ -354,21 +355,29 @@ export default class Hyperview extends PureComponent { Behaviors.setRanOnce(behaviorElement); } - let newRoot: Document = onUpdateCallbacks.getDoc(); - newRoot = Behaviors.setIndicatorsBeforeLoad( - showIndicatorIdList, - hideIndicatorIdList, - newRoot, - ); + let newRoot = onUpdateCallbacks.getDoc(); + if (newRoot) { + newRoot = Behaviors.setIndicatorsBeforeLoad( + showIndicatorIdList, + hideIndicatorIdList, + newRoot, + ); + } // Re-render the modifications onUpdateCallbacks.setState({ doc: newRoot, }); // Fetch the resource, then perform the action on the target and undo indicators. - const fetchAndUpdate = () => - this.fetchElement(href, verb, newRoot, formData, onUpdateCallbacks).then( - newElement => { + const fetchAndUpdate = () => { + if (newRoot) { + this.fetchElement( + href, + verb, + newRoot, + formData, + onUpdateCallbacks, + ).then(newElement => { // If a target is specified and exists, use it. Otherwise, the action target defaults // to the element triggering the action. let targetElement = targetId @@ -389,21 +398,23 @@ export default class Hyperview extends PureComponent { // the doc to avoid any race conditions newRoot = onUpdateCallbacks.getDoc(); } - newRoot = Behaviors.setIndicatorsAfterLoad( - showIndicatorIdList, - hideIndicatorIdList, - newRoot, - ); - // Re-render the modifications - onUpdateCallbacks.setState({ - doc: newRoot, - }); - + if (newRoot) { + newRoot = Behaviors.setIndicatorsAfterLoad( + showIndicatorIdList, + hideIndicatorIdList, + newRoot, + ); + // Re-render the modifications + onUpdateCallbacks.setState({ + doc: newRoot, + }); + } if (typeof onEnd === 'function') { onEnd(); } - }, - ); + }); + } + }; if (delay) { /** @@ -417,25 +428,27 @@ export default class Hyperview extends PureComponent { const delayMs = parseInt(delay, 10); const timeoutId = setTimeout(() => { // Check the current doc for an element with the same timeout ID - const timeoutElement = Services.getElementByTimeoutId( - onUpdateCallbacks.getDoc(), - timeoutId.toString(), - ); + const timeoutDoc = onUpdateCallbacks.getDoc(); + const timeoutElement = timeoutDoc + ? Services.getElementByTimeoutId(timeoutDoc, timeoutId.toString()) + : null; if (timeoutElement) { // Element with the same ID exists, we can execute the behavior Services.removeTimeoutId(timeoutElement); fetchAndUpdate(); } else { - // Element with the same ID does not exist, - // we don't execute the behavior and undo the indicators. - newRoot = Behaviors.setIndicatorsAfterLoad( - showIndicatorIdList, - hideIndicatorIdList, - onUpdateCallbacks.getDoc(), - ); - onUpdateCallbacks.setState({ - doc: newRoot, - }); + if (timeoutDoc) { + // Element with the same ID does not exist, + // we don't execute the behavior and undo the indicators. + newRoot = Behaviors.setIndicatorsAfterLoad( + showIndicatorIdList, + hideIndicatorIdList, + timeoutDoc, + ); + onUpdateCallbacks.setState({ + doc: newRoot, + }); + } if (typeof onEnd === 'function') { onEnd(); } diff --git a/src/types.ts b/src/types.ts index 26e49b8e3..7eb0d9d30 100644 --- a/src/types.ts +++ b/src/types.ts @@ -216,7 +216,7 @@ export type HvComponentOnUpdate = ( options: HvComponentOptions, ) => void; -export type HvGetRoot = () => Document; +export type HvGetRoot = () => Document | null; export type HvUpdateRoot = (root: Document, updateStylesheet?: boolean) => void; @@ -410,7 +410,7 @@ export type OnUpdateCallbacks = { clearElementError: () => void; getNavigation: () => Navigation; getOnUpdate: () => HvComponentOnUpdate; - getDoc: () => Document; + getDoc: () => Document | null; registerPreload: (id: number, element: Element) => void; setNeedsLoad: () => void; getState: () => ScreenState; @@ -418,10 +418,10 @@ export type OnUpdateCallbacks = { }; export type ScreenState = { - doc?: Document | null | undefined; - elementError?: Error | null | undefined; - error?: Error | null | undefined; - staleHeaderType?: 'stale-if-error' | null | undefined; - styles?: Stylesheets.StyleSheets | null | undefined; - url?: string | null | undefined; + doc?: Document | null; + elementError?: Error | null; + error?: Error | null; + staleHeaderType?: XResponseStaleReason | null; + styles?: Stylesheets.StyleSheets | null; + url?: string | null; }; From 5667a5f46b3774afe4404b0bf3072df6a7181393 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Mon, 23 Oct 2023 15:48:52 -0400 Subject: [PATCH 26/29] chore(hvroute): hvroute as an onUpdate provider for navigator (#729) Implement `hv-route` as a provider of `onUpdate` to allow navigators to process behaviors Only routes which own a document are providers, any children which do not own a document will inherit. Asana: https://app.asana.com/0/1204008699308084/1205741965789637/f --------- Co-authored-by: Florent Bonomo --- src/contexts/index.ts | 6 ++ src/core/components/hv-navigator/types.ts | 2 + src/core/components/hv-root/index.tsx | 5 +- src/core/components/hv-route/index.tsx | 123 ++++++++++++++++++---- src/core/components/hv-route/types.ts | 5 - src/services/navigator/index.ts | 2 +- src/types.ts | 6 +- 7 files changed, 117 insertions(+), 32 deletions(-) diff --git a/src/contexts/index.ts b/src/contexts/index.ts index 77ad2e40d..fc526203f 100644 --- a/src/contexts/index.ts +++ b/src/contexts/index.ts @@ -7,6 +7,7 @@ */ import type { ComponentType } from 'react'; +import type { HvComponentOnUpdate } from 'hyperview/src/types'; import React from 'react'; import type { RefreshControlProps } from 'react-native'; @@ -27,3 +28,8 @@ export const DocContext = React.createContext<{ getDoc: () => Document | undefined; setDoc?: (doc: Document) => void; } | null>(null); + +export const OnUpdateContext = React.createContext<{ + onUpdate: HvComponentOnUpdate; + // eslint-disable-next-line @typescript-eslint/no-empty-function +}>({ onUpdate: () => {} }); diff --git a/src/core/components/hv-navigator/types.ts b/src/core/components/hv-navigator/types.ts index f5369dc15..382949d89 100644 --- a/src/core/components/hv-navigator/types.ts +++ b/src/core/components/hv-navigator/types.ts @@ -7,6 +7,7 @@ */ import { FC } from 'react'; +import type { HvComponentOnUpdate } from 'hyperview/src/types'; import type { Props as HvRouteProps } from 'hyperview/src/core/components/hv-route'; export type RouteParams = { @@ -30,6 +31,7 @@ export type NavigatorParams = { */ export type Props = { element?: Element; + onUpdate: HvComponentOnUpdate; params?: RouteParams; routeComponent: FC; }; diff --git a/src/core/components/hv-root/index.tsx b/src/core/components/hv-root/index.tsx index ac93a14c7..7fe8bc333 100644 --- a/src/core/components/hv-root/index.tsx +++ b/src/core/components/hv-root/index.tsx @@ -217,10 +217,11 @@ export default class Hyperview extends PureComponent { } else if (navAction && Object.values(NAV_ACTIONS).includes(navAction)) { const navigation = options.onUpdateCallbacks.getNavigation(); const doc = options.onUpdateCallbacks.getDoc(); - if (navigation && doc) { + const state = options.onUpdateCallbacks.getState(); + if (navigation && doc && state.url) { const { behaviorElement, delay, newElement, targetId } = options; const delayVal: number = +(delay || ''); - navigation.setUrl(options.onUpdateCallbacks.getState().url || ''); + navigation.setUrl(state.url); navigation.setDocument(doc); navigation.navigate( href || Navigation.ANCHOR_ID_SEPARATOR, diff --git a/src/core/components/hv-route/index.tsx b/src/core/components/hv-route/index.tsx index 9b0e4fedf..13cf243d8 100644 --- a/src/core/components/hv-route/index.tsx +++ b/src/core/components/hv-route/index.tsx @@ -19,11 +19,19 @@ import * as Stylesheets from 'hyperview/src/services/stylesheets'; import * as Types from './types'; import * as TypesLegacy from 'hyperview/src/types'; import * as UrlService from 'hyperview/src/services/url'; +import { + DOMString, + HvComponentOptions, + OnUpdateCallbacks, + ScreenState, +} from 'hyperview/src/types'; import React, { JSXElementConstructor, PureComponent, useContext } from 'react'; import HvNavigator from 'hyperview/src/core/components/hv-navigator'; import HvScreen from 'hyperview/src/core/components/hv-screen'; import LoadError from 'hyperview/src/core/components/load-error'; import Loading from 'hyperview/src/core/components/loading'; +// eslint-disable-next-line instawork/import-services +import Navigation from 'hyperview/src/services/navigation'; /** * Implementation of an HvRoute component @@ -32,22 +40,28 @@ import Loading from 'hyperview/src/core/components/loading'; * - Renders the document * - Handles errors */ -class HvRouteInner extends PureComponent { +class HvRouteInner extends PureComponent { parser?: DomService.Parser; navLogic: NavigatorService.Navigator; componentRegistry: TypesLegacy.ComponentRegistry; + needsLoad = false; + + navigation: Navigation; + constructor(props: Types.InnerRouteProps) { super(props); this.state = { - doc: undefined, - error: undefined, + doc: null, + error: null, }; this.navLogic = new NavigatorService.Navigator(this.props); this.componentRegistry = Components.getRegistry(this.props.components); + this.needsLoad = false; + this.navigation = new Navigation(props.entrypointUrl, this.getNavigation()); } /** @@ -55,10 +69,10 @@ class HvRouteInner extends PureComponent { */ static getDerivedStateFromProps( props: Types.InnerRouteProps, - state: Types.State, + state: ScreenState, ) { if (props.element) { - return { ...state, doc: undefined }; + return { ...state, doc: null }; } return state; } @@ -77,11 +91,24 @@ class HvRouteInner extends PureComponent { } componentDidUpdate(prevProps: Types.InnerRouteProps) { - if (prevProps.url !== this.props.url) { + if (prevProps.url !== this.props.url || this.needsLoad) { this.load(); } + this.needsLoad = false; } + /** + * Returns a navigation object similar to the one provided by React Navigation, + * but connected to the nav logic of this component. + */ + getNavigation = () => ({ + back: this.navLogic.back, + closeModal: this.navLogic.closeModal, + navigate: this.navLogic.navigate, + openModal: this.navLogic.openModal, + push: this.navLogic.push, + }); + getUrl = (): string => { return UrlService.getUrlFromHref( this.props.url || this.props.entrypointUrl, @@ -95,8 +122,9 @@ class HvRouteInner extends PureComponent { load = async (): Promise => { if (!this.parser) { this.setState({ - doc: undefined, + doc: null, error: new NavigatorService.HvRouteError('No parser or context found'), + url: null, }); return; } @@ -108,20 +136,25 @@ class HvRouteInner extends PureComponent { // Set the state with the merged document this.setState(state => { - const merged = NavigatorService.mergeDocument(doc, state.doc); + const merged = NavigatorService.mergeDocument( + doc, + state.doc || undefined, + ); const root = Helpers.getFirstChildTag( merged, TypesLegacy.LOCAL_NAME.DOC, ); if (!root) { return { - doc: undefined, + doc: null, error: new NavigatorService.HvRouteError('No root element found'), + url: null, }; } return { doc: merged, error: undefined, + url, }; }); } catch (err: unknown) { @@ -129,8 +162,9 @@ class HvRouteInner extends PureComponent { this.props.onError(err as Error); } this.setState({ - doc: undefined, + doc: null, error: err as Error, + url: null, }); } }; @@ -178,6 +212,40 @@ class HvRouteInner extends PureComponent { this.props.setPreload(id, element); }; + /** + * Implement the callbacks from this class + */ + updateCallbacks = (): OnUpdateCallbacks => { + return { + clearElementError: () => { + // Noop + }, + getDoc: () => this.state.doc || null, + getNavigation: () => this.navigation, + getOnUpdate: () => this.onUpdate, + getState: () => this.state, + registerPreload: (id, element) => this.registerPreload(id, element), + setNeedsLoad: () => { + this.needsLoad = true; + }, + setState: (state: ScreenState) => { + this.setState(state); + }, + }; + }; + + onUpdate = ( + href: DOMString | null | undefined, + action: DOMString | null | undefined, + element: Element, + options: HvComponentOptions, + ) => { + this.props.onUpdate(href, action, element, { + ...options, + onUpdateCallbacks: this.updateCallbacks(), + }); + }; + /** * View shown while loading * Includes preload functionality @@ -312,29 +380,42 @@ class HvRouteInner extends PureComponent { ) { if (this.state.doc) { // The provides doc access to nested navigators + // The provides access to the onUpdate method for this route // only pass it when the doc is available and is not being overridden by an element return ( this.state.doc, + getDoc: () => this.state.doc || undefined, setDoc: (doc: Document) => this.setState({ doc }), }} > - + + + ); } // Without a doc, the navigator shares the higher level context return ( - + + {updater => ( + + )} + ); } diff --git a/src/core/components/hv-route/types.ts b/src/core/components/hv-route/types.ts index 48ca388c6..f3160e595 100644 --- a/src/core/components/hv-route/types.ts +++ b/src/core/components/hv-route/types.ts @@ -47,11 +47,6 @@ export type NavigatorMapContextProps = { getPreload: (key: number) => Element | undefined; }; -export type State = { - doc?: Document; - error?: Error; -}; - /** * The route prop used by react-navigation */ diff --git a/src/services/navigator/index.ts b/src/services/navigator/index.ts index d4235dbef..dc3126a2f 100644 --- a/src/services/navigator/index.ts +++ b/src/services/navigator/index.ts @@ -98,7 +98,7 @@ export class Navigator { } }; - back = (params: TypesLegacy.NavigationRouteParams | undefined) => { + back = (params?: TypesLegacy.NavigationRouteParams | undefined) => { this.sendRequest(TypesLegacy.NAV_ACTIONS.BACK, params); }; diff --git a/src/types.ts b/src/types.ts index 7eb0d9d30..eb575f758 100644 --- a/src/types.ts +++ b/src/types.ts @@ -389,13 +389,13 @@ export type NavigationRouteParams = { }; export type NavigationProps = { - back: (routeParams?: NavigationRouteParams | null | undefined) => void; - closeModal: (routeParams?: NavigationRouteParams | null | undefined) => void; + back: (routeParams?: NavigationRouteParams | undefined) => void; + closeModal: (routeParams?: NavigationRouteParams | undefined) => void; navigate: ( routeParams: NavigationRouteParams, key?: string | null | undefined, ) => void; - openModal: (routeParams?: NavigationRouteParams | null | undefined) => void; + openModal: (routeParams: NavigationRouteParams) => void; push: (routeParams: NavigationRouteParams) => void; }; From f3e25c0ae8a3f570387c3727bb094a086fa684ae Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Mon, 23 Oct 2023 17:03:28 -0400 Subject: [PATCH 27/29] feat(navigation behaviors): implementation of behaviors in navigators (#730) Implementation of `` elements as children of `` elements - Added an optional `` child in schema - Gather and cache all "load" behaviors per navigator - warn developer of any behaviors with non matching triggers - trigger all behaviors on component load - Refresh and re-trigger behaviors on content update See individual commits for a few clean up steps performed as part of the work Asana: https://app.asana.com/0/1204008699308084/1205741965790735/f --- schema/core.xsd | 1 + src/core/components/hv-navigator/index.tsx | 95 ++++++++++++++++------ 2 files changed, 72 insertions(+), 24 deletions(-) diff --git a/schema/core.xsd b/schema/core.xsd index 922cae7f6..dba4f17c5 100644 --- a/schema/core.xsd +++ b/schema/core.xsd @@ -1235,6 +1235,7 @@ + diff --git a/src/core/components/hv-navigator/index.tsx b/src/core/components/hv-navigator/index.tsx index 70930c5ab..21c89ee84 100644 --- a/src/core/components/hv-navigator/index.tsx +++ b/src/core/components/hv-navigator/index.tsx @@ -6,12 +6,20 @@ * */ -import * as NavigationContext from 'hyperview/src/contexts/navigation'; +import * as Behaviors from 'hyperview/src/services/behaviors'; +import * as Dom from 'hyperview/src/services/dom'; import * as NavigatorMapContext from 'hyperview/src/contexts/navigator-map'; import * as NavigatorService from 'hyperview/src/services/navigator'; -import * as Types from './types'; -import * as TypesLegacy from 'hyperview/src/types'; -import React, { PureComponent, useContext } from 'react'; +import { BEHAVIOR_ATTRIBUTES, LOCAL_NAME, TRIGGERS } from 'hyperview/src/types'; +import { + ParamTypes, + Props, + RouteParams, + ScreenParams, + StackScreenOptions, + TabScreenOptions, +} from './types'; +import React, { PureComponent } from 'react'; import { createCustomStackNavigator } from 'hyperview/src/core/components/navigator-stack'; import { createCustomTabNavigator } from 'hyperview/src/core/components/navigator-tab'; import { getFirstChildTag } from 'hyperview/src/services/dom/helpers'; @@ -21,16 +29,64 @@ import { getFirstChildTag } from 'hyperview/src/services/dom/helpers'; */ const SHOW_NAVIGATION_UI = false; -const Stack = createCustomStackNavigator(); -const BottomTab = createCustomTabNavigator(); +const Stack = createCustomStackNavigator(); +const BottomTab = createCustomTabNavigator(); + +export default class HvNavigator extends PureComponent { + behaviorElements: Element[] = []; + + constructor(props: Props) { + super(props); + this.updateBehaviorElements(); + } + + componentDidMount() { + this.triggerLoadBehaviors(); + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.element === this.props.element) { + return; + } + + this.updateBehaviorElements(); + this.triggerLoadBehaviors(); + } + + /** + * Cache all behaviors with a `load` trigger + */ + updateBehaviorElements = () => { + if (this.props.element) { + this.behaviorElements = Dom.getBehaviorElements( + this.props.element, + ).filter(e => { + const triggerAttr = e.getAttribute(BEHAVIOR_ATTRIBUTES.TRIGGER); + if (triggerAttr !== TRIGGERS.LOAD) { + console.warn( + `Unsupported trigger '${triggerAttr}'. Only "load" is supported`, + ); + return false; + } + return true; + }); + } + }; + + triggerLoadBehaviors = () => { + if (this.behaviorElements.length > 0 && this.props.element) { + Behaviors.triggerBehaviors( + this.props.element, + this.behaviorElements, + this.props.onUpdate, + ); + } + }; -export default class HvNavigator extends PureComponent { /** * Encapsulated options for the stack screenOptions */ - stackScreenOptions = ( - route: Types.ScreenParams, - ): Types.StackScreenOptions => ({ + stackScreenOptions = (route: ScreenParams): StackScreenOptions => ({ headerMode: 'screen', headerShown: SHOW_NAVIGATION_UI, title: this.getId(route.params), @@ -39,7 +95,7 @@ export default class HvNavigator extends PureComponent { /** * Encapsulated options for the tab screenOptions */ - tabScreenOptions = (route: Types.ScreenParams): Types.TabScreenOptions => ({ + tabScreenOptions = (route: ScreenParams): TabScreenOptions => ({ headerShown: SHOW_NAVIGATION_UI, tabBarStyle: { display: SHOW_NAVIGATION_UI ? 'flex' : 'none' }, title: this.getId(route.params), @@ -48,7 +104,7 @@ export default class HvNavigator extends PureComponent { /** * Logic to determine the nav route id */ - getId = (params: Types.RouteParams): string => { + getId = (params: RouteParams): string => { if (!params) { throw new NavigatorService.HvNavigatorError('No params found for route'); } @@ -89,7 +145,7 @@ export default class HvNavigator extends PureComponent { this.getId(params)} + getId={({ params }: ScreenParams) => this.getId(params)} initialParams={initialParams} name={id} options={{ @@ -139,15 +195,6 @@ export default class HvNavigator extends PureComponent { */ buildScreens = (element: Element, type: string): React.ReactNode => { const screens: React.ReactElement[] = []; - const navigationContext: NavigationContext.NavigationContextProps | null = useContext( - NavigationContext.Context, - ); - const navigatorMapContext: NavigatorMapContext.NavigatorMapContextProps | null = useContext( - NavigatorMapContext.NavigatorMapContext, - ); - if (!navigationContext || !navigatorMapContext) { - throw new NavigatorService.HvRouteError('No context found'); - } const elements: Element[] = NavigatorService.getChildElements(element); @@ -157,7 +204,7 @@ export default class HvNavigator extends PureComponent { // This iteration will also process nested navigators // and retrieve additional urls from child routes elements.forEach((navRoute: Element) => { - if (navRoute.localName === TypesLegacy.LOCAL_NAME.NAV_ROUTE) { + if (navRoute.localName === LOCAL_NAME.NAV_ROUTE) { const id: string | null | undefined = navRoute.getAttribute('id'); if (!id) { throw new NavigatorService.HvNavigatorError( @@ -171,7 +218,7 @@ export default class HvNavigator extends PureComponent { // Check for nested navigators const nestedNavigator: Element | null = getFirstChildTag( navRoute, - TypesLegacy.LOCAL_NAME.NAVIGATOR, + LOCAL_NAME.NAVIGATOR, ); if (!nestedNavigator && !href) { From f5110f4ab130cf384dd66fb65c7a92b7b82bc1ad Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Tue, 31 Oct 2023 22:55:47 -0400 Subject: [PATCH 28/29] fix: deep link urls are not triggering navigation events (#733) Deep link requests were not properly triggering navigation events like tab selections. Behaviors continued to work as expected. Cause: There was a chunk of code which was calling `useContext` in the hv-navigator for two contexts. The result of these were not being used so [the code was removed](https://github.com/Instawork/hyperview/pull/701/commits/f3e25c0ae8a3f570387c3727bb094a086fa684ae). Without that context, the `useEffect` of the custom navigators doesn't fire. Adding it back as part of the hierarchy restores the functionality. In the videos below, the deep link should cause a selection of the "Accounts" tab. | Before | After | | -- | -- | | ![before](https://github.com/Instawork/hyperview/assets/127122858/268e58a2-5beb-4dea-b554-eb5328bd6df9) | ![after](https://github.com/Instawork/hyperview/assets/127122858/25fc969a-77fa-44c5-a766-fa2bf054ade0) | --- src/core/components/hv-navigator/index.tsx | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/core/components/hv-navigator/index.tsx b/src/core/components/hv-navigator/index.tsx index 21c89ee84..affb54b1d 100644 --- a/src/core/components/hv-navigator/index.tsx +++ b/src/core/components/hv-navigator/index.tsx @@ -8,10 +8,11 @@ import * as Behaviors from 'hyperview/src/services/behaviors'; import * as Dom from 'hyperview/src/services/dom'; +import * as NavigationContext from 'hyperview/src/contexts/navigation'; import * as NavigatorMapContext from 'hyperview/src/contexts/navigator-map'; import * as NavigatorService from 'hyperview/src/services/navigator'; import { BEHAVIOR_ATTRIBUTES, LOCAL_NAME, TRIGGERS } from 'hyperview/src/types'; -import { +import type { ParamTypes, Props, RouteParams, @@ -195,7 +196,6 @@ export default class HvNavigator extends PureComponent { */ buildScreens = (element: Element, type: string): React.ReactNode => { const screens: React.ReactElement[] = []; - const elements: Element[] = NavigatorService.getChildElements(element); // For tab navigators, the screens are appended @@ -336,13 +336,17 @@ export default class HvNavigator extends PureComponent { render() { return ( - - {this.props.params && this.props.params.isModal ? ( - - ) : ( - + + {() => ( + + {this.props.params && this.props.params.isModal ? ( + + ) : ( + + )} + )} - + ); } } From 676098c93efdd892ab9a47c0d65cf472e325b42b Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Tue, 31 Oct 2023 23:02:11 -0400 Subject: [PATCH 29/29] Allow on-event behaviors in navigation behavior array --- src/core/components/hv-navigator/index.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core/components/hv-navigator/index.tsx b/src/core/components/hv-navigator/index.tsx index affb54b1d..6d7cb3c3d 100644 --- a/src/core/components/hv-navigator/index.tsx +++ b/src/core/components/hv-navigator/index.tsx @@ -63,9 +63,12 @@ export default class HvNavigator extends PureComponent { this.props.element, ).filter(e => { const triggerAttr = e.getAttribute(BEHAVIOR_ATTRIBUTES.TRIGGER); - if (triggerAttr !== TRIGGERS.LOAD) { + if ( + triggerAttr !== TRIGGERS.LOAD && + triggerAttr !== TRIGGERS.ON_EVENT + ) { console.warn( - `Unsupported trigger '${triggerAttr}'. Only "load" is supported`, + `Unsupported trigger '${triggerAttr}'. Only "load" and "on-event" are supported`, ); return false; }