diff --git a/schema/core.xsd b/schema/core.xsd index 191c9a9dd..dba4f17c5 100644 --- a/schema/core.xsd +++ b/schema/core.xsd @@ -1235,9 +1235,11 @@ + + @@ -1249,6 +1251,7 @@ + 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/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/index.ts b/src/contexts/index.ts index 3042fb92b..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'; @@ -24,5 +25,11 @@ export const RefreshControlComponentContext = React.createContext< >(undefined); export const DocContext = React.createContext<{ - getDoc: () => Document; + 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/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/contexts/navigator-map.tsx b/src/contexts/navigator-map.tsx index ef9aec0a9..0a9f50231 100644 --- a/src/contexts/navigator-map.tsx +++ b/src/contexts/navigator-map.tsx @@ -9,13 +9,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: Element) => void; - getElement: (key: string) => Element | undefined; setPreload: (key: number, element: Element) => void; getPreload: (key: number) => Element | undefined; - initialRouteName?: string; }; /** @@ -23,16 +18,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, }); type Props = { children: React.ReactNode }; @@ -42,26 +32,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 [elementMap] = 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 setElement = (key: string, element: Element) => { - elementMap.set(key, element); - }; - - const getElement = (key: string): Element | undefined => { - return elementMap.get(key); - }; - const setPreload = (key: number, element: Element) => { preloadMap.set(key, element); }; @@ -73,12 +45,8 @@ export function NavigatorMapProvider(props: Props) { return ( {props.children} diff --git a/src/core/components/hv-navigator/index.tsx b/src/core/components/hv-navigator/index.tsx index 92fd8a258..6d7cb3c3d 100644 --- a/src/core/components/hv-navigator/index.tsx +++ b/src/core/components/hv-navigator/index.tsx @@ -6,34 +6,160 @@ * */ +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 * as Types from './types'; -import * as TypesLegacy from 'hyperview/src/types'; -import React, { PureComponent, useContext } from 'react'; -import { getFirstTag } from 'hyperview/src/services/dom/helpers'; +import { BEHAVIOR_ATTRIBUTES, LOCAL_NAME, TRIGGERS } from 'hyperview/src/types'; +import type { + 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'; /** * Flag to show the navigator UIs */ 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 { + 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 && + triggerAttr !== TRIGGERS.ON_EVENT + ) { + console.warn( + `Unsupported trigger '${triggerAttr}'. Only "load" and "on-event" are supported`, + ); + return false; + } + return true; + }); + } + }; + + triggerLoadBehaviors = () => { + if (this.behaviorElements.length > 0 && this.props.element) { + Behaviors.triggerBehaviors( + this.props.element, + this.behaviorElements, + this.props.onUpdate, + ); + } + }; + + /** + * Encapsulated options for the stack screenOptions + */ + stackScreenOptions = (route: ScreenParams): StackScreenOptions => ({ + headerMode: 'screen', + headerShown: SHOW_NAVIGATION_UI, + title: this.getId(route.params), + }); + + /** + * Encapsulated options for the tab screenOptions + */ + tabScreenOptions = (route: ScreenParams): TabScreenOptions => ({ + headerShown: SHOW_NAVIGATION_UI, + tabBarStyle: { display: SHOW_NAVIGATION_UI ? 'flex' : 'none' }, + title: this.getId(route.params), + }); + + /** + * Logic to determine the nav route id + */ + getId = (params: RouteParams): string => { + if (!params) { + throw new NavigatorService.HvNavigatorError('No params found for route'); + } + if (params.id) { + if (NavigatorService.isDynamicRoute(params.id)) { + // Dynamic routes use their url as id + return params.url || params.id; + } + return params.id; + } + return params.url; + }; -export default class HvNavigator extends PureComponent { /** * Build an individual tab screen */ - buildTabScreen = (id: string, type: string): React.ReactElement => { + buildScreen = ( + id: string, + type: string, + href: string | undefined, + isModal: boolean, + ): React.ReactElement => { + const initialParams = NavigatorService.isDynamicRoute(id) + ? {} + : { id, isModal, url: href }; if (type === NavigatorService.NAVIGATOR_TYPE.TAB) { return ( + ); + } + if (type === NavigatorService.NAVIGATOR_TYPE.STACK) { + return ( + this.getId(params)} + initialParams={initialParams} name={id} + options={{ + cardStyleInterpolator: isModal + ? NavigatorService.CardStyleInterpolators.forVerticalIOS + : undefined, + presentation: isModal + ? NavigatorService.ID_MODAL + : NavigatorService.ID_CARD, + }} /> ); } @@ -43,93 +169,73 @@ export default class HvNavigator extends PureComponent { }; /** - * Build all screens from received routes + * Build the card and modal screens for a stack navigator */ - buildScreens = (element: Element, type: string): React.ReactNode => { + buildDynamicScreens = (): React.ReactElement[] => { const screens: React.ReactElement[] = []; - const navigationContext: NavigationContext.NavigationContextProps | null = useContext( - NavigationContext.Context, + + screens.push( + this.buildScreen( + NavigatorService.ID_CARD, + NavigatorService.NAVIGATOR_TYPE.STACK, + undefined, + false, + ), ); - const navigatorMapContext: NavigatorMapContext.NavigatorMapContextProps | null = useContext( - NavigatorMapContext.NavigatorMapContext, + + screens.push( + this.buildScreen( + NavigatorService.ID_MODAL, + NavigatorService.NAVIGATOR_TYPE.STACK, + undefined, + true, + ), ); - if (!navigationContext || !navigatorMapContext) { - throw new NavigatorService.HvRouteError('No context found'); - } + return screens; + }; - const { buildTabScreen } = this; + /** + * Build all screens from received routes + */ + buildScreens = (element: Element, type: string): React.ReactNode => { + const screens: React.ReactElement[] = []; const elements: 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: 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( `No id provided for ${navRoute.localName}`, ); } + const href: string | null | undefined = navRoute.getAttribute('href'); + const isModal = + navRoute.getAttribute(NavigatorService.KEY_MODAL) === 'true'; // Check for nested navigators - const nestedNavigator: Element | null = getFirstTag( + const nestedNavigator: Element | null = getFirstChildTag( navRoute, - TypesLegacy.LOCAL_NAME.NAVIGATOR, + LOCAL_NAME.NAVIGATOR, ); - if (nestedNavigator) { - // Cache the navigator for the route - navigatorMapContext.setElement(id, nestedNavigator); - } else { - const href: string | null | undefined = navRoute.getAttribute('href'); - if (!href) { - throw new NavigatorService.HvNavigatorError( - `No href provided for route '${id}'`, - ); - } - const url = NavigatorService.getUrlFromHref( - href, - navigationContext?.entrypointUrl, - ); - // 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)); + if (!nestedNavigator && !href) { + throw new NavigatorService.HvNavigatorError( + `No href provided for route '${id}'`, + ); } + screens.push(this.buildScreen(id, type, href || undefined, isModal)); } }); // 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={{ presentation: 'modal' }} - />, - ); + screens.push(...this.buildDynamicScreens()); } return screens; }; @@ -137,53 +243,39 @@ export default class HvNavigator extends PureComponent { /** * Build the required navigator from the xml element */ - Navigator = (props: Types.Props): React.ReactElement => { - const id: string | null | undefined = props.element.getAttribute('id'); + Navigator = (): React.ReactElement => { + if (!this.props.element) { + throw new NavigatorService.HvNavigatorError( + 'No element found for navigator', + ); + } + + const id: string | null | undefined = this.props.element.getAttribute('id'); if (!id) { 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, + const type: string | null | undefined = this.props.element.getAttribute( + 'type', ); - if (!navigationContext || !navigatorMapContext) { - throw new NavigatorService.HvRouteError('No context found'); - } - - const type: string | null | undefined = props.element.getAttribute('type'); const selected: | Element - | undefined = NavigatorService.getSelectedNavRouteElement(props.element); - if (!selected) { - throw new NavigatorService.HvNavigatorError( - `No selected route defined for '${id}'`, - ); - } + | undefined = NavigatorService.getSelectedNavRouteElement( + this.props.element, + ); const selectedId: string | undefined = selected - .getAttribute('id') - ?.toString(); - if (selectedId) { - navigatorMapContext.initialRouteName = selectedId; - } - const { buildScreens } = this; + ? selected.getAttribute('id')?.toString() + : undefined; + switch (type) { case NavigatorService.NAVIGATOR_TYPE.STACK: return ( ({ - header: undefined, - headerMode: 'screen', - headerShown: SHOW_NAVIGATION_UI, - title: route.params?.url || id, - })} + screenOptions={({ route }) => this.stackScreenOptions(route)} > - {buildScreens(props.element, type)} + {this.buildScreens(this.props.element, type)} ); case NavigatorService.NAVIGATOR_TYPE.TAB: @@ -192,13 +284,9 @@ 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)} > - {buildScreens(props.element, type)} + {this.buildScreens(this.props.element, type)} ); default: @@ -208,15 +296,60 @@ export default class HvNavigator extends PureComponent { ); }; + /** + * Build a stack navigator for a modal + */ + ModalNavigator = (): React.ReactElement => { + if (!this.props.params) { + throw new NavigatorService.HvNavigatorError( + 'No params found for modal screen', + ); + } + + if (!this.props.params.id) { + throw new NavigatorService.HvNavigatorError( + 'No id found for modal screen', + ); + } + + 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[] = []; + screens.push( + this.buildScreen( + screenId, + NavigatorService.NAVIGATOR_TYPE.STACK, + this.props.params?.url || undefined, + false, + ), + ); + screens.push(...this.buildDynamicScreens()); + + return ( + this.stackScreenOptions(route)} + > + {screens} + + ); + }; + render() { - const { Navigator } = this; return ( - - - + + {() => ( + + {this.props.params && this.props.params.isModal ? ( + + ) : ( + + )} + + )} + ); } } diff --git a/src/core/components/hv-navigator/types.ts b/src/core/components/hv-navigator/types.ts index db6e88ea1..382949d89 100644 --- a/src/core/components/hv-navigator/types.ts +++ b/src/core/components/hv-navigator/types.ts @@ -7,17 +7,16 @@ */ 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 = { id?: string; url: string; + isModal?: boolean; }; -export type ParamTypes = { - dynamic: RouteParams; - modal: RouteParams; -}; +export type ParamTypes = Record; export type ScreenParams = { params: RouteParams; @@ -31,6 +30,26 @@ export type NavigatorParams = { * All of the props used by hv-navigator */ export type Props = { - element: Element; + element?: Element; + onUpdate: HvComponentOnUpdate; + params?: RouteParams; routeComponent: FC; }; + +/** + * Options used for a stack navigator's screenOptions + */ +export type StackScreenOptions = { + headerMode: 'float' | 'screen' | undefined; + 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; +}; diff --git a/src/core/components/hv-root/index.tsx b/src/core/components/hv-root/index.tsx index 6a5fd129f..7fe8bc333 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,486 @@ 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 (newRoot && (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(); + const doc = options.onUpdateCallbacks.getDoc(); + const state = options.onUpdateCallbacks.getState(); + if (navigation && doc && state.url) { + const { behaviorElement, delay, newElement, targetId } = options; + const delayVal: number = +(delay || ''); + navigation.setUrl(state.url); + navigation.setDocument(doc); + 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 = 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 = () => { + 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 + ? 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(); + } + if (newRoot) { + 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 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 { + 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(); + } + } + }, 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 +551,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 +580,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 941c11913..13cf243d8 100644 --- a/src/core/components/hv-route/index.tsx +++ b/src/core/components/hv-route/index.tsx @@ -19,17 +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 React, { - ComponentType, - JSXElementConstructor, - PureComponent, - ReactNode, - useContext, -} from 'react'; +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 @@ -38,22 +40,41 @@ 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()); + } + + /** + * Override the state to clear the doc when an element is passed + */ + static getDerivedStateFromProps( + props: Types.InnerRouteProps, + state: ScreenState, + ) { + if (props.element) { + return { ...state, doc: null }; + } + return state; } componentDidMount() { @@ -70,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, @@ -88,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; } @@ -98,22 +133,43 @@ 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 || undefined, + ); + const root = Helpers.getFirstChildTag( + merged, + TypesLegacy.LOCAL_NAME.DOC, + ); + if (!root) { + return { + doc: null, + error: new NavigatorService.HvRouteError('No root element found'), + url: null, + }; + } + return { + doc: merged, + error: undefined, + url, + }; }); } catch (err: unknown) { if (this.props.onError) { this.props.onError(err as Error); } this.setState({ - doc: undefined, + doc: null, error: err as Error, + url: null, }); } }; - getRenderElement = (): Element | null => { + getRenderElement = (): Element | undefined => { if (this.props.element) { return this.props.element; } @@ -122,7 +178,7 @@ class HvRouteInner extends PureComponent { } // Get the element - const root: Element | null = Helpers.getFirstTag( + const root: Element | null = Helpers.getFirstChildTag( this.state.doc, TypesLegacy.LOCAL_NAME.DOC, ); @@ -131,7 +187,7 @@ class HvRouteInner extends PureComponent { } // Get the first child as or - const screenElement: Element | null = Helpers.getFirstTag( + const screenElement: Element | null = Helpers.getFirstChildTag( root, TypesLegacy.LOCAL_NAME.SCREEN, ); @@ -139,7 +195,7 @@ class HvRouteInner extends PureComponent { return screenElement; } - const navigatorElement: Element | null = Helpers.getFirstTag( + const navigatorElement: Element | null = Helpers.getFirstChildTag( root, TypesLegacy.LOCAL_NAME.NAVIGATOR, ); @@ -156,18 +212,58 @@ 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 */ Load = (): React.ReactElement => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + const noop = () => {}; + if (this.props.route?.params?.preloadScreen) { const preloadElement = this.props.getPreload( this.props.route?.params?.preloadScreen, ); if (preloadElement) { const [body] = Array.from( - preloadElement.getElementsByTagNameNS(Namespaces.HYPERVIEW, 'body'), + preloadElement.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + 'body', + ) as HTMLCollectionOf, ); const styleSheet = Stylesheets.createStylesheets( (preloadElement as unknown) as Document, @@ -175,7 +271,7 @@ class HvRouteInner extends PureComponent { const component: | string | React.ReactElement> - | null = Render.renderElement(body, styleSheet, () => {}, { + | null = Render.renderElement(body, styleSheet, () => noop, { componentRegistry: this.componentRegistry, }); if (component) { @@ -242,6 +338,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} @@ -256,30 +353,78 @@ class HvRouteInner extends PureComponent { /** * Evaluate the element and render the appropriate component */ - Route = (props: { - handleBack?: ComponentType<{ children: ReactNode }>; - }): React.ReactElement => { - const renderElement: Element | null = this.getRenderElement(); + Route = (): React.ReactElement => { + const { Screen } = this; - if (!renderElement) { - throw new NavigatorService.HvRenderError('No element found'); - } + const isModal = this.props.route?.params.isModal + ? this.props.route.params.isModal + : false; + + const renderElement: 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) { - return ; + if ( + isModal || + renderElement?.localName === TypesLegacy.LOCAL_NAME.NAVIGATOR + ) { + 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 || undefined, + setDoc: (doc: Document) => this.setState({ doc }), + }} + > + + + + + ); + } + // Without a doc, the navigator shares the higher level context + return ( + + {updater => ( + + )} + + ); } - const { Screen } = this; - if (renderElement.localName === TypesLegacy.LOCAL_NAME.SCREEN) { - if (props.handleBack) { + if (renderElement?.localName === TypesLegacy.LOCAL_NAME.SCREEN) { + if (this.props.handleBack) { return ( - + - + ); } return ; @@ -292,7 +437,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'); } @@ -302,7 +450,7 @@ class HvRouteInner extends PureComponent { } const { Route } = this; - return ; + return ; }; render() { @@ -311,7 +459,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 ; @@ -324,6 +476,44 @@ class HvRouteInner extends PureComponent { } } +/** + * Retrieve the url from the props, params, or context + */ +const getRouteUrl = ( + props: Types.Props, + navigationContext: Types.NavigationContextProps, +) => { + // The initial hv-route element will use the entrypoint url + if (props.navigation === undefined) { + return navigationContext.entrypointUrl; + } + + return props.route?.params?.url + ? NavigatorService.cleanHrefFragment(props.route?.params?.url) + : undefined; +}; + +/** + * Retrieve a nested navigator as a child of the nav-route with the given id + */ +const getNestedNavigator = ( + id?: string, + doc?: Document, +): Element | undefined => { + if (!id || !doc) { + return undefined; + } + + const route = NavigatorService.getRouteById(doc, id); + 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 @@ -344,42 +534,46 @@ 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 docContext = useContext(Contexts.DocContext); - 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); - } - } + const url = getRouteUrl(props, navigationContext); - // Fall back to the entrypoint url - url = url || navigationContext.entrypointUrl; + // Get the navigator element from the context + const element: Element | undefined = getNestedNavigator( + props.route?.params?.id, + docContext?.getDoc(), + ); - const id: string | undefined = - props.route?.params?.id || - navigatorMapContext.initialRouteName || - undefined; + // Use the focus event to set the selected route + React.useEffect(() => { + if (props.navigation) { + const unsubscribeFocus: () => void = props.navigation.addListener( + 'focus', + () => { + NavigatorService.setSelected( + docContext?.getDoc(), + props.route?.params?.id, + ); + }, + ); - 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 unsubscribeRemove: () => void = props.navigation.addListener( + 'beforeRemove', + () => { + NavigatorService.removeStackRoute( + docContext?.getDoc(), + props.route?.params?.id, + ); + }, + ); - // Get the navigator element from the context - const element: Element | undefined = - id && includeElement ? navigatorMapContext.getElement(id) : undefined; + return () => { + unsubscribeFocus(); + unsubscribeRemove(); + }; + } + return undefined; + }, [props.navigation, props.route?.params?.id, docContext]); return ( void; onParseBefore?: (url: string) => void; + onUpdate: HvComponentOnUpdate; url?: string; behaviors?: HvBehavior[]; components?: HvComponent[]; @@ -37,16 +43,8 @@ export type NavigationContextProps = { }; export type NavigatorMapContextProps = { - getRoute: (key: string) => string | undefined; - getElement: (key: string) => Element | undefined; setPreload: (key: number, element: Element) => void; getPreload: (key: number) => Element | undefined; - initialRouteName?: string; -}; - -export type State = { - doc?: Document; - error?: Error; }; /** @@ -58,8 +56,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; @@ -67,17 +64,15 @@ export type InnerRouteProps = { onError?: (error: Error) => void; onParseAfter?: (url: string) => void; onParseBefore?: (url: string) => void; + onUpdate: HvComponentOnUpdate; behaviors?: HvBehavior[]; components?: HvComponent[]; elementErrorComponent?: ComponentType; errorScreen?: ComponentType; loadingScreen?: ComponentType; handleBack?: ComponentType<{ children: ReactNode }>; - getRoute: (key: string) => string | undefined; - getElement: (key: string) => Element | undefined; setPreload: (key: number, element: Element) => void; getPreload: (key: number) => Element | undefined; - initialRouteName?: string; element?: Element; }; diff --git a/src/core/components/hv-screen/index.js b/src/core/components/hv-screen/index.js index 5b92b8c64..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. */ @@ -321,7 +262,9 @@ export default class HvScreen extends React.Component { ); return ( - this.doc}> + this.doc, + }}> {screenElement} {elementErrorComponent ? (React.createElement(elementErrorComponent, { error: this.state.elementError, onPressReload: () => this.reload() })) : null} @@ -330,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. @@ -358,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); @@ -406,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/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..7f269bd02 --- /dev/null +++ b/src/core/components/navigator-stack/types.ts @@ -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 { 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 = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 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..8cf054115 --- /dev/null +++ b/src/core/components/navigator-tab/index.tsx @@ -0,0 +1,71 @@ +/** + * 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(() => { + 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 ( + + + + ); +}; + +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..221e372b5 --- /dev/null +++ b/src/core/components/navigator-tab/types.tsx @@ -0,0 +1,70 @@ +/** + * 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 = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?: any; +}; 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..90f20f352 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,89 @@ 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 }); +}; + +/** + * 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/services/dom/helpers.ts b/src/services/dom/helpers.ts index 436b6ffe7..12968efb3 100644 --- a/src/services/dom/helpers.ts +++ b/src/services/dom/helpers.ts @@ -8,6 +8,7 @@ import * as Namespaces from 'hyperview/src/services/namespaces'; import type { LocalName, NamespaceURI, NodeType } from 'hyperview/src/types'; +import { NODE_TYPE } from 'hyperview/src/types'; export const getBehaviorElements = (element: Element) => { const behaviorElements = Array.from( @@ -33,6 +34,30 @@ 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, +): Element | null => { + if (!node || !node.childNodes) { + return null; + } + for (let i = 0; i < node.childNodes.length; i += 1) { + const child = node.childNodes[i] as Element; + if ( + child.nodeType === NODE_TYPE.ELEMENT_NODE && + child.localName === localName && + child.namespaceURI === namespace + ) { + return child; + } + } + return null; +}; + export const getPreviousNodeOfType = ( node: Node | null, type: NodeType, diff --git a/src/services/navigator/helpers.test.ts b/src/services/navigator/helpers.test.ts index d5f7d8184..b25ef944b 100644 --- a/src/services/navigator/helpers.test.ts +++ b/src/services/navigator/helpers.test.ts @@ -3,7 +3,7 @@ import * as Errors from './errors'; import * as Namespaces from '../namespaces'; import * as Types from './types'; import * as TypesLegacy from '../../types'; -import { ID_DYNAMIC, ID_MODAL } from './types'; +import { ID_CARD, ID_MODAL } from './types'; import { buildParams, buildRequest, @@ -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 */ @@ -136,14 +198,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( @@ -441,18 +503,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( - ID_DYNAMIC, + it(`should not return route 'card' from url with fragment: ${url}`, () => { + expect(getRouteId(TypesLegacy.NAV_ACTIONS.PUSH, url)).not.toEqual( + ID_CARD, ); }); - 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), ); }); }); @@ -460,13 +522,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 'card' from url with non-fragment: ${url}`, () => { + expect(getRouteId(TypesLegacy.NAV_ACTIONS.PUSH, url)).toEqual( + ID_CARD, + ); + }); + }); + }); + + 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, ); }); @@ -482,19 +567,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 '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 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_CARD); }); - it(`should return type 'dynamic' from url with non-static: ${url}`, () => { - expect(getRouteId(action, url, false)).toEqual(ID_DYNAMIC); + it(`should return type 'card' from url with non-fragment: ${url}`, () => { + expect(getRouteId(action, url)).toEqual(ID_CARD); }); }); }); @@ -594,3 +679,115 @@ describe('buildRequest', () => { // - invalid path // - success }); + +describe('mergeDocuments', () => { + const originalDoc = parser.parseFromString(mergeOriginalDoc); + const origNavigators = originalDoc.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + 'navigator', + ); + const [origTabNavigator] = Array.from(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] = Array.from(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] = Array.from(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 136b5bb6f..30c20cc99 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'; +import * as Namespaces from 'hyperview/src/services/namespaces'; import * as Types from './types'; import * as TypesLegacy from 'hyperview/src/types'; import * as UrlService from 'hyperview/src/services/url'; import { ANCHOR_ID_SEPARATOR } from './types'; +/** + * Card and modal routes are not defined in the document + */ +export const isDynamicRoute = (id: string): boolean => { + return id === Types.ID_CARD || id === Types.ID_MODAL; +}; + /** * Get an array of all child elements of a node */ @@ -28,13 +37,14 @@ export const getChildElements = (element: Element | Document): Element[] => { */ export const isNavigationElement = (element: 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) ); }; /** - * Get the route designated as 'selected' or the first route if none is marked + * Get the route designated as 'selected' */ export const getSelectedNavRouteElement = ( element: Element, @@ -48,10 +58,10 @@ export const getSelectedNavRouteElement = ( } const selectedChild = elements.find( - child => child.getAttribute('selected')?.toLowerCase() === 'true', + child => child.getAttribute(Types.KEY_SELECTED) === 'true', ); - return selectedChild || elements[0]; + return selectedChild; }; /** @@ -118,11 +128,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; @@ -197,25 +206,37 @@ 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 card 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_CARD; +}; + +/** + * Search for a route with the given id + */ +export const getRouteById = ( + doc: Document, + id: string, +): Element | undefined => { + const routes = Array.from( + doc.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + TypesLegacy.LOCAL_NAME.NAV_ROUTE, + ), + ).filter((n: Element) => { + return n.getAttribute(Types.KEY_ID) === id; + }); + return routes && routes.length > 0 ? routes[0] : undefined; }; /** @@ -268,21 +289,26 @@ export const buildRequest = ( validateUrl(action, routeParams); const [navigation, path] = getNavigatorAndPath( - routeParams.targetId ?? '', + 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 ?? undefined); if (!path || !path.length) { - return [navigation, routeId, routeParams]; + return [navigation, routeId, cleanedParams]; } // The first path id the screen which will receive the initial request @@ -297,8 +323,173 @@ export const buildRequest = ( | TypesLegacy.NavigationRouteParams = buildParams( routeId, path, - routeParams, + cleanedParams, ); return [navigation, lastPathId || routeId, params]; }; + +/** + * Create a map of from a list of nodes + */ +const nodesToMap = (nodes: NodeListOf): Types.RouteMap => { + const map: Types.RouteMap = {}; + if (!nodes) { + return map; + } + Array.from(nodes).forEach(node => { + if (node.nodeType === TypesLegacy.NODE_TYPE.ELEMENT_NODE) { + const element = node as Element; + if (isNavigationElement(element)) { + const id = element.getAttribute(Types.KEY_ID); + if (id) { + map[id] = element; + } + } + } + }); + return map; +}; + +/** + * 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: Element, newNodes: NodeListOf): 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 Element; + if (isNavigationElement(element)) { + if (element.localName === TypesLegacy.LOCAL_NAME.NAVIGATOR) { + element.setAttribute(Types.KEY_MERGE, 'false'); + } else if (element.localName === TypesLegacy.LOCAL_NAME.NAV_ROUTE) { + element.setAttribute(Types.KEY_SELECTED, 'false'); + } + } + }); + + const currentMap: Types.RouteMap = nodesToMap(current.childNodes); + + Array.from(newNodes).forEach(node => { + if (node.nodeType === TypesLegacy.NODE_TYPE.ELEMENT_NODE) { + const newElement = node as Element; + if (isNavigationElement(newElement)) { + const id = newElement.getAttribute(Types.KEY_ID); + if (id) { + const currentElement = currentMap[id] as Element; + if (currentElement) { + if (newElement.localName === TypesLegacy.LOCAL_NAME.NAVIGATOR) { + const isMergeable = + newElement.getAttribute(Types.KEY_MERGE) === 'true'; + if (isMergeable) { + currentElement.setAttribute(Types.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( + Types.KEY_SELECTED, + newElement.getAttribute(Types.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: Document, + currentDoc?: Document, +): Document => { + if (!currentDoc) { + return newDoc; + } + if (!newDoc || !newDoc.childNodes) { + return currentDoc; + } + + // Create a clone of the current document + const composite = currentDoc.cloneNode(true) as Document; + 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; +}; + +export const setSelected = ( + doc: Document | undefined, + id: string | undefined, +) => { + if (!doc || !id) { + return; + } + const route = getRouteById(doc, id); + if (route) { + // Reset all siblings + if (route.parentNode && route.parentNode.childNodes) { + Array.from(route.parentNode.childNodes).forEach((child: Node) => { + const sibling = child as Element; + if (sibling && sibling.localName === TypesLegacy.LOCAL_NAME.NAV_ROUTE) { + sibling.setAttribute(Types.KEY_SELECTED, 'false'); + } + }); + } + + // Set the selected route + route.setAttribute(Types.KEY_SELECTED, 'true'); + } +}; + +/** + * Remove a stack route from the document + */ +export const removeStackRoute = ( + doc: Document | undefined, + id: string | undefined, +) => { + if (!doc || !id) { + return; + } + const route = getRouteById(doc, id); + if (route && route.parentNode) { + const parentNode = route.parentNode as Element; + const type = parentNode.getAttribute(Types.KEY_TYPE); + if (type === Types.NAVIGATOR_TYPE.STACK) { + route.parentNode.removeChild(route); + } + } +}; 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 412fc36b7..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); }; @@ -120,13 +120,22 @@ 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 { + isDynamicRoute, isUrlFragment, cleanHrefFragment, getChildElements, + getRouteById, getSelectedNavRouteElement, getUrlFromHref, + mergeDocument, + removeStackRoute, + setSelected, } from './helpers'; -export { ID_DYNAMIC, ID_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 233be1883..6b8a14066 100644 --- a/src/services/navigator/types.ts +++ b/src/services/navigator/types.ts @@ -9,8 +9,13 @@ import * as TypesLegacy from 'hyperview/src/types'; 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'; +export const KEY_MODAL = 'modal'; +export const KEY_ID = 'id'; +export const KEY_TYPE = 'type'; /** * Definition of the available navigator types @@ -37,6 +42,7 @@ export type NavigationProp = { goBack: () => void; getState: () => NavigationState; getParent: (id?: string) => NavigationProp | undefined; + addListener: (event: string, callback: () => void) => () => void; }; /** @@ -64,3 +70,10 @@ export type NavigationState = { type: string; history?: unknown[]; }; + +/** + * Type defining a map of + */ +export type RouteMap = { + [key: string]: Element; +}; diff --git a/src/types.ts b/src/types.ts index 20cbb28e6..eb575f758 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 = ( @@ -211,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; @@ -255,6 +260,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', @@ -352,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; @@ -367,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; }; @@ -383,3 +405,23 @@ export type Fetch = ( input: RequestInfo | URL, init?: RequestInit | undefined, ) => Promise; + +export type OnUpdateCallbacks = { + clearElementError: () => void; + getNavigation: () => Navigation; + getOnUpdate: () => HvComponentOnUpdate; + getDoc: () => Document | null; + registerPreload: (id: number, element: Element) => void; + setNeedsLoad: () => void; + getState: () => ScreenState; + setState: (state: ScreenState) => void; +}; + +export type ScreenState = { + doc?: Document | null; + elementError?: Error | null; + error?: Error | null; + staleHeaderType?: XResponseStaleReason | null; + styles?: Stylesheets.StyleSheets | null; + url?: string | null; +};