diff --git a/packages/datascreens/src/dataListBase.ts b/packages/datascreens/src/dataListBase.ts index c391db81..50ee8482 100644 --- a/packages/datascreens/src/dataListBase.ts +++ b/packages/datascreens/src/dataListBase.ts @@ -3,7 +3,9 @@ import { IPagingInfo } from "@frui.ts/data"; import { observable } from "mobx"; export default abstract class DataListBase implements IPagingInfo { - @observable.shallow itemsValue?: T[]; + @observable.shallow + private itemsValue?: T[]; + get items() { return this.itemsValue; } diff --git a/packages/datascreens/src/detailViewModel.ts b/packages/datascreens/src/detailViewModel.ts index 650ef174..3815ea03 100644 --- a/packages/datascreens/src/detailViewModel.ts +++ b/packages/datascreens/src/detailViewModel.ts @@ -1,3 +1,4 @@ +import type { Awaitable } from "@frui.ts/helpers"; import { BusyWatcher, ScreenBase } from "@frui.ts/screens"; import { action, observable } from "mobx"; @@ -16,5 +17,5 @@ export default abstract class DetailViewModel extends ScreenBase { this.setItem(item); } } - protected abstract loadDetail(): Promise | TEntity | undefined; + protected abstract loadDetail(): Awaitable; } diff --git a/packages/datascreens/src/filteredList.ts b/packages/datascreens/src/filteredList.ts index e2cdb10f..8c3da0a7 100644 --- a/packages/datascreens/src/filteredList.ts +++ b/packages/datascreens/src/filteredList.ts @@ -1,6 +1,7 @@ import type { PagedQueryResult } from "@frui.ts/data"; import { IPagingFilter } from "@frui.ts/data"; import { attachDirtyWatcher, AutomaticDirtyWatcher } from "@frui.ts/dirtycheck"; +import type { Awaitable } from "@frui.ts/helpers"; import { bound } from "@frui.ts/helpers"; import { validate } from "@frui.ts/validation"; import { action, isArrayLike, observable, runInAction } from "mobx"; @@ -38,7 +39,7 @@ export default class FilteredList< } constructor( - public onLoadData: (filter: TFilter, paging: IPagingFilter) => Promise | void>, + public onLoadData: (filter: TFilter, paging: IPagingFilter) => Awaitable | void>, private initFilter: () => TFilter = () => ({} as TFilter), private defaultPagingFilter: (previous?: Readonly) => IPagingFilter = previous => ({ limit: FilteredList.defaultPageSize, diff --git a/packages/helpers/src/types.ts b/packages/helpers/src/types.ts index db74077a..29a5d88e 100644 --- a/packages/helpers/src/types.ts +++ b/packages/helpers/src/types.ts @@ -1,3 +1,5 @@ +export type Awaitable = T | PromiseLike; + export type BindingTarget = Map | Record; export type PropertyName = keyof TTarget & string; diff --git a/packages/screens/__tests__/router/urlRouterBase.test.ts b/packages/screens/__tests__/router/urlRouterBase.test.ts index 15567c97..2a32c006 100644 --- a/packages/screens/__tests__/router/urlRouterBase.test.ts +++ b/packages/screens/__tests__/router/urlRouterBase.test.ts @@ -15,7 +15,7 @@ describe("UrlRouterBase", () => { describe("initialize", () => { it("serializes and sets the current path", async () => { const navigator = mock(); - navigator.getNavigationState.mockReturnValue({ name: "my-screen" }); + navigator.getNavigationState.mockReturnValue([{ name: "my-screen" }]); const router = new TestRouter(navigator); @@ -28,7 +28,7 @@ describe("UrlRouterBase", () => { describe("navigate", () => { it("deserializes path and calls navigate", async () => { const navigator = mock(); - navigator.getNavigationState.mockReturnValue({ name: "my-screen" }); + navigator.getNavigationState.mockReturnValue([{ name: "my-screen" }]); const router = new TestRouter(navigator); diff --git a/packages/screens/src/busyWatcher.ts b/packages/screens/src/busyWatcher.ts index dc6f9755..244a64d5 100644 --- a/packages/screens/src/busyWatcher.ts +++ b/packages/screens/src/busyWatcher.ts @@ -1,3 +1,4 @@ +import type { Awaitable } from "@frui.ts/helpers"; import { action, computed, ObservableMap } from "mobx"; export type BusyWatcherKey = string | symbol; @@ -60,7 +61,7 @@ export function watchBusy(target: any, propertyKey?: string, descriptor?: Proper const key = isCustomKey ? (target as BusyWatcherKey) : Symbol(); const decorator: Decorator = (target, propertyKey, descriptor) => { - const originalFunction = descriptor.value as (...args: any) => Promise | unknown; + const originalFunction = descriptor.value as (...args: any) => Awaitable; descriptor.value = function (this: { busyWatcher?: BusyWatcher }, ...args: any[]) { const ticket = this.busyWatcher?.getBusyTicket(key); diff --git a/packages/screens/src/index.ts b/packages/screens/src/index.ts index f33c9abb..cd1c609b 100644 --- a/packages/screens/src/index.ts +++ b/packages/screens/src/index.ts @@ -1,4 +1,6 @@ +export * from "./models/findChildResult"; export * from "./models/navigationContext"; +export * from "./models/pathElements"; export { default as ScreenBase, getNavigator } from "./screens/screenBase"; export * from "./navigation/types"; diff --git a/packages/screens/src/models/findChildResult.ts b/packages/screens/src/models/findChildResult.ts new file mode 100644 index 00000000..c0105287 --- /dev/null +++ b/packages/screens/src/models/findChildResult.ts @@ -0,0 +1,20 @@ +import type { PathElement } from "./pathElements"; + +export type FindChildResult = ChildFoundResult | NoChildResult; + +export interface ChildFoundResult { + newChild: TChild; + closePrevious?: boolean; + + pathForChild?: PathElement[]; + attachToParent?: boolean; +} + +export interface NoChildResult { + newChild: undefined; + closePrevious?: boolean; +} + +export function isChildFoundResult(result: FindChildResult): result is ChildFoundResult { + return !!result.newChild; +} diff --git a/packages/screens/src/models/navigationContext.ts b/packages/screens/src/models/navigationContext.ts index acc39603..35959fbd 100644 --- a/packages/screens/src/models/navigationContext.ts +++ b/packages/screens/src/models/navigationContext.ts @@ -1,5 +1,5 @@ import type { ScreenNavigator } from "../navigation/types"; -import type PathElement from "./pathElements"; +import type { PathElement } from "./pathElements"; export interface NavigationContext { screen?: TScreen; diff --git a/packages/screens/src/models/pathElements.ts b/packages/screens/src/models/pathElements.ts index 3af6164f..cf701c21 100644 --- a/packages/screens/src/models/pathElements.ts +++ b/packages/screens/src/models/pathElements.ts @@ -1,4 +1,4 @@ -export default interface PathElement { +export interface PathElement { name: string; - params?: Record; + params?: Record; } diff --git a/packages/screens/src/navigation/conductors/activeChildConductor.ts b/packages/screens/src/navigation/conductors/activeChildConductor.ts index 899cb6d3..8bdf404b 100644 --- a/packages/screens/src/navigation/conductors/activeChildConductor.ts +++ b/packages/screens/src/navigation/conductors/activeChildConductor.ts @@ -1,6 +1,9 @@ +import type { Awaitable } from "@frui.ts/helpers"; import { computed, observable, runInAction } from "mobx"; +import type { FindChildResult } from "../../models/findChildResult"; +import { isChildFoundResult } from "../../models/findChildResult"; import type { ClosingNavigationContext, NavigationContext } from "../../models/navigationContext"; -import type PathElement from "../../models/pathElements"; +import type { PathElement } from "../../models/pathElements"; import { getNavigator } from "../../screens/screenBase"; import LifecycleScreenNavigatorBase from "../lifecycleScreenNavigatorBase"; import type { LifecycleScreenNavigator, ScreenNavigator } from "../types"; @@ -8,7 +11,7 @@ import type { LifecycleScreenNavigator, ScreenNavigator } from "../types"; export default class ActiveChildConductor< TChild = unknown, TScreen = any, - TNavigationParams extends Record = Record + TNavigationParams extends Record = Record > extends LifecycleScreenNavigatorBase { @observable.ref private activeChildValue?: TChild = undefined; @@ -17,13 +20,13 @@ export default class ActiveChildConductor< } // extension point, implement this to decide what navigate should do - canChangeActiveChild?: (context: NavigationContext, currentChild: TChild | undefined) => Promise; + canChangeActiveChild?: (context: NavigationContext, currentChild: TChild | undefined) => Awaitable; // extension point, implement this to decide what navigate should do findNavigationChild?: ( context: NavigationContext, currentChild: TChild | undefined - ) => Promise<{ newChild: TChild | undefined; closePrevious?: boolean }>; + ) => Awaitable>; // default functionality overrides @@ -72,18 +75,22 @@ export default class ActiveChildConductor< await this.callAll("onNavigate", context); const currentChild = this.activeChild; - const { newChild, closePrevious } = await this.findNavigationChild(context, currentChild); + const childResult = await this.findNavigationChild(context, currentChild); - if (currentChild !== newChild) { + if (currentChild !== childResult.newChild) { const currentChildNavigator = getNavigator(currentChild); - await currentChildNavigator?.deactivate?.(!!closePrevious); + await currentChildNavigator?.deactivate?.(!!childResult.closePrevious); - runInAction(() => (this.activeChildValue = newChild)); + runInAction(() => (this.activeChildValue = childResult.newChild)); } - if (newChild) { - const newChildNavigator = getNavigator(newChild); - await newChildNavigator?.navigate(path.slice(1)); + if (isChildFoundResult(childResult)) { + if (childResult.attachToParent !== false) { + this.connectChild(childResult.newChild); + } + + const newChildNavigator = getNavigator(childResult.newChild); + await newChildNavigator?.navigate(childResult.pathForChild ?? path.slice(1)); } } diff --git a/packages/screens/src/navigation/conductors/allChildrenActiveConductor.ts b/packages/screens/src/navigation/conductors/allChildrenActiveConductor.ts index 7d34429f..f2a6e276 100644 --- a/packages/screens/src/navigation/conductors/allChildrenActiveConductor.ts +++ b/packages/screens/src/navigation/conductors/allChildrenActiveConductor.ts @@ -7,7 +7,7 @@ import type ScreenLifecycleEventHub from "../screenLifecycleEventHub"; export default class AllChildrenActiveConductor< TChild = unknown, TScreen = any, - TNavigationParams extends Record = Record + TNavigationParams extends Record = Record > extends LifecycleScreenNavigatorBase { readonly children: TChild[]; diff --git a/packages/screens/src/navigation/conductors/oneOfListActiveConductor.ts b/packages/screens/src/navigation/conductors/oneOfListActiveConductor.ts index 317b950b..fb45b8ac 100644 --- a/packages/screens/src/navigation/conductors/oneOfListActiveConductor.ts +++ b/packages/screens/src/navigation/conductors/oneOfListActiveConductor.ts @@ -1,5 +1,6 @@ import type { IArrayWillChange, IArrayWillSplice } from "mobx"; import { observable } from "mobx"; +import type { FindChildResult } from "../../models/findChildResult"; import type { NavigationContext } from "../../models/navigationContext"; import { getNavigator } from "../../screens/screenBase"; import type ScreenLifecycleEventHub from "../screenLifecycleEventHub"; @@ -9,7 +10,7 @@ import ActiveChildConductor from "./activeChildConductor"; export default class OneOfListActiveConductor< TChild = unknown, TScreen = any, - TNavigationParams extends Record = Record + TNavigationParams extends Record = Record > extends ActiveChildConductor { readonly children: TChild[]; @@ -38,13 +39,12 @@ export default class OneOfListActiveConductor< findNavigationChild = (context: NavigationContext, currentChild: TChild | undefined) => { const searchedNavigationName = context.path[1]?.name; const newChild = this.findChild(searchedNavigationName); - const result = { newChild, closePrevious: false }; - return Promise.resolve(result); + return { newChild, closePrevious: false } as FindChildResult; }; private findChild(navigationName: string | undefined) { if (this.preserveActiveChild && navigationName === undefined) { - return this.activeChild; + return this.activeChild ?? this.children[0]; } return navigationName !== undefined ? this.children.find(x => getNavigator(x)?.navigationName === navigationName) : undefined; diff --git a/packages/screens/src/navigation/debugHelper.ts b/packages/screens/src/navigation/debugHelper.ts index 95d1b5b6..3f17b228 100644 --- a/packages/screens/src/navigation/debugHelper.ts +++ b/packages/screens/src/navigation/debugHelper.ts @@ -1,5 +1,5 @@ import { get, isArrayLike } from "mobx"; -import type PathElement from "../models/pathElements"; +import type { PathElement } from "../models/pathElements"; import type UrlRouterBase from "../router/urlRouterBase"; import type ScreenBase from "../screens/screenBase"; import { getNavigator } from "../screens/screenBase"; @@ -10,7 +10,7 @@ interface ViewModelInfo { name?: string; navigationPath?: string; navigationName?: string; - navigationState?: PathElement; + navigationState?: PathElement[]; activeChild?: ViewModelInfo; children?: ViewModelInfo[]; instance: any; diff --git a/packages/screens/src/navigation/lifecycleScreenNavigatorBase.ts b/packages/screens/src/navigation/lifecycleScreenNavigatorBase.ts index de759f05..ce611ded 100644 --- a/packages/screens/src/navigation/lifecycleScreenNavigatorBase.ts +++ b/packages/screens/src/navigation/lifecycleScreenNavigatorBase.ts @@ -1,6 +1,6 @@ import { computed, observable, runInAction } from "mobx"; import type { ClosingNavigationContext, NavigationContext } from "../models/navigationContext"; -import type PathElement from "../models/pathElements"; +import type { PathElement } from "../models/pathElements"; import type { HasLifecycleEvents } from "../screens/hasLifecycleHandlers"; import type ScreenBase from "../screens/screenBase"; import type ScreenLifecycleEventHub from "./screenLifecycleEventHub"; @@ -8,7 +8,7 @@ import type { LifecycleScreenNavigator, ScreenNavigator } from "./types"; export default abstract class LifecycleScreenNavigatorBase< TScreen extends Partial & Partial, - TNavigationParams extends Record + TNavigationParams extends Record > implements LifecycleScreenNavigator { // extension point - you can either set getNavigationName function, or assign navigationName property @@ -44,8 +44,10 @@ export default abstract class LifecycleScreenNavigatorBase< return this.aggregateBooleanAll("canNavigate", context); } - getNavigationParams?: () => TNavigationParams; - getNavigationState(): PathElement { + getNavigationParams?: () => TNavigationParams | undefined; + getNavigationState: () => PathElement[] = () => [this.createDefaultNavigationState()]; + + createDefaultNavigationState() { return { name: this.navigationName, params: this.getNavigationParams?.(), diff --git a/packages/screens/src/navigation/simpleScreenNavigator.ts b/packages/screens/src/navigation/simpleScreenNavigator.ts index b4422d2e..95192f87 100644 --- a/packages/screens/src/navigation/simpleScreenNavigator.ts +++ b/packages/screens/src/navigation/simpleScreenNavigator.ts @@ -1,8 +1,17 @@ import type { HasLifecycleEvents } from "../screens/hasLifecycleHandlers"; import type ScreenBase from "../screens/screenBase"; import LifecycleScreenNavigatorBase from "./lifecycleScreenNavigatorBase"; +import type ScreenLifecycleEventHub from "./screenLifecycleEventHub"; export default class SimpleScreenNavigator< TScreen extends Partial & Partial = any, - TNavigationParams extends Record = Record -> extends LifecycleScreenNavigatorBase {} + TNavigationParams extends Record = Record +> extends LifecycleScreenNavigatorBase { + constructor(screen?: TScreen, navigationPrefix?: string, eventHub?: ScreenLifecycleEventHub) { + super(screen, eventHub); + + if (navigationPrefix) { + this.getNavigationState = () => [{ name: navigationPrefix }, this.createDefaultNavigationState()]; + } + } +} diff --git a/packages/screens/src/navigation/types.ts b/packages/screens/src/navigation/types.ts index f6a60204..74358be5 100644 --- a/packages/screens/src/navigation/types.ts +++ b/packages/screens/src/navigation/types.ts @@ -1,19 +1,20 @@ -import type PathElement from "../models/pathElements"; +import type { Awaitable } from "@frui.ts/helpers"; +import type { PathElement } from "../models/pathElements"; export interface ScreenNavigator { readonly isActive: boolean; - canNavigate(path: PathElement[]): Promise | boolean; + canNavigate(path: PathElement[]): Awaitable; navigate(path: PathElement[]): Promise; navigationName: string; - getNavigationState(): PathElement; + getNavigationState(): PathElement[]; parent: ScreenNavigator | undefined; getPrimaryChild(): ScreenNavigator | undefined; } export interface LifecycleScreenNavigator extends ScreenNavigator { - canDeactivate(isClosing: boolean): Promise | boolean; + canDeactivate(isClosing: boolean): Awaitable; deactivate(isClosing: boolean): Promise; } diff --git a/packages/screens/src/router/routerBase.ts b/packages/screens/src/router/routerBase.ts index 025cab17..a548c3ef 100644 --- a/packages/screens/src/router/routerBase.ts +++ b/packages/screens/src/router/routerBase.ts @@ -1,4 +1,4 @@ -import type PathElement from "../models/pathElements"; +import type { PathElement } from "../models/pathElements"; import type { ScreenNavigator } from "../navigation/types"; export default abstract class RouterBase { @@ -13,7 +13,7 @@ export default abstract class RouterBase { let navigator = this.rootNavigator; while (navigator) { - path.push(navigator.getNavigationState()); + path.push(...navigator.getNavigationState()); navigator = navigator.getPrimaryChild(); } @@ -25,20 +25,20 @@ export default abstract class RouterBase { const childPath = child?.getNavigationState(); if (childPath) { - path.push(childPath); + path.push(...childPath); } let navigator: ScreenNavigator | undefined = parent; while (navigator) { - path.unshift(navigator.getNavigationState()); + path.unshift(...navigator.getNavigationState()); navigator = navigator.parent; } return path; } - protected cloneWithChildPath(path: PathElement[], child: ScreenNavigator | undefined) { + protected cloneWithChildPath(path: PathElement[], child: ScreenNavigator | undefined): PathElement[] { const childPath = child?.getNavigationState(); - return childPath ? [...path, childPath] : path; + return childPath ? [...path, ...childPath] : path; } } diff --git a/packages/screens/src/router/urlRouterBase.ts b/packages/screens/src/router/urlRouterBase.ts index 3312ab7b..33381703 100644 --- a/packages/screens/src/router/urlRouterBase.ts +++ b/packages/screens/src/router/urlRouterBase.ts @@ -1,4 +1,5 @@ -import type PathElement from "../models/pathElements"; +import type { Awaitable } from "@frui.ts/helpers"; +import type { PathElement } from "../models/pathElements"; import type ScreenBase from "../screens/screenBase"; import type { RouteDefinition, RouteName } from "./route"; import { Route } from "./route"; @@ -11,7 +12,11 @@ const SEGMENT_REGEX = /^(?[\w-]+)(\[(?\S+)\])?$/; export default abstract class UrlRouterBase extends RouterBase implements Router { protected routes = new Map>(); - async initialize() { + initialize() { + return this.updateUrl(); + } + + async updateUrl() { const path = this.getCurrentPath(); if (path.length) { const url = this.serializePath(path); @@ -19,7 +24,7 @@ export default abstract class UrlRouterBase extends RouterBase implements Router } } - protected abstract persistUrl(url: string): Promise; + protected abstract persistUrl(url: string): Awaitable; registerRoute(definition: RouteDefinition) { const names = Array.isArray(definition.name) ? definition.name : [definition.name]; @@ -33,13 +38,7 @@ export default abstract class UrlRouterBase extends RouterBase implements Router async navigate(path: string | PathElement[]) { // TODO unwrap path if alias for a route - const elements: PathElement[] = - typeof path === "string" - ? path - .split(URL_SEPARATOR) - .filter(x => x) - .map(x => this.deserializePath(x) ?? { name: "parse-error" }) - : path; + const elements: PathElement[] = typeof path === "string" ? this.deserializePath(path) : path; await this.rootNavigator?.navigate(elements); const currentPath = this.getCurrentPath(); @@ -119,14 +118,24 @@ export default abstract class UrlRouterBase extends RouterBase implements Router protected serializePathElement(element: PathElement): string { if (element.params) { - const values = Object.entries(element.params).map(([key, value]) => `${key}=${encodeURIComponent(value)}`); + const values = Object.entries(element.params) + .filter(([key, value]) => value !== undefined) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + .map(([key, value]) => `${key}=${encodeURIComponent(value!)}`); return `${element.name}[${values.join(",")}]`; } else { return element.name; } } - protected deserializePath(text: string): PathElement | undefined { + protected deserializePath(path: string) { + return path + .split(URL_SEPARATOR) + .filter(x => x) + .map(x => this.deserializePathSegment(x) ?? { name: "parse-error" }); + } + + protected deserializePathSegment(text: string): PathElement | undefined { const match = SEGMENT_REGEX.exec(text)?.groups; if (!match) { diff --git a/packages/screens/src/screens/hasLifecycleHandlers.ts b/packages/screens/src/screens/hasLifecycleHandlers.ts index f8902c11..244f6a5b 100644 --- a/packages/screens/src/screens/hasLifecycleHandlers.ts +++ b/packages/screens/src/screens/hasLifecycleHandlers.ts @@ -1,24 +1,25 @@ +import type { Awaitable } from "@frui.ts/helpers"; import type { ClosingNavigationContext, NavigationContext } from "../models/navigationContext"; export interface HasLifecycleEvents { /** Called only once, on the first call of navigate */ - onInitialize: (context: NavigationContext) => Promise | void; + onInitialize: (context: NavigationContext) => Awaitable; /** Checks if the new route could be activated. Used mainly on conductors to check if current child can be closed because of the new route. */ - canNavigate: (context: NavigationContext) => Promise | boolean; + canNavigate: (context: NavigationContext) => Awaitable; /** Called whenever navigate is called on a deactivated screen */ - onActivate: (context: NavigationContext) => Promise | void; + onActivate: (context: NavigationContext) => Awaitable; /** Called on every navigattion path change */ - onNavigate: (context: NavigationContext) => Promise | void; + onNavigate: (context: NavigationContext) => Awaitable; /** Returns value whether the current screen could be closed */ - canDeactivate: (context: ClosingNavigationContext) => Promise | boolean; + canDeactivate: (context: ClosingNavigationContext) => Awaitable; /** Called on every screen deactivation */ - onDeactivate: (context: ClosingNavigationContext) => Promise | void; + onDeactivate: (context: ClosingNavigationContext) => Awaitable; /** Called when the screen is about to be closed */ - onDispose: (context: ClosingNavigationContext) => Promise | void; + onDispose: (context: ClosingNavigationContext) => Awaitable; } diff --git a/packages/views/src/useScreenLifecycle.ts b/packages/views/src/useScreenLifecycle.ts index a21288b6..0973d4b0 100644 --- a/packages/views/src/useScreenLifecycle.ts +++ b/packages/views/src/useScreenLifecycle.ts @@ -5,10 +5,15 @@ import { useEffect } from "react"; export default function useScreenLifecycle(vm: any, closeOnCleanup = true) { useEffect(() => { const navigator = getNavigator(vm); + if (!navigator) { + return; + } - void navigator?.navigate([]); + if (!navigator.isActive) { + void navigator.navigate([]); + } - if (navigator?.deactivate) { + if (navigator.deactivate) { return () => { void navigator.deactivate?.(closeOnCleanup); }; diff --git a/stories/src/viewModels/testRouter.ts b/stories/src/viewModels/testRouter.ts index a6c22377..50771c2e 100644 --- a/stories/src/viewModels/testRouter.ts +++ b/stories/src/viewModels/testRouter.ts @@ -7,9 +7,8 @@ export default class TestRouter extends UrlRouterBase { currentPath: string; @action - protected persistUrl(path: string): Promise { + protected persistUrl(path: string) { this.currentPath = path; - return Promise.resolve(); } hrefParams(url: string, onClick?: MouseEventHandler) {