history#Location TypeScript Examples

The following examples show how to use history#Location. You can vote up the ones you like or vote down the ones you don't like, and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: utils.ts    From backstage with Apache License 2.0 7 votes vote down vote up
export function isLocationMatch(currentLocation: Location, toLocation: Path) {
  const toDecodedSearch = new URLSearchParams(toLocation.search).toString();
  const toQueryParameters = qs.parse(toDecodedSearch);

  const currentDecodedSearch = new URLSearchParams(
    currentLocation.search,
  ).toString();
  const currentQueryParameters = qs.parse(currentDecodedSearch);

  const matching =
    isEqual(toLocation.pathname, currentLocation.pathname) &&
    isMatch(currentQueryParameters, toQueryParameters);

  return matching;
}
Example #2
Source File: LocationContext.ts    From next-core with GNU General Public License v3.0 6 votes vote down vote up
handleBeforePageLeave(detail: {
    location?: Location<PluginHistoryState>;
    action?: Action;
  }): void {
    this.dispatchLifeCycleEvent(
      new CustomEvent("page.beforeLeave", {
        detail,
      }),
      this.beforePageLeaveHandlers
    );
  }
Example #3
Source File: useLocation.ts    From rocon with MIT License 6 votes vote down vote up
useLocation = (): Location => {
  const location = useContext(LocationContext);
  if (location === undefined) {
    throw new Error(
      "No location found. Please make sure you have placed RoconRoot above."
    );
  }
  return location;
}
Example #4
Source File: RoconRoot.tsx    From rocon with MIT License 6 votes vote down vote up
RoconRoot: React.FC<RoconRootProps> = memo((props) => {
  const history = useMemo(
    () => props.history || createBrowserHistory(),
    [props.history]
  );

  const historyContextValue = useMemo(() => {
    return {
      history,
    };
  }, [history]);

  const [location, setLocation] = useState<Location>(history.location);

  useEffect(() => {
    // this `setLocation` handles the case where location is changed after above `history.location` is calculated
    setLocation(history.location);
    return history.listen((state) => {
      setLocation(state.location);
    });
  }, [history]);

  return (
    <HistoryContext.Provider value={historyContextValue}>
      <LocationContext.Provider value={location}>
        {props.children}
      </LocationContext.Provider>
    </HistoryContext.Provider>
  );
})
Example #5
Source File: index.tsx    From subscan-multisig-react with Apache License 2.0 6 votes vote down vote up
// redirect on invalid tabs
// eslint-disable-next-line complexity
function redirect(basePath: string, location: Location, items: TabItem[], hidden?: string[] | null | false): void {
  if (location.pathname !== basePath) {
    // Has the form /staking/query/<something>
    const [, , section] = location.pathname.split('/');
    // eslint-disable-next-line @typescript-eslint/no-shadow
    const alias = items.find(({ alias }) => alias === section);

    if (alias) {
      window.location.hash = alias.isRoot ? basePath : `${basePath}/${alias.name}`;
    } else if (hidden && (hidden.includes(section) || !items.some(({ isRoot, name }) => !isRoot && name === section))) {
      window.location.hash = basePath;
    }
  }
}
Example #6
Source File: useLocation.ts    From next-core with GNU General Public License v3.0 6 votes vote down vote up
/** @internal */
export function useLocation(): Location<PluginHistoryState> {
  const history = getHistory();
  const [location, setLocation] = React.useState(history.location);

  React.useEffect(() => {
    const unlisten = history.listen((location) => setLocation(location));
    return unlisten;
  }, [history]);

  return location;
}
Example #7
Source File: Router.ts    From next-core with GNU General Public License v3.0 6 votes vote down vote up
private getBlockMessageBeforePageLave(detail: {
    location?: Location<PluginHistoryState>;
    action?: Action;
  }): string {
    const history = getHistory();
    const previousMessage = history.getBlockMessage();
    this.locationContext?.handleBeforePageLeave(detail);
    const message = history.getBlockMessage();
    if (!previousMessage && message) {
      // Auto unblock only if new block was introduced by `onBeforePageLeave`.
      history.unblock();
    }
    return message;
  }
Example #8
Source File: Router.spec.ts    From next-core with GNU General Public License v3.0 6 votes vote down vote up
spyOnHistory = {
  location: {
    pathname: "/yo",
  } as unknown as Location,
  listen: jest.fn((fn: LocationListener) => {
    historyListeners.push(fn);
    return () => {
      const index = historyListeners.indexOf(fn);
      if (index >= 0) {
        historyListeners.splice(index, 1);
      }
    };
  }),
  replace: jest.fn(),
  block: jest.fn((fn) => {
    blockFn = fn;
  }),
  createHref: () => "/oops",
  setBlockMessage,
  getBlockMessage,
  unblock,
}
Example #9
Source File: Tracker.ts    From nosgestesclimat-site with MIT License 6 votes vote down vote up
track(loc: Location) {
		const currentPath = loc.pathname + loc.search

		if (this.previousPath === currentPath) {
			return
		}
		if (this.previousPath) {
			this.push(['setReferrerUrl', this.previousPath])
		}
		this.push(['setCustomUrl', currentPath])
		// TODO: We should also call 'setDocumentTitle' but at this point the
		// document.title isn't updated yet.
		this.push(['trackPageView'])
		this.previousPath = currentPath
	}
Example #10
Source File: tour.tsx    From clearflask with Apache License 2.0 6 votes vote down vote up
setStep = async (
  dispatch: ThunkDispatch<ReduxStateTour, undefined, AllTourActions>,
  history: History,
  location: Location,
  guideId: string,
  guide: TourDefinitionGuide,
  onGuideCompleted: TourData['onGuideCompleted'],
  stepId?: string,
  step?: TourDefinitionGuideStep,
) => {
  dispatch({
    type: 'tourSetStep', payload: {
      activeStep: undefined,
    }
  });
  if (!!stepId && !!step) {
    if (!!step.openPath && location.pathname !== step.openPath) {
      await new Promise(resolve => setTimeout(resolve, 250));
      history.push(step.openPath);
    }
    await new Promise(resolve => setTimeout(resolve, step.showDelay !== undefined ? step.showDelay : 250));
    dispatch({
      type: 'tourSetStep', payload: {
        activeStep: { guideId, stepId },
      }
    });
  } else {
    if (guide.onComplete?.openPath) {
      await new Promise(resolve => setTimeout(resolve, 250));
      history.push(guide.onComplete.openPath);
    }
    onGuideCompleted?.(guideId, guide);
  }
}
Example #11
Source File: router.ts    From che-dashboard-next with Eclipse Public License 2.0 6 votes vote down vote up
getMockRouterProps = <Params extends { [K in keyof Params]: string } = {}>(path: string, params: Params): {
  history: MemoryHistory<LocationState>;
  location: Location<LocationState>;
  match: routerMatch<Params>
} => {
  const isExact = false;
  const url = generateUrl(path, params);

  const match: routerMatch<Params> = { isExact, path, url, params };
  const history = createMemoryHistory();
  const location = createLocation(match.url);

  return { history, location, match };
}
Example #12
Source File: Items.tsx    From backstage with Apache License 2.0 6 votes vote down vote up
useLocationMatch = (
  submenu: React.ReactElement<SidebarSubmenuProps>,
  location: Location,
): boolean =>
  useElementFilter(
    submenu.props.children,
    elements => {
      let active = false;
      elements
        .getElements()
        .forEach(
          ({
            props: { to, dropdownItems },
          }: {
            props: Partial<SidebarSubmenuItemProps>;
          }) => {
            if (!active) {
              if (dropdownItems?.length) {
                dropdownItems.forEach(
                  ({ to: _to }) =>
                    (active =
                      active || isLocationMatch(location, resolvePath(_to))),
                );
                return;
              }
              if (to) {
                active = isLocationMatch(location, resolvePath(to));
              }
            }
          },
        );
      return active;
    },
    [location.pathname],
  )
Example #13
Source File: utils.test.ts    From backstage with Apache License 2.0 6 votes vote down vote up
describe('isLocationMatching', () => {
  let currentLocation: Location;
  let toLocation: Path;

  it('return false when pathname in target and current location differ', async () => {
    currentLocation = {
      pathname: '/catalog',
      search: '?kind=component',
      state: null,
      hash: '',
      key: '',
    };
    toLocation = {
      pathname: '/catalog-a',
      search: '?kind=component',
      hash: '',
    };
    expect(isLocationMatch(currentLocation, toLocation)).toBe(false);
  });

  it('return true when exact match between current and target location parameters', async () => {
    currentLocation = {
      pathname: '/catalog',
      search: '?kind=component',
      state: null,
      hash: '',
      key: '',
    };
    toLocation = { pathname: '/catalog', search: '?kind=component', hash: '' };
    expect(isLocationMatch(currentLocation, toLocation)).toBe(true);
  });

  it('return true when target query parameters are subset of current location query parameters', async () => {
    currentLocation = {
      pathname: '/catalog',
      search: '?x=foo&y=bar',
      state: null,
      hash: '',
      key: '',
    };
    toLocation = { pathname: '/catalog', search: '?x=foo', hash: '' };
    expect(isLocationMatch(currentLocation, toLocation)).toBe(true);
  });

  it('return false when no matching query parameters between target and current location', async () => {
    currentLocation = {
      pathname: '/catalog',
      search: '?y=bar',
      state: null,
      hash: '',
      key: '',
    };
    toLocation = { pathname: '/catalog', search: '?x=foo', hash: '' };
    expect(isLocationMatch(currentLocation, toLocation)).toBe(false);
  });

  it('return true when query parameters match in different order', async () => {
    currentLocation = {
      pathname: '/catalog',
      search: '?y=bar&x=foo',
      state: null,
      hash: '',
      key: '',
    };
    toLocation = { pathname: '/catalog', search: '?x=foo&y=bar', hash: '' };
    expect(isLocationMatch(currentLocation, toLocation)).toBe(true);
  });

  it('return true when there is a matching query parameter alongside extra parameters', async () => {
    currentLocation = {
      pathname: '/catalog',
      search: '?y=bar&x=foo',
      state: null,
      hash: '',
      key: '',
    };
    toLocation = { pathname: '/catalog', search: '', hash: '' };
    expect(isLocationMatch(currentLocation, toLocation)).toBe(true);
  });
});
Example #14
Source File: filters.tsx    From ke with MIT License 6 votes vote down vote up
setFilterValue = (
  location: Location,
  filterName: string,
  filterValue: Accessor<string>,
  history: History,
  setPage?: (page: number) => void
): void => {
  const filters = FilterManager.getFilters(location.search)
  filters.push({ filterName, filterOperation: undefined, value: filterValue })
  FilterManager.resetPagination(setPage)
  FilterManager.setFilters(location, filters, history)
}
Example #15
Source File: hooks.ts    From atlas with GNU General Public License v3.0 5 votes vote down vote up
useVideoWorkspaceRouting = (): Location => {
  const navigate = useNavigate()

  const location = useLocation()
  const locationState = location.state as RoutingState
  const [cachedLocation, setCachedLocation] = useState<Location>()

  const videoWorkspaceMatch = useMatch(WORKSPACE_MATCH)
  const { isWorkspaceOpen, setIsWorkspaceOpen } = useVideoWorkspace()
  const [cachedIsWorkspaceOpen, setCachedIsWorkspaceOpen] = useState(false)

  useEffect(() => {
    if (location === cachedLocation) {
      return
    }
    setCachedLocation(location)

    if (videoWorkspaceMatch && !isWorkspaceOpen) {
      // route changed to video edit
      const state: RoutingState = {
        overlaidLocation: cachedLocation ?? defaultLocation,
      }
      navigate(location, { replace: true, state })
      setIsWorkspaceOpen(true)
    }
  }, [location, cachedLocation, videoWorkspaceMatch, navigate, isWorkspaceOpen, setIsWorkspaceOpen])

  useEffect(() => {
    if (isWorkspaceOpen === cachedIsWorkspaceOpen) {
      return
    }
    setCachedIsWorkspaceOpen(isWorkspaceOpen)

    if (!isWorkspaceOpen) {
      // restore the old location when videoWorkspace was closed
      const oldLocation = locationState?.overlaidLocation ?? absoluteRoutes.studio.index()
      navigate(oldLocation)
    }
    if (isWorkspaceOpen && !videoWorkspaceMatch) {
      // isWorkspaceOpen changed without the route change, change URL and save current location
      const state: RoutingState = {
        overlaidLocation: location,
      }
      navigate(absoluteRoutes.studio.videoWorkspace(), { state: state })
    }
  }, [cachedIsWorkspaceOpen, isWorkspaceOpen, location, locationState, navigate, videoWorkspaceMatch])

  if (videoWorkspaceMatch) {
    return locationState?.overlaidLocation ?? cachedLocation ?? defaultLocation
  }

  return location
}
Example #16
Source File: LocationContext.ts    From rocon with MIT License 5 votes vote down vote up
LocationContext = createContext<Location | undefined>(undefined)
Example #17
Source File: hooks.ts    From atlas with GNU General Public License v3.0 5 votes vote down vote up
defaultLocation: Location = {
  pathname: absoluteRoutes.studio.index(),
  key: '',
  search: '',
  hash: '',
  state: null,
}
Example #18
Source File: useModalRoute.ts    From querybook with Apache License 2.0 5 votes vote down vote up
export function useModalRoute(location: Location<{ isModal: boolean }>) {
    const disableModal = useSelector(
        (state: IStoreState) =>
            state.user.computedSettings.show_full_view === 'enabled'
    );
    return Boolean(!disableModal && location?.state?.isModal);
}
Example #19
Source File: App.tsx    From minesweeper with MIT License 5 votes vote down vote up
Navigation: FC = () => {
  const [searchParams] = useSearchParams();
  const level = searchParams.get('level') || '';

  const getLocationObjWithSearchParams = (
    pathname: string
  ): Partial<Location> => ({
    pathname,
    search: `${
      level &&
      `?${new URLSearchParams({
        level,
      }).toString()}`
    }`,
  });

  return (
    <nav>
      <ul>
        <li>
          <Link to={getLocationObjWithSearchParams('/')}>Home</Link>
        </li>
        <li>
          <Link to={getLocationObjWithSearchParams('/minesweeper/hooks')}>
            Game With Hooks
          </Link>
        </li>
        <li>
          <Link to={getLocationObjWithSearchParams('/minesweeper/usereducer')}>
            Game With useReducer
          </Link>
        </li>
        <li>
          <Link to={getLocationObjWithSearchParams('/minesweeper/reactredux')}>
            Game With ReactRedux
          </Link>
        </li>
      </ul>
    </nav>
  );
}
Example #20
Source File: routerStore.ts    From lightning-terminal with MIT License 5 votes vote down vote up
/** the current location from the browser's history */
  location: Location;
Example #21
Source File: Router.spec.ts    From next-core with GNU General Public License v3.0 5 votes vote down vote up
mockHistoryPop = (location: Partial<Location>): void => {
  historyListeners.forEach((fn) => {
    fn(location as Location, "POP");
  });
}
Example #22
Source File: Router.spec.ts    From next-core with GNU General Public License v3.0 5 votes vote down vote up
mockHistoryPush = (location: Partial<Location>): void => {
  historyListeners.forEach((fn) => {
    fn(location as Location, "PUSH");
  });
}
Example #23
Source File: AppContainer.tsx    From anthem with Apache License 2.0 5 votes vote down vote up
routeChangeListener = (location: Location, action: Action) => {
    // Dispatch relevant actions for routing events.
    Analytics.page();
    this.props.onRouteChange({ ...location, action });
  };
Example #24
Source File: WorkbenchActionList.tsx    From next-basics with GNU General Public License v3.0 5 votes vote down vote up
export function WorkbenchActionList(
  props: WorkbenchActionListProps
): React.ReactElement {
  const { menu, appId } = props;
  const history = getHistory();
  const [activeIndex, setActiveIndex] = useState<number>();
  const [location, setLocation] = useState<Location>(history.location);

  useEffect(() => {
    const unlisten: UnregisterCallback = history.listen((location) => {
      setLocation(location);
    });
    return unlisten;
  }, []);

  useEffect(() => {
    if (!currentAppId) currentAppId = appId;
    if (currentAppId !== appId) {
      historyMap.clear();
      currentAppId = appId;
    }
  }, [appId]);

  useEffect(() => {
    const { pathname, search } = location;

    const { selectedKeys } = initMenuItemAndMatchCurrentPathKeys(
      menu?.menuItems ?? [],
      pathname,
      search,
      ""
    );
    setActiveIndex(Number(selectedKeys[0]));
  }, [menu, location]);

  const handleLinkClick = (item: SidebarMenuSimpleItem): void => {
    if (item.href) return;
    historyMap.set(activeIndex, `${location.pathname}${location.search}`);
  };

  return (
    <div className={styles.workBenchActionList}>
      {menu?.menuItems
        ?.map((item, index) => {
          if (item.type === "default") {
            let url = item.to;
            if (activeIndex !== index && historyMap.has(index)) {
              url = historyMap.get(index);
            }
            return (
              <WorkbenchAction
                key={index}
                icon={item.icon}
                tooltip={item.text}
                to={url as string}
                href={item.href}
                target={item.target}
                active={activeIndex === index}
                linkClick={() => handleLinkClick(item)}
              />
            );
          }
        })
        .filter(Boolean)}
    </div>
  );
}
Example #25
Source File: filterManager.ts    From ke with MIT License 5 votes vote down vote up
static setFilters(location: Location, filters: Filter[], history: History): void {
    const query = new URLSearchParams(location.search)

    FilterManager.setQueryFilters(query, filters)

    history.replace({ ...history.location, search: query.toString() })
  }
Example #26
Source File: Router.spec.ts    From next-core with GNU General Public License v3.0 4 votes vote down vote up
describe("Router", () => {
  let router: Router;
  const kernel = {
    mountPoints: {
      main: document.createElement("div"),
      bg: document.createElement("div"),
      portal: document.createElement("div"),
    },
    bootstrapData: {
      storyboards: [],
    },
    unsetBars: jest.fn(),
    menuBar: {
      element: document.createElement("div"),
    },
    appBar: {
      element: document.createElement("div"),
    },
    getFeatureFlags: mockFeature,
    toggleBars: jest.fn(),
    firstRendered: jest.fn(),
    toggleLegacyIframe: jest.fn(),
    updateWorkspaceStack: jest.fn(),
    getPreviousWorkspace: jest.fn(),
    getRecentApps: jest.fn(),
    loadDepsOfStoryboard: jest.fn(),
    registerCustomTemplatesInStoryboard: jest.fn(),
    fulfilStoryboard: jest.fn(),
    loadMicroAppApiOrchestrationAsync: jest.fn(),
    prefetchDepsOfStoryboard: jest.fn(),
    loadDynamicBricks: jest.fn(),
    layoutBootstrap: jest.fn(),
    presetBricks: {
      pageNotFound: "basic-bricks.page-not-found",
      pageError: "basic-bricks.page-error",
    },
    currentLayout: "console",
  } as unknown as Kernel;

  beforeEach(() => {
    router = new Router(kernel);
    apiAnalyzer.create({ api: "fake-api" });
  });

  afterEach(() => {
    historyListeners = [];
    __setMatchedStoryboard(undefined);
    __setMountRoutesResults(undefined);
    jest.clearAllMocks();
  });

  it("should render matched storyboard", async () => {
    const analyticsData = {
      prop1: "value",
    };
    __setMatchedStoryboard({
      routes: [],
      app: {
        id: "hello",
      },
    });
    __setMountRoutesResults(
      {
        route: {
          alias: "route alias",
        },
        main: [
          {
            type: "p",
          },
        ],
        menuBar: {
          title: "menu",
        },
        appBar: {
          title: "app",
        },
        analyticsData,
      },
      null
    );
    expect(router.getState()).toBe("initial");
    await router.bootstrap();
    expect(router.getState()).toBe("mounted");
    expect(spyOnHistory.listen).toBeCalled();
    const dispatchedEvent = spyOnDispatchEvent.mock.calls[0][0] as CustomEvent;
    expect(dispatchedEvent.type).toBe("app.change");
    expect(spyOnMountTree.mock.calls[0][0]).toEqual([{ type: "p" }]);
    expect(spyOnMountStaticNode.mock.calls[0][0]).toBe(kernel.menuBar.element);
    expect(spyOnMountStaticNode.mock.calls[0][1]).toEqual({
      title: "menu",
      subMenu: null,
    });
    expect(spyOnMountStaticNode.mock.calls[1][0]).toBe(kernel.appBar.element);
    expect(spyOnMountStaticNode.mock.calls[1][1]).toEqual({ title: "app" });
    expect(kernel.toggleBars).not.toBeCalled();
    expect(kernel.firstRendered).toBeCalled();
    expect(kernel.loadDepsOfStoryboard).toBeCalled();
    expect(kernel.registerCustomTemplatesInStoryboard).toBeCalled();
    expect(kernel.fulfilStoryboard).toBeCalled();
    expect(kernel.loadMicroAppApiOrchestrationAsync).toBeCalled();
    expect(kernel.prefetchDepsOfStoryboard).toBeCalled();
    expect(preCheckPermissions).toBeCalled();
    expect(spyOnMediaEventTargetAddEventListener).toBeCalled();
    expect(mockUserAnalyticsEvent).toBeCalledWith("page_view", {
      micro_app_id: "hello",
      route_alias: "route alias",
      ...analyticsData,
    });
  });

  it("should redirect to login page if not logged in.", async () => {
    __setMatchedStoryboard({
      app: {
        id: "hello",
      },
      dependsAll: true,
      routes: [],
    });
    __setMountRoutesResults(
      null,
      new HttpResponseError(new Response("", { status: 401 }), {
        code: 100003,
      })
    );
    await router.bootstrap();
    expect(spyOnHistory.replace.mock.calls[0]).toEqual([
      "/auth/login",
      {
        from: {
          pathname: "/yo",
        },
      },
    ]);
    expect(mockConsoleError).toBeCalled();
  });

  it("should show page error.", async () => {
    __setMatchedStoryboard({
      app: {
        id: "hello",
      },
      routes: [],
    });
    __setMountRoutesResults(null, new Error("oops"));
    await router.bootstrap();
    expect(spyOnHistory.replace).not.toBeCalled();
    expect(kernel.layoutBootstrap).toBeCalledWith("console");
    expect(kernel.loadDynamicBricks).toBeCalledWith([
      "basic-bricks.page-error",
    ]);
    expect(spyOnMountTree).toBeCalledTimes(1);
    expect(spyOnMountTree.mock.calls[0][0]).toMatchObject([
      {
        type: "basic-bricks.page-error",
        properties: {
          error: "Error: oops",
          code: null,
        },
      },
    ]);

    __setMountRoutesResults(
      null,
      new HttpResponseError(
        new Response("", { statusText: "oops", status: 403 })
      )
    );
    await router.bootstrap();
    expect(spyOnMountTree).toBeCalledTimes(2);
    expect(spyOnMountTree.mock.calls[1][0]).toMatchObject([
      {
        type: "basic-bricks.page-error",
        properties: {
          error: "HttpResponseError: oops",
          code: 403,
        },
      },
    ]);
  });

  it("should redirect to login page if redirect exists.", async () => {
    __setMatchedStoryboard({
      app: {
        id: "hello",
      },
      dependsAll: true,
      routes: [],
    });
    __setMountRoutesResults(
      {
        flags: {
          unauthenticated: true,
          redirect: {
            path: "/auth/login",
            state: {
              from: "/private",
            },
          },
        },
      },
      null
    );
    await router.bootstrap();
    expect(spyOnHistory.replace.mock.calls[0]).toEqual([
      "/auth/login",
      {
        from: {
          pathname: "/yo",
        },
      },
    ]);
  });

  it("should render matched storyboard with dependsAll and redirect", async () => {
    __setMatchedStoryboard({
      app: {
        id: "hello",
      },
      dependsAll: true,
      routes: [],
    });
    __setMountRoutesResults(
      {
        flags: {
          redirect: {
            path: "/auth/login",
            state: {
              from: "/private",
            },
          },
        },
      },
      null
    );
    await router.bootstrap();
    expect(spyOnHistory.replace.mock.calls[0]).toEqual([
      "/auth/login",
      {
        from: "/private",
      },
    ]);
    expect(spyOnMountStaticNode).not.toBeCalled();
    expect(spyOnMountTree).not.toBeCalled();
  });

  it("should render matched storyboard with bars hidden and empty main", async () => {
    __setMatchedStoryboard({
      app: {
        id: "hello",
      },
      routes: [],
    });
    __setMountRoutesResults(
      {
        flags: {
          barsHidden: true,
        },
        main: [],
      },
      null
    );
    await router.bootstrap();
    expect(kernel.loadDynamicBricks).toBeCalledWith([
      "basic-bricks.page-not-found",
    ]);
    expect(kernel.toggleBars).toBeCalledWith(false);
    expect(spyOnMountStaticNode).not.toBeCalled();
    expect(spyOnMountTree).toBeCalledTimes(1);
    expect(spyOnMountTree.mock.calls[0][0]).toMatchObject([
      {
        type: "basic-bricks.page-not-found",
        properties: {
          url: "/oops",
        },
      },
    ]);
  });

  it("should handle when page not found", async () => {
    await router.bootstrap();
    expect(kernel.loadDynamicBricks).toBeCalledWith([
      "basic-bricks.page-not-found",
    ]);
    expect(spyOnMountTree).toBeCalledTimes(1);
    expect(spyOnMountTree.mock.calls[0][0]).toMatchObject([
      {
        type: "basic-bricks.page-not-found",
        properties: {
          url: "/oops",
        },
      },
    ]);
  });

  it("should redirect to login when page not found and not logged in", async () => {
    spyOnIsLoggedIn.mockReturnValueOnce(false);
    await router.bootstrap();
    expect(kernel.loadMicroAppApiOrchestrationAsync).not.toBeCalled();
    expect(spyOnMountTree).toBeCalledTimes(0);
    expect(spyOnHistory.replace).toBeCalledWith("/auth/login", {
      from: {
        pathname: "/yo",
      },
    });
  });

  it("should ignore rendering if notify is false", async () => {
    await router.bootstrap();
    jest.clearAllMocks();
    mockHistoryPush({
      pathname: "/first",
    });
    await (global as any).flushPromises();
    expect(spyOnMountTree).toBeCalledTimes(1);
    mockHistoryPush({
      pathname: "/second",
      state: {
        notify: false,
      },
    });
    await (global as any).flushPromises();
    expect(spyOnMountTree).toBeCalledTimes(1);
  });

  it("should ignore rendering if location not changed except hash, state and key", async () => {
    await router.bootstrap();
    jest.clearAllMocks();
    mockHistoryPush({
      pathname: "/first",
      search: "?ok=1",
      key: "123",
      state: {
        from: "earth",
      },
    });
    await (global as any).flushPromises();
    expect(spyOnMountTree).toBeCalledTimes(1);
    mockHistoryPush({
      pathname: "/first",
      search: "?ok=1",
      hash: "#good",
    });
    await (global as any).flushPromises();
    expect(spyOnMountTree).toBeCalledTimes(1);
  });

  it("should ignore rendering if in situation of goBack after pushAnchor", async () => {
    await router.bootstrap();
    jest.clearAllMocks();
    mockHistoryPush({
      pathname: "/first",
      search: "?ok=1",
      key: "123",
      hash: "#yes",
      state: {
        notify: false,
      },
    });
    await (global as any).flushPromises();
    expect(spyOnMountTree).toBeCalledTimes(0);
    mockHistoryPop({
      pathname: "/first",
      search: "?ok=1",
      hash: null,
      key: "456",
    });
    await (global as any).flushPromises();
    expect(spyOnMountTree).toBeCalledTimes(0);
  });

  it("should render in queue", async () => {
    await router.bootstrap();
    jest.clearAllMocks();
    mockHistoryPush({
      pathname: "/first",
    });
    // `/second` should be ignored and replaced by `/third`.
    mockHistoryPush({
      pathname: "/second",
    });
    mockHistoryPush({
      pathname: "/third",
    });
    await (global as any).flushPromises();
    expect(spyOnMountTree).toBeCalledTimes(2);
  });

  it("should notify when location changed", async () => {
    const mockImage = jest.spyOn(window, "Image");
    mockFeature.mockReturnValue({ "log-location-change": true });
    router = new Router(kernel);
    await router.bootstrap();
    jest.clearAllMocks();
    mockHistoryPush({
      pathname: "/first",
    });
    expect(mockImage).toBeCalled();
  });

  it("should block", async () => {
    router = new Router(kernel);
    jest.clearAllMocks();

    const fireBeforeunload = (): CustomEvent => {
      const beforeunloadEvent = new CustomEvent("beforeunload");
      jest.spyOn(beforeunloadEvent, "preventDefault");
      window.dispatchEvent(beforeunloadEvent);
      return beforeunloadEvent;
    };

    const eventTriggeringNothing = fireBeforeunload();
    expect(eventTriggeringNothing.preventDefault).not.toBeCalled();

    jest.clearAllMocks();
    spyOnHistory.setBlockMessage("Are you sure to leave?");
    const eventBlocking = fireBeforeunload();
    expect(eventBlocking.preventDefault).toBeCalled();
    expect(spyOnHistory.unblock).not.toBeCalled();

    spyOnHistory.unblock();
    jest.clearAllMocks();
    const messageUnblocked = blockFn(
      {
        pathname: "/home",
      } as Location,
      "PUSH"
    );
    expect(messageUnblocked).toBe(undefined);

    jest.clearAllMocks();
    spyOnHistory.setBlockMessage("Are you sure to leave?");
    const messageBlocked = blockFn(
      {
        pathname: "/home",
      } as Location,
      "PUSH"
    );
    expect(messageBlocked).toBe("Are you sure to leave?");
  });
});
Example #27
Source File: index.tsx    From erda-ui with GNU Affero General Public License v3.0 4 votes vote down vote up
ApiDesign = () => {
  const [
    {
      contentKey,
      dataTypeFormData,
      filterKey,
      apiResourceList,
      apiDataTypeList,

      quotePathMap,
      treeModalVisible,
      apiModalVisible,
      curTreeNodeData,
      curApiName,
      curDataType,
      newTreeNode,
      popVisible,
      apiDetail,
    },
    updater,
    update,
  ] = useUpdate({
    contentKey: 'SUMMARY',
    dataTypeFormData: {},
    filterKey: '',
    apiResourceList: [] as string[],
    apiDataTypeList: [] as string[],

    quotePathMap: {} as Obj,
    treeModalVisible: false,
    apiModalVisible: false,
    curTreeNodeData: {},
    curApiName: '',

    curDataType: '',
    newTreeNode: {} as API_SETTING.IFileTree,
    popVisible: false,
    apiDetail: {},
  });

  const { inode: inodeQuery, pinode: pinodeQuery } = routeInfoStore.useStore((s) => s.query);

  React.useEffect(() => {
    const [key] = contentKey.split('&DICE&');
    if (key === 'RESOURCE') {
      curApiName && updater.contentKey(`${key}&DICE&${curApiName}`);
    } else {
      curDataType && updater.contentKey(`${key}&DICE&${curDataType}`);
    }
  }, [curApiName, contentKey, updater, curDataType]);
  const { isExternalRepo, repoConfig } = appStore.useStore((s) => s.detail);

  const [openApiDoc, apiWs, apiLockState, isDocChanged, wsQuery, formErrorNum, isApiReadOnly, lockUser, docValidData] =
    apiDesignStore.useStore((s) => [
      s.openApiDoc,
      s.apiWs,
      s.apiLockState,
      s.isDocChanged,
      s.wsQuery,
      s.formErrorNum,
      s.isApiReadOnly,
      s.lockUser,
      s.docValidData,
    ]);

  const {
    updateOpenApiDoc,
    createTreeNode,
    commitSaveApi,
    getApiDetail,
    publishApi,
    updateFormErrorNum,
    resetDocValidData,
  } = apiDesignStore;

  const [getApiDocDetailLoading, commitSaveApiLoading, getTreeListLoading] = useLoading(apiDesignStore, [
    'getApiDetail',
    'commitSaveApi',
    'getTreeList',
  ]);

  useMount(() => {
    window.addEventListener('beforeunload', beforeunload);
  });

  useUnmount(() => {
    updateOpenApiDoc({});
    apiWs && apiWs.close();
    window.removeEventListener('beforeunload', beforeunload);
  });

  const changeRef = React.useRef(null as any);

  React.useEffect(() => {
    changeRef.current = isDocChanged;
  }, [isDocChanged]);

  const beforeunload = React.useCallback((e) => {
    const msg = `${i18n.t('dop:not saved yet, confirm to leave')}?`;
    if (changeRef.current) {
      // eslint-disable-next-line no-param-reassign
      (e || window.event).returnValue = msg;
    }

    return msg;
  }, []);

  const apiResourceMap = React.useMemo(() => {
    const tempMap = openApiDoc?.paths || {};
    const fullKeys = keys(tempMap);
    let tempList = [];
    if (filterKey) {
      tempList = filter(keys(tempMap), (name) => name.indexOf(filterKey) > -1);
    } else {
      tempList = fullKeys;
    }
    updater.apiResourceList(tempList);
    return tempMap;
  }, [filterKey, openApiDoc, updater]);

  const apiDataTypeMap = React.useMemo(() => {
    const tempMap = openApiDoc?.components?.schemas || {};
    const fullKeys = keys(tempMap);
    let tempList = [];
    if (filterKey) {
      tempList = filter(fullKeys, (name) => name.indexOf(filterKey) > -1);
    } else {
      tempList = fullKeys;
    }
    updater.apiDataTypeList(tempList);
    return tempMap;
  }, [filterKey, openApiDoc, updater]);

  const onCreateDoc = (values: { name: string; pinode: string }) => {
    createTreeNode(values).then((res) => {
      updater.newTreeNode(res);
    });
    updater.treeModalVisible(false);
  };

  const onContentChange = React.useCallback(
    (contentName: string) => {
      const nextHandle = () => {
        updateFormErrorNum(0);
        const [, name] = contentName.split('&DICE&');
        updater.contentKey(contentName);
        if (contentName.startsWith('RESOURCE') && name) {
          updater.curApiName(name);
          const tempApiDetail = get(openApiDoc, ['paths', name]) || {};
          updater.apiDetail(tempApiDetail);
        }
        if (contentName.startsWith('DATATYPE')) {
          const _fromData = apiDataTypeMap[name] || { type: 'string', example: 'Example', 'x-dice-name': name };
          updater.dataTypeFormData({ ..._fromData, name });
          updater.curDataType(name);
        }
      };

      if (formErrorNum > 0) {
        confirm({
          title: i18n.t('dop:Are you sure to leave, with the error message not saved?'),
          onOk() {
            nextHandle();
          },
        });
      } else {
        nextHandle();
      }
    },
    [apiDataTypeMap, formErrorNum, openApiDoc, updateFormErrorNum, updater],
  );

  const dataTypeNameMap = React.useMemo(() => {
    return keys(get(openApiDoc, ['components', 'schemas']));
  }, [openApiDoc]);

  const apiNameMap = React.useMemo(() => {
    return keys(openApiDoc.paths || {});
  }, [openApiDoc]);

  const onAddHandle = (addKey: IListKey) => {
    let newData = {};
    let newName = `/api/new${apiResourceList.length}`;
    while (apiNameMap.includes(newName)) {
      newName += '1';
    }
    let dataPath = ['paths', newName];

    if (addKey === 'DATATYPE') {
      newName = `NewDataType${apiDataTypeList.length}`;
      newData = { type: 'string', example: 'Example', 'x-dice-name': newName };
      dataPath = ['components', 'schemas', newName];
    }

    const tempDocDetail = produce(openApiDoc, (draft) => set(draft, dataPath, newData));
    updateOpenApiDoc(tempDocDetail);

    onContentChange(`${addKey}&DICE&${newName}`);
  };

  const onDeleteHandle = (itemKey: string) => {
    const [key, name] = itemKey.split('&DICE&');

    if (key === 'DATATYPE') {
      const newQuoteMap = getQuoteMap(openApiDoc);
      if (newQuoteMap[name]?.length) {
        message.warning(i18n.t('dop:this type is referenced and cannot be deleted'));
        return;
      }
    } else if (key === 'RESOURCE') {
      const paths = keys(openApiDoc.paths);
      if (paths.length === 1) {
        message.warning(i18n.t('dop:at least one API needs to be kept'));
        return;
      }
    }
    const dataPath = key === 'RESOURCE' ? ['paths', name] : ['components', 'schemas', name];
    const tempDocDetail = produce(openApiDoc, (draft) => {
      unset(draft, dataPath);
    });
    updateOpenApiDoc(tempDocDetail);
    onContentChange('SUMMARY');
  };

  // 左侧列表头部渲染
  const renderPanelHead = (titleKey: IListKey) => (
    <div className="list-panel-head inline-flex justify-between items-center">
      <span className="font-bold">{LIST_TITLE_MAP[titleKey]}</span>
      {!apiLockState && (
        <ErdaIcon
          type="plus"
          className="mr-0 cursor-pointer"
          size="16px"
          onClick={(e) => {
            e.stopPropagation();
            onAddHandle(titleKey);
          }}
        />
      )}
    </div>
  );

  // 左侧列表渲染
  const renderListItem = (listKey: IListKey, name: string) => {
    const apiData = apiResourceMap[name] || {};
    const key = `${listKey}&DICE&${name}`;
    return (
      <LazyRender key={name} minHeight={listKey === 'RESOURCE' ? '58px' : '37px'}>
        <div
          className={`list-title ${contentKey === key ? 'list-title-active' : ''}`}
          onClick={() => onContentChange(key)}
        >
          <div className="flex justify-between items-center">
            <Ellipsis title={name}>
              <div className="list-title-name w-full nowrap mr-1">{name}</div>
            </Ellipsis>
            <Popconfirm
              title={`${i18n.t('common:confirm to delete')}?`}
              onConfirm={(e: any) => {
                e.stopPropagation();
                onDeleteHandle(key);
              }}
              onCancel={(e: any) => e.stopPropagation()}
            >
              {!apiLockState && (
                <CustomIcon
                  type="shanchu"
                  className="list-title-btn cursor-pointer"
                  onClick={(e) => e?.stopPropagation()}
                />
              )}
            </Popconfirm>
          </div>
          {listKey === 'RESOURCE' && (
            <div className="method-list">
              {map(API_METHODS, (methodKey: API_SETTING.ApiMethod) => {
                const methodIconClass = !isEmpty(apiData[methodKey]) ? `method-icon-${methodKey}` : '';
                return (
                  <Tooltip title={methodKey} key={methodKey}>
                    <div className={`method-icon mr-2 ${methodIconClass}`} />
                  </Tooltip>
                );
              })}
            </div>
          )}
        </div>
      </LazyRender>
    );
  };

  // 获取所有引用的pathMap
  const getQuoteMap = React.useCallback(
    (data: Obj) => {
      const getQuotePath = (innerData: Obj, prefixPath: Array<number | string>, pathMap: Obj) => {
        const refTypePath = get(innerData, [QUOTE_PREFIX, 0, '$ref']) || innerData[QUOTE_PREFIX_NO_EXTENDED];
        if (refTypePath) {
          const _type = refTypePath.split('/').slice(-1)[0];
          // eslint-disable-next-line no-param-reassign
          !pathMap[_type] && (pathMap[_type] = []);
          if (!pathMap[_type].includes(prefixPath)) {
            pathMap[_type].push(prefixPath);
          }
        }
        if (innerData?.properties) {
          forEach(keys(innerData.properties), (item) => {
            getQuotePath(innerData.properties[item], [...prefixPath, 'properties', item], pathMap);
          });
        }
        if (innerData?.items) {
          getQuotePath(innerData.items, [...prefixPath, 'items'], pathMap);
        }
      };

      const tempMap = {};
      const pathMap = data.paths;
      forEach(keys(pathMap), (path) => {
        const pathData = pathMap[path];
        forEach(keys(pathData), (method) => {
          const methodData = pathData[method];
          const _path = ['paths', path, method];

          forEach(API_MEDIA_TYPE, (mediaType) => {
            // responses
            const responsePath = ['responses', '200', 'content', mediaType, 'schema'];
            const responseData = get(methodData, responsePath) || {};
            getQuotePath(responseData, [..._path, ...responsePath], tempMap);

            // requestBody;
            const requestBodyPath = ['requestBody', 'content', mediaType, 'schema'];
            const requestBody = get(methodData, requestBodyPath) || {};
            getQuotePath(requestBody, [..._path, ...requestBodyPath], tempMap);
          });

          // parameters
          const parametersData = methodData.parameters || [];
          forEach(parametersData, (pData, index) => {
            getQuotePath(pData, [..._path, 'parameters', index], tempMap);
          });
        });
      });

      // datatype中的引用
      const dataTypeData = data?.components?.schemas || {};
      forEach(keys(dataTypeData), (dataTypeName) => {
        getQuotePath(dataTypeData[dataTypeName], ['components', 'schemas', dataTypeName], tempMap);
      });
      updater.quotePathMap(tempMap);
      return tempMap;
    },
    [updater],
  );

  const onQuotePathMapChange = React.useCallback(
    (pathMap: Obj) => {
      updater.quotePathMap(pathMap);
    },
    [updater],
  );

  const onApiNameChange = React.useCallback(
    (name: string) => {
      updater.curApiName(name);
    },
    [updater],
  );

  const renderContent = (key: string) => {
    if (key.startsWith('RESOURCE')) {
      return (
        <ApiResource
          onQuoteChange={onQuotePathMapChange}
          onApiNameChange={onApiNameChange}
          quotePathMap={quotePathMap}
          apiName={curApiName}
          apiDetail={apiDetail}
        />
      );
    } else if (key.startsWith('DATATYPE')) {
      return (
        <DataTypeConfig
          quotePathMap={quotePathMap}
          dataTypeNameMap={dataTypeNameMap}
          formData={dataTypeFormData}
          key={dataTypeFormData?.name}
          dataType={curDataType}
          onQuoteNameChange={onQuotePathMapChange}
          onDataTypeNameChange={(name) => updater.curDataType(name)}
          isEditMode={!apiLockState}
        />
      );
    } else {
      return <ApiSummary />;
    }
  };

  const isDocLocked = React.useMemo(() => {
    return wsQuery?.sessionID && apiLockState;
  }, [apiLockState, wsQuery]);

  const LockTipVisible = React.useMemo(() => isApiReadOnly || isDocLocked, [isApiReadOnly, isDocLocked]);

  const docLockTip = React.useMemo(() => {
    if (isApiReadOnly) {
      return i18n.t('dop:protect branch, not editable');
    } else if (isDocLocked) {
      return lockUser + API_LOCK_WARNING;
    } else {
      return '';
    }
  }, [isApiReadOnly, isDocLocked, lockUser]);

  const errorData = React.useMemo(() => {
    return {
      branchName: curTreeNodeData.branchName,
      docName: `${curTreeNodeData.apiDocName}.yaml`,
      msg: docValidData.msg,
    };
  }, [curTreeNodeData, docValidData]);

  const onEditDocHandle = () => {
    if (!apiWs) {
      initApiWs({ inode: inodeQuery, pinode: pinodeQuery });
    } else if (isDocLocked) {
      message.warning(lockUser + API_LOCK_WARNING);
    }
  };

  const onPublishApi = React.useCallback(
    (values: any) => {
      publishApi(values).then(() => {
        apiWs && apiWs.close();
        getApiDetail(inodeQuery as string).then((data: any) => {
          getQuoteMap(data.openApiDoc);
          updater.curTreeNodeData({
            ...curTreeNodeData,
            asset: data.asset,
          });
        });
      });
    },
    [apiWs, curTreeNodeData, getApiDetail, getQuoteMap, inodeQuery, publishApi, updater],
  );

  const onSelectDoc = React.useCallback(
    (nodeData, reset) => {
      if (reset) {
        updateOpenApiDoc({});
        resetDocValidData();
      }
      onContentChange('Summary');
      update({
        contentKey: 'SUMMARY',
        curTreeNodeData: nodeData,
        newTreeNode: {} as API_SETTING.IFileTree,
        filterKey: '',
      });
    },
    [onContentChange, resetDocValidData, update, updateOpenApiDoc],
  );

  const onToggleTreeVisible = React.useCallback(
    (val: boolean) => {
      updater.popVisible(val);
    },
    [updater],
  );

  const onConfirmPublish = React.useCallback(() => {
    if (isDocChanged) {
      confirm({
        title: i18n.t('dop:The current document has not been saved. Publish the saved document?'),
        onOk() {
          updater.apiModalVisible(true);
        },
      });
    } else {
      updater.apiModalVisible(true);
    }
  }, [isDocChanged, updater]);

  const showErrorDocTip = React.useMemo(() => {
    return !docValidData.valid && !isDocChanged && !isEmpty(openApiDoc);
  }, [docValidData.valid, isDocChanged, openApiDoc]);

  return isExternalRepo === undefined ? (
    <EmptyHolder relative />
  ) : (
    <div className="api-design">
      <TopButtonGroup>
        <Button type="primary" onClick={() => updater.treeModalVisible(true)}>
          {i18n.t('dop:New Document')}
        </Button>
      </TopButtonGroup>
      <div className="api-design-wrap">
        <div className="search-wrap mb-4 flex items-center justify-start">
          <ApiDocTree
            treeNodeData={curTreeNodeData}
            newTreeNode={newTreeNode}
            getQuoteMap={getQuoteMap}
            onSelectDoc={onSelectDoc}
            popVisible={popVisible}
            onVisibleChange={onToggleTreeVisible}
          />
          {LockTipVisible && (
            <span className="ml-4">
              <CustomIcon type="lock" />
              {docLockTip}
            </span>
          )}
          {showErrorDocTip && <ErrorPopover {...errorData} />}
          {inodeQuery && !isEmpty(curTreeNodeData) && (
            <div className="flex items-center flex-wrap justify-end flex-1">
              {!apiWs || isDocLocked ? (
                <WithAuth pass={!isApiReadOnly && docValidData.valid}>
                  <Button type="ghost" onClick={onEditDocHandle}>
                    {i18n.t('Edit')}
                  </Button>
                </WithAuth>
              ) : (
                <Button type="ghost" disabled={formErrorNum > 0} onClick={() => commitSaveApi()}>
                  {i18n.t('Save')}
                </Button>
              )}
              <WithAuth pass={inodeQuery && docValidData.valid}>
                <Button type="primary" className="ml-2" onClick={onConfirmPublish}>
                  {i18n.t('publisher:Release')}
                </Button>
              </WithAuth>
            </div>
          )}
        </div>
        <Spin spinning={getApiDocDetailLoading || commitSaveApiLoading || getTreeListLoading}>
          {isEmpty(openApiDoc) ? (
            <ErrorEmptyHolder {...errorData} isLoading={getTreeListLoading} />
          ) : (
            <div className="api-design-content">
              <div className="api-design-content-list flex flex-col justify-start">
                <Input
                  placeholder={i18n.t('Search by keyword')}
                  className="mx-2 my-3 api-filter-input"
                  prefix={<ErdaIcon type="search1" size="14" className="mr-0.5 mt-0.5" />}
                  onInput={(e: React.ChangeEvent<HTMLInputElement>) => updater.filterKey(e.target.value)}
                />

                <div
                  className={`list-title py-3 border-bottom font-bold ${
                    contentKey === 'SUMMARY' ? 'list-title-active' : ''
                  }`}
                  onClick={() => onContentChange('SUMMARY')}
                >
                  {i18n.t('dop:API overview')}
                </div>
                <div className="panel-list">
                  <Collapse
                    accordion
                    bordered={false}
                    defaultActiveKey={['RESOURCE']}
                    className="api-overview-collapse"
                  >
                    <Panel header={renderPanelHead('RESOURCE')} key="RESOURCE">
                      {!isEmpty(apiResourceList) ? (
                        map(apiResourceList, (name) => renderListItem('RESOURCE', name))
                      ) : (
                        <EmptyHolder relative />
                      )}
                    </Panel>
                    <Panel header={renderPanelHead('DATATYPE')} key="DATATYPE">
                      {!isEmpty(apiDataTypeList) ? (
                        map(apiDataTypeList, (name) => renderListItem('DATATYPE', name))
                      ) : (
                        <EmptyHolder relative />
                      )}
                    </Panel>
                  </Collapse>
                </div>
              </div>
              <div className="api-design-content-detail px-4 py-3">{renderContent(contentKey)}</div>
            </div>
          )}
        </Spin>
        <ApiDocAddModal
          visible={treeModalVisible}
          onClose={() => updater.treeModalVisible(false)}
          onSubmit={onCreateDoc}
        />
        <ApiPublishModal
          visible={apiModalVisible}
          treeNodeData={curTreeNodeData as API_SETTING.ITreeNodeData}
          onSubmit={onPublishApi}
          onClose={() => updater.apiModalVisible(false)}
        />
      </div>
      <Prompt
        when={isDocChanged}
        message={(location: Location) => {
          if (location.pathname.endsWith('apiDesign')) {
            return false;
          }
          return `${i18n.t('dop:not saved yet, confirm to leave')}?`;
        }}
      />
    </div>
  );
}
Example #28
Source File: Editor.tsx    From legend-studio with Apache License 2.0 4 votes vote down vote up
EditorInner = observer(() => {
  const params = useParams<EditorPathParams | GroupEditorPathParams>();
  const projectId = params.projectId;
  const workspaceType = (params as { groupWorkspaceId: string | undefined })
    .groupWorkspaceId
    ? WorkspaceType.GROUP
    : WorkspaceType.USER;
  const workspaceId =
    workspaceType === WorkspaceType.GROUP
      ? (params as GroupEditorPathParams).groupWorkspaceId
      : (params as EditorPathParams).workspaceId;
  const editorStore = useEditorStore();
  const applicationStore = useApplicationStore();

  // Extensions
  const extraEditorExtensionComponents = editorStore.pluginManager
    .getStudioPlugins()
    .flatMap(
      (plugin) =>
        plugin.getExtraEditorExtensionComponentRendererConfigurations?.() ?? [],
    )
    .filter(isNonNullable)
    .map((config) => (
      <Fragment key={config.key}>{config.renderer(editorStore)}</Fragment>
    ));

  // Resize
  const { ref, width, height } = useResizeDetector<HTMLDivElement>();
  // These create snapping effect on panel resizing
  const resizeSideBar = (handleProps: ResizablePanelHandlerProps): void =>
    editorStore.sideBarDisplayState.setSize(
      (handleProps.domElement as HTMLDivElement).getBoundingClientRect().width,
    );

  const resizeAuxPanel = (handleProps: ResizablePanelHandlerProps): void =>
    editorStore.auxPanelDisplayState.setSize(
      (handleProps.domElement as HTMLDivElement).getBoundingClientRect().height,
    );

  useEffect(() => {
    if (ref.current) {
      editorStore.auxPanelDisplayState.setMaxSize(ref.current.offsetHeight);
    }
  }, [editorStore, ref, height, width]);

  // Hotkeys
  const [hotkeyMapping, hotkeyHandlers] = buildReactHotkeysConfiguration(
    editorStore.hotkeys,
  );

  // Cleanup the editor
  useEffect(() => (): void => editorStore.cleanUp(), [editorStore]);

  // Initialize the app
  useEffect(() => {
    flowResult(
      editorStore.initialize(projectId, workspaceId, workspaceType),
    ).catch(applicationStore.alertUnhandledError);
  }, [editorStore, applicationStore, projectId, workspaceId, workspaceType]);

  // Browser Navigation Blocking (reload, close tab, go to another URL)
  // NOTE: there is no way to customize the alert message for now since Chrome removed support for it
  // See https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#Browser_compatibility
  // There is also no way to customize this modal style-wise so we would only be able to do so for route navigation blocking
  // See https://medium.com/@jozollo/blocking-navigation-with-react-router-v4-a3f2e359d096
  useEffect(() => {
    const onUnload = (event: BeforeUnloadEvent): void => {
      /**
       * NOTE: one subtle trick here. Since we have to use `useEffect` to set the event listener for `beforeunload` event,
       * we have to be be careful, if we extract `editorStore.ignoreNavigationBlock` out to a variable such as `ignoreNavigationBlock`
       * and then make this `useEffect` be called in response to that, there is a chance that if we set `editorStore.ignoreNavigationBlock`
       * to `true` and then go on to call systematic refresh (i.e. `window.location.reload()`) immediately, the event listener on window will
       * become stale and still show the blocking popup.
       *
       * This is almost guaranteed to happen as `useEffect` occurs after rendering, and thus will defnitely be called after the immediate
       * `window.location.reload()`. As such, the best way is instead of expecting `useEffect` to watch out for the change in `ignoreNavigationBlock`
       * we will access the value of `ignoreNavigationBlock` in the body of the `onUnload` function to make it more dynamic. This ensures the
       * event listener will never go stale
       */
      const showAlert =
        editorStore.isInConflictResolutionMode ||
        editorStore.hasUnpushedChanges;
      if (!editorStore.ignoreNavigationBlocking && showAlert) {
        event.returnValue = '';
      }
    };
    window.removeEventListener('beforeunload', onUnload);
    window.addEventListener('beforeunload', onUnload);
    return (): void => window.removeEventListener('beforeunload', onUnload);
  }, [editorStore]);

  // Route Navigation Blocking
  // See https://medium.com/@michaelchan_13570/using-react-router-v4-prompt-with-custom-modal-component-ca839f5faf39
  const [blockedLocation, setBlockedLocation] = useState<
    Location | undefined
  >();
  const retryBlockedLocation = useCallback(
    (allowedNavigation: boolean): void => {
      if (allowedNavigation && blockedLocation) {
        applicationStore.navigator.goTo(blockedLocation.pathname);
      }
    },
    [blockedLocation, applicationStore],
  );
  // NOTE: we have to use `useStateWithCallback` here because we want to guarantee that we call `history.push(blockedLocation.pathname)`
  // after confirmedAllowNavigation is flipped, otherwise we would end up in the `false` case of handleBlockedNavigation again!
  // Another way to go about this is to use `setTimeout(() => history.push(...), 0)` but it can potentially be more error-prone
  // See https://www.robinwieruch.de/react-usestate-callback
  const [confirmedAllowNavigation, setConfirmedAllowNavigation] =
    useStateWithCallback<boolean>(false, retryBlockedLocation);
  const onNavigationChangeIndicator = Boolean(
    editorStore.changeDetectionState.workspaceLocalLatestRevisionState.changes
      .length,
  );
  const handleRouteNavigationBlocking = (nextLocation: Location): boolean => {
    // NOTE: as long as we're in conflict resolution, we want this block to be present
    const showAlert =
      editorStore.isInConflictResolutionMode || editorStore.hasUnpushedChanges;
    if (
      !editorStore.ignoreNavigationBlocking &&
      !confirmedAllowNavigation &&
      showAlert
    ) {
      editorStore.setActionAlertInfo({
        message: editorStore.isInConflictResolutionMode
          ? 'You have not accepted the conflict resolution, the current resolution will be discarded. Leave anyway?'
          : 'You have unpushed changes. Leave anyway?',
        type: ActionAlertType.CAUTION,
        onEnter: (): void => editorStore.setBlockGlobalHotkeys(true),
        onClose: (): void => editorStore.setBlockGlobalHotkeys(false),
        actions: [
          {
            label: 'Leave this page',
            type: ActionAlertActionType.PROCEED_WITH_CAUTION,
            handler: (): void => setConfirmedAllowNavigation(true),
          },
          {
            label: 'Stay on this page',
            type: ActionAlertActionType.PROCEED,
            default: true,
            handler: (): void => setBlockedLocation(undefined),
          },
        ],
      });
      setBlockedLocation(nextLocation);
      return false;
    }
    // Reset the confirm flag and the blocked location here
    setBlockedLocation(undefined);
    setConfirmedAllowNavigation(false);
    return true;
  };
  const editable =
    editorStore.graphManagerState.graphBuildState.hasCompleted &&
    editorStore.isInitialized;
  const isResolvingConflicts =
    editorStore.isInConflictResolutionMode &&
    !editorStore.conflictResolutionState.hasResolvedAllConflicts;
  const promptComponent = (
    <Prompt
      when={onNavigationChangeIndicator}
      message={handleRouteNavigationBlocking}
    />
  );

  useApplicationNavigationContext(
    LEGEND_STUDIO_APPLICATION_NAVIGATION_CONTEXT.EDITOR,
  );

  return (
    <div className="app__page">
      <div className="editor">
        {promptComponent}
        <GlobalHotKeys
          keyMap={hotkeyMapping}
          handlers={hotkeyHandlers}
          allowChanges={true}
        >
          <div className="editor__body">
            <ActivityBar />
            <Backdrop className="backdrop" open={editorStore.backdrop} />
            <div ref={ref} className="editor__content-container">
              <div className="editor__content">
                <ResizablePanelGroup orientation="vertical">
                  <ResizablePanel
                    {...getControlledResizablePanelProps(
                      editorStore.sideBarDisplayState.size === 0,
                      {
                        onStopResize: resizeSideBar,
                      },
                    )}
                    size={editorStore.sideBarDisplayState.size}
                    direction={1}
                  >
                    <SideBar />
                  </ResizablePanel>
                  <ResizablePanelSplitter />
                  <ResizablePanel minSize={300}>
                    <ResizablePanelGroup orientation="horizontal">
                      <ResizablePanel
                        {...getControlledResizablePanelProps(
                          editorStore.auxPanelDisplayState.isMaximized,
                        )}
                      >
                        {(isResolvingConflicts || editable) &&
                          editorStore.isInFormMode && <EditPanel />}
                        {editable && editorStore.isInGrammarTextMode && (
                          <GrammarTextEditor />
                        )}
                        {!editable && <EditPanelSplashScreen />}
                      </ResizablePanel>
                      <ResizablePanelSplitter>
                        <ResizablePanelSplitterLine
                          color={
                            editorStore.auxPanelDisplayState.isMaximized
                              ? 'transparent'
                              : 'var(--color-dark-grey-250)'
                          }
                        />
                      </ResizablePanelSplitter>
                      <ResizablePanel
                        {...getControlledResizablePanelProps(
                          editorStore.auxPanelDisplayState.size === 0,
                          {
                            onStopResize: resizeAuxPanel,
                          },
                        )}
                        flex={0}
                        direction={-1}
                        size={editorStore.auxPanelDisplayState.size}
                      >
                        <AuxiliaryPanel />
                      </ResizablePanel>
                    </ResizablePanelGroup>
                  </ResizablePanel>
                </ResizablePanelGroup>
              </div>
            </div>
          </div>
          {extraEditorExtensionComponents}
          <StatusBar actionsDisabled={!editable} />
          {editable && <ProjectSearchCommand />}
          {editorStore.localChangesState.workspaceSyncState
            .workspaceSyncConflictResolutionState.showModal && (
            <WorkspaceSyncConflictResolver />
          )}
        </GlobalHotKeys>
      </div>
    </div>
  );
})
Example #29
Source File: LocationContext.spec.ts    From next-core with GNU General Public License v3.0 4 votes vote down vote up
describe("LocationContext", () => {
  const kernel: Kernel = {
    mountPoints: {
      main: {},
      bg: document.createElement("div"),
      portal: {},
    },
    bootstrapData: {
      storyboards: [],
      brickPackages: [
        {
          filePath: "all.js",
        },
      ],
    },
    nextApp: {
      id: "hello",
      name: "Hello",
      homepage: "/hello",
    },
    unsetBars: jest.fn(),
    menuBar: {
      element: {},
    },
    appBar: {
      element: {
        breadcrumb: [],
      },
    },
    toggleBars: jest.fn(),
    getFeatureFlags: jest.fn().mockReturnValue({
      testing: true,
    }),
    loadDynamicBricksInBrickConf: jest.fn(),
  } as any;

  const getInitialMountResult = (): MountRoutesResult => ({
    main: [],
    menuInBg: [],
    portal: [],
    menuBar: {},
    appBar: {
      breadcrumb: kernel.appBar.element.breadcrumb,
    },
    flags: {
      redirect: undefined,
    },
  });

  afterEach(() => {
    spyOnIsLoggedIn.mockReset();
    jest.clearAllMocks();
  });

  describe("matchStoryboard", () => {
    const storyboards: RuntimeStoryboard[] = [
      {
        app: {
          id: "hello",
          name: "Hello",
          homepage: "/hello",
        },
        routes: [
          {
            path: "/hello",
            public: true,
            bricks: [],
          },
        ],
      },
      {
        app: {
          id: "hello-world",
          name: "Hello World",
          homepage: "/hello/world",
        },
        routes: [
          {
            path: "/hello/world",
            public: true,
            bricks: [],
          },
        ],
      },
      {
        app: {
          id: "oops",
          name: "OOPS",
          // Legacy storyboard with no homepage.
          homepage: null,
        },
      },
    ];

    it("should handle match missed", () => {
      const context = new LocationContext(kernel, {
        pathname: "/",
        search: "",
        hash: "",
        state: {},
      });
      const storyboard = context.matchStoryboard(storyboards);
      expect(storyboard).toBe(undefined);
    });

    it("should handle match hit", () => {
      const context = new LocationContext(kernel, {
        pathname: "/hello",
        search: "",
        hash: "",
        state: {},
      });
      const storyboard = context.matchStoryboard(storyboards);
      expect(storyboard.app.id).toBe("hello");
    });

    it("should handle match hit sub-path", () => {
      const context = new LocationContext(kernel, {
        pathname: "/hello/everyone",
        search: "",
        hash: "",
        state: {},
      });
      const storyboard = context.matchStoryboard(storyboards);
      expect(storyboard.app.id).toBe("hello");
    });

    it("should handle match more precisely", () => {
      const context = new LocationContext(kernel, {
        pathname: "/hello/world",
        search: "",
        hash: "",
        state: {},
      });
      const storyboard = context.matchStoryboard(storyboards);
      expect(storyboard.app.id).toBe("hello-world");
    });

    it("should handle standalone micro-apps", () => {
      window.STANDALONE_MICRO_APPS = true;
      const context = new LocationContext(kernel, {
        pathname: "/hello/world",
        search: "",
        hash: "",
        state: {},
      });
      const storyboard = context.matchStoryboard(storyboards.slice(0, 1));
      expect(storyboard.app.id).toBe("hello");
      window.STANDALONE_MICRO_APPS = false;
    });
  });

  describe("mountRoutes", () => {
    it("should mount nothing if match missed", async () => {
      const context = new LocationContext(kernel, {
        pathname: "/hello",
        search: "",
        hash: "",
        state: {},
      });
      const result = await context.mountRoutes(
        [],
        undefined,
        getInitialMountResult()
      );
      expect(result).toMatchObject({
        main: [],
        menuBar: {},
        appBar: {},
        flags: {
          redirect: undefined,
        },
      });
    });

    it("should redirect if not logged in", async () => {
      const location = {
        pathname: "/",
        search: "",
        hash: "",
        state: {},
      };
      const context = new LocationContext(kernel, location);
      spyOnIsLoggedIn.mockReturnValueOnce(false);
      const result = await context.mountRoutes(
        [
          {
            path: "/",
            bricks: [],
          },
        ],
        undefined,
        getInitialMountResult()
      );
      expect(result).toMatchObject({
        main: [],
        menuBar: {},
        appBar: {},
        flags: {
          unauthenticated: true,
          redirect: undefined,
        },
      });
    });

    it("should redirect if match redirected", async () => {
      const context = new LocationContext(kernel, {
        pathname: "/hello",
        search: "",
        hash: "",
        state: {},
      });
      spyOnIsLoggedIn.mockReturnValue(true);
      const result = await context.mountRoutes(
        [
          {
            path: "/hello",
            redirect: "/oops",
          },
        ],
        undefined,
        getInitialMountResult()
      );
      expect(result).toMatchObject({
        main: [],
        menuBar: {},
        appBar: {},
        flags: {
          redirect: {
            path: "/oops",
          },
        },
      });
    });

    it("should validate permissions when permissionsPreCheck has been defined", async () => {
      const context = new LocationContext(kernel, {
        pathname: "/",
        search: "",
        hash: "",
        state: {},
      });
      spyOnIsLoggedIn.mockReturnValue(true);
      spyOnGetCurrentContext.mockReturnValueOnce(context.getCurrentContext());
      await context.mountRoutes(
        [
          {
            path: "/",
            permissionsPreCheck: ["<% CTX.instanceUpdateAction %>"],
            bricks: [],
            context: [
              {
                name: "instanceUpdateAction",
                value: "cmdb_HOST_instance_update",
              },
            ],
          },
        ],
        undefined,
        getInitialMountResult()
      );
      expect(validatePermissions).toHaveBeenCalledWith([
        "cmdb_HOST_instance_update",
      ]);
    });

    it("should ignore validating permissions if not logged in", async () => {
      const context = new LocationContext(kernel, {
        pathname: "/",
        search: "",
        hash: "",
        state: {},
      });
      spyOnIsLoggedIn.mockReturnValue(false);
      await context.mountRoutes(
        [
          {
            path: "/",
            permissionsPreCheck: ["<% CTX.instanceUpdateAction %>"],
            bricks: [],
          },
        ],
        undefined,
        getInitialMountResult()
      );
      expect(validatePermissions).not.toBeCalled();
    });

    it("should mount if match hit", async () => {
      const context = new LocationContext(kernel, {
        pathname: "/",
        search: "",
        hash: "",
        state: {},
      });
      spyOnIsLoggedIn.mockReturnValue(true);

      spyOnGetCurrentContext.mockReturnValue(context.getCurrentContext());

      spyOnGetResolver.mockReturnValueOnce({
        async resolveOne(
          type: any,
          resolveConf: ResolveConf,
          conf: Record<string, any>
        ): Promise<void> {
          Object.assign(conf, resolveConf.transform);
        },
      } as any);

      jest
        .spyOn(context.resolver, "resolve")
        .mockImplementation((brickConf) => {
          if (brickConf.lifeCycle?.useResolves?.length > 0) {
            return Promise.reject(new ResolveRequestError("Invalid request"));
          }
          return Promise.resolve();
        });

      const result = await context.mountRoutes(
        [
          {
            path: "/",
            providers: [
              "provider-a",
              {
                brick: "provider-b",
                properties: {
                  args: ["good"],
                },
              },
            ],
            context: [
              {
                name: "myFreeContext",
                value: "bad",
                if: "<% FLAGS['should-not-enabled'] %>",
              },
              {
                name: "myFreeContext",
                value: "good",
                if: "<% !FLAGS['should-not-enabled'] %>",
              },
              {
                name: "myAsyncContext",
                resolve: {
                  provider: "provider-c",
                  transform: {
                    value: "even better",
                  },
                },
              },
              {
                name: "myAsyncContext",
                resolve: {
                  provider: "provider-d",
                  transform: {
                    value: "turns worse",
                  },
                  if: "<% CTX.myFreeContext === 'bad' %>",
                },
              },
              {
                name: "myFallbackToValueContext",
                resolve: {
                  provider: "provider-c",
                  transform: {
                    value: "provider return value",
                  },
                  if: "<% CTX.myFreeContext === 'bad' %>",
                },
                value: "default value",
              },
            ],
            type: "routes",
            routes: [
              {
                alias: "route alias",
                path: "/",
                analyticsData: {
                  prop1: "<% CTX.myAsyncContext %>",
                },
                bricks: [
                  {
                    if: "${FLAGS.testing}",
                    brick: "div",
                    iid: "5df55280297f6",
                    properties: {
                      title:
                        "<% `${CTX.myFreeContext} ${CTX.myAsyncContext} ${CTX.myFallbackToValueContext}` %>",
                      useBrick: {
                        brick: "useBrick-a",
                        properties: {
                          useBrick: {
                            brick: "useBrick-in-useBrick-b",
                            slots: {
                              content: {
                                bricks: [
                                  {
                                    brick: "slots-useBrick-in-useBrick-c",
                                  },
                                ],
                                type: "bricks",
                              },
                            },
                          },
                        },
                        slots: {
                          content: {
                            bricks: [
                              {
                                brick: "slots-in-useBrick-d",
                                slots: "error",
                              },
                              {
                                brick: "slots-in-useBrick-g",
                                slots: null,
                              },
                              {
                                brick: "slots-in-useBrick-h",
                                slots: [
                                  {
                                    brick: "slots-in-brick-i",
                                  },
                                ],
                              },
                            ],
                            type: "bricks",
                          },
                          error1: {
                            bricks: {
                              brick: "error-brick",
                            },
                          },
                          error2: {
                            bricks: [
                              {
                                useBrick: "1",
                                slots: {
                                  content: "slots-in-brick-j",
                                },
                              },
                              {
                                useBrick: 2,
                              },
                              {
                                useBrick: null,
                              },
                              {
                                useBrick: undefined,
                              },
                            ],
                          },
                        },
                      },
                      columns: [
                        {
                          title: "title-1",
                          label: "label-1",
                          useBrick: true,
                        },
                        {
                          title: "title-2",
                          label: "label-2",
                          useBrick: {
                            brick: "deep-useBrick-e",
                            properties: {
                              useBrick: {
                                brick: "deep-useBrick-in-useBrick-f",
                              },
                            },
                          },
                        },
                      ],
                    },
                    context: [
                      {
                        name: "myNewPropContext",
                        property: "title",
                      },
                      {
                        name: "myFreeContextDefinedOnBrick",
                        value: "some value",
                        onChange: {
                          action: "console.log",
                        },
                      },
                    ],
                    exports: {
                      title: "CTX.myPropContext",
                    },
                    events: {
                      click: {
                        action: "history.push",
                      },
                    },
                    lifeCycle: {
                      onBeforePageLoad: {
                        action: "theme.setDarkTheme",
                      },
                      onPageLoad: {
                        action: "console.log",
                      },
                      onBeforePageLeave: {
                        action: "console.log",
                      },
                      onPageLeave: {
                        action: "console.log",
                      },
                      onAnchorLoad: {
                        action: "console.log",
                      },
                      onAnchorUnload: {
                        action: "console.log",
                      },
                      onMediaChange: {
                        action: "console.log",
                      },
                      onMessageClose: {
                        action: "console.log",
                      },
                      onMessage: {
                        channel: "pipelineChannel",
                        handlers: {
                          action: "console.log",
                        },
                      },
                      onScrollIntoView: {
                        threshold: 0.5,
                        handlers: [
                          {
                            action: "console.log",
                          },
                        ],
                      },
                    },
                    slots: {
                      menu: {
                        type: "bricks",
                        bricks: [
                          {
                            brick: "p",
                          },
                        ],
                      },
                      content: {
                        type: "routes",
                        routes: [
                          {
                            path: "/",
                            bricks: [],
                            menu: {
                              sidebarMenu: {
                                title: "menu title",
                                menuItems: [],
                                menuId: "test-menu-id",
                              },
                              pageTitle: "page title",
                              breadcrumb: {
                                items: [
                                  {
                                    text: "first breadcrumb",
                                  },
                                ],
                              },
                            },
                          },
                        ],
                      },
                      extendA: {
                        type: "routes",
                        routes: [
                          {
                            path: "/",
                            bricks: [],
                            menu: {
                              type: "brick",
                              brick: "a",
                            },
                          },
                        ],
                      },
                      extendB: {
                        type: "routes",
                        routes: [
                          {
                            path: "/",
                            bricks: [],
                            menu: {
                              type: "brick",
                              brick: "b",
                              events: {},
                              lifeCycle: {
                                onPageLoad: {
                                  action: "console.info",
                                },
                                onBeforePageLeave: {
                                  action: "console.info",
                                },
                                onPageLeave: {
                                  action: "console.info",
                                },
                                onAnchorLoad: {
                                  action: "console.info",
                                  args: ["${EVENT.detail.anchor}"],
                                },
                                onAnchorUnload: {
                                  action: "console.info",
                                },
                                onMessageClose: {
                                  action: "console.info",
                                },
                              },
                            },
                          },
                        ],
                      },
                      extendC: {
                        type: "routes",
                        routes: [
                          {
                            path: "/",
                            bricks: [],
                            menu: {
                              breadcrumb: {
                                overwrite: true,
                                items: [
                                  {
                                    text: "second breadcrumb",
                                  },
                                ],
                                noCurrentApp: true,
                              },
                            },
                          },
                        ],
                      },
                      extendD: {
                        type: "routes",
                        routes: [
                          {
                            path: "/",
                            bricks: [],
                            menu: false,
                          },
                        ],
                      },
                      extendE: {
                        type: "routes",
                        routes: [
                          {
                            path: "/",
                            bricks: [],
                            menu: {},
                          },
                        ],
                      },
                      extendF: {
                        type: "invalid",
                        routes: [],
                      },
                      extendG: {
                        type: "routes",
                        routes: [
                          {
                            path: "/",
                            bricks: [
                              {
                                brick: "modal-a",
                                portal: true,
                                properties: {
                                  args: ["a"],
                                },
                                slots: {
                                  content: {
                                    type: "bricks",
                                    bricks: [
                                      {
                                        brick: "h2",
                                        properties: {
                                          textContent: "modal heading",
                                        },
                                      },
                                      {
                                        brick: "p",
                                        portal: true,
                                        properties: {
                                          textContent: "portal in portal",
                                        },
                                      },
                                    ],
                                  },
                                },
                              },
                            ],
                            menu: {},
                          },
                        ],
                      },
                    },
                  },
                  {
                    if: "${FLAGS.testing|not}",
                    brick: "div",
                  },
                  {
                    brick: "brick-will-fail",
                    lifeCycle: {
                      useResolves: [
                        {
                          useProvider: "provider-will-reject",
                        },
                      ],
                    },
                  },
                  {
                    template: "template-will-fail",
                    lifeCycle: {
                      useResolves: [
                        {
                          useProvider: "provider-will-reject",
                        },
                      ],
                    },
                  },
                ],
              },
            ],
          },
        ] as any,
        undefined,
        getInitialMountResult()
      );
      expect(result).toMatchObject({
        route: expect.objectContaining({
          path: "/",
          alias: "route alias",
        }),
        menuBar: {
          menu: {
            title: "menu title",
            menuItems: [],
            menuId: "test-menu-id",
          },
        },
        appBar: {
          pageTitle: "page title",
          breadcrumb: [
            {
              text: "second breadcrumb",
            },
          ],
          noCurrentApp: true,
        },
        flags: {
          barsHidden: true,
          redirect: undefined,
        },
        analyticsData: {
          prop1: "even better",
        },
      });
      expect(result.main).toMatchObject([
        {
          type: "div",
          properties: {
            title: "good even better default value",
          },
          events: {
            click: {
              action: "history.push",
            },
          },
          children: [
            {
              type: "p",
              slotId: "menu",
            },
          ],
        },
        {
          type: "basic-bricks.brick-error",
          properties: {
            brickName: "brick-will-fail",
            errorType: "ResolveRequestError",
            errorMessage: "Invalid request",
            isLegacyTemplate: false,
          },
        },
        {
          type: "basic-bricks.brick-error",
          properties: {
            brickName: "template-will-fail",
            errorType: "ResolveRequestError",
            errorMessage: "Invalid request",
            isLegacyTemplate: true,
          },
        },
      ]);
      const { storyboardContext } = result.main[0].context;
      expect(storyboardContext.get("myNewPropContext")).toMatchObject({
        type: "brick-property",
        prop: "title",
      });
      expect(
        storyboardContext.get("myFreeContextDefinedOnBrick")
      ).toMatchObject({
        type: "free-variable",
        value: "some value",
        eventTarget: expect.anything(),
      });
      expect(result.main[0].properties).toEqual({
        title: "good even better default value",
        useBrick: {
          brick: "useBrick-a",
          properties: {
            useBrick: {
              brick: "useBrick-in-useBrick-b",
              slots: {
                content: {
                  bricks: [
                    {
                      brick: "slots-useBrick-in-useBrick-c",
                    },
                  ],
                  type: "bricks",
                },
              },
            },
          },
          slots: {
            content: {
              bricks: [
                {
                  brick: "slots-in-useBrick-d",
                  slots: "error",
                },
                {
                  brick: "slots-in-useBrick-g",
                  slots: null,
                },
                {
                  brick: "slots-in-useBrick-h",
                  slots: [
                    {
                      brick: "slots-in-brick-i",
                    },
                  ],
                },
              ],
              type: "bricks",
            },
            error1: {
              bricks: {
                brick: "error-brick",
              },
            },
            error2: {
              bricks: [
                {
                  useBrick: "1",
                  slots: {
                    content: "slots-in-brick-j",
                  },
                },
                {
                  useBrick: 2,
                },
                {
                  useBrick: null,
                },
                {
                  useBrick: undefined,
                },
              ],
            },
          },
        },
        columns: [
          {
            title: "title-1",
            label: "label-1",
            useBrick: true,
          },
          {
            title: "title-2",
            label: "label-2",
            useBrick: {
              brick: "deep-useBrick-e",
              properties: {
                useBrick: {
                  brick: "deep-useBrick-in-useBrick-f",
                },
              },
            },
          },
        ],
      });
      expect(kernel.mountPoints.bg.children.length).toBe(2);
      expect(kernel.mountPoints.bg.children[0].tagName).toBe("PROVIDER-A");
      expect(kernel.mountPoints.bg.children[1].tagName).toBe("PROVIDER-B");
      expect((kernel.mountPoints.bg.children[1] as any).args).toEqual(["good"]);

      expect(result.portal).toMatchObject([
        {
          type: "modal-a",
          properties: {
            args: ["a"],
          },
          children: [
            {
              type: "h2",
              properties: {
                textContent: "modal heading",
              },
              children: [],
              slotId: "content",
            },
          ],
          slotId: undefined,
        },
        {
          type: "p",
          properties: {
            textContent: "portal in portal",
          },
          children: [],
          slotId: undefined,
        },
      ]);

      context.handleBeforePageLoad();
      expect(applyTheme).toBeCalledWith("dark");
      context.handlePageLoad();
      context.handleAnchorLoad();
      (history.getHistory as jest.Mock).mockReturnValue({
        location: {
          hash: "#yes",
        },
      });
      context.handleAnchorLoad();
      context.handleScrollIntoView("5df55280297f6");
      context.handleBeforePageLeave({
        location: { pathname: "/home" } as Location,
        action: "POP",
      });
      context.handlePageLeave();
      const mediaChangeDetail = { breakpoint: MediaBreakpoint.xLarge };
      context.handleMediaChange(mediaChangeDetail);
      context.handleMessageClose(new CloseEvent("error"));
      context.handleMessage();
      context.handleBrickBindObserver();

      expect(spyOnDispatchEvent).toBeCalledWith(
        expect.objectContaining({ type: "page.load" })
      );

      // Assert `console.log()`.
      expect(consoleLog).toHaveBeenNthCalledWith(
        1,
        new CustomEvent("page.load")
      );
      expect(consoleLog).toHaveBeenNthCalledWith(
        2,
        new CustomEvent("anchor.unload")
      );
      expect(consoleLog).toHaveBeenNthCalledWith(
        3,
        new CustomEvent("anchor.load", {
          detail: {
            hash: "#yes",
            anchor: "yes",
          },
        })
      );
      expect(consoleLog).toHaveBeenNthCalledWith(
        4,
        new CustomEvent("page.beforeLeave", {
          detail: {
            location: { pathname: "/home" },
            action: "POP",
          },
        })
      );
      expect(consoleLog).toHaveBeenNthCalledWith(
        5,
        new CustomEvent("page.leave")
      );

      expect(consoleLog).toHaveBeenNthCalledWith(
        6,
        new CustomEvent("media.change", { detail: mediaChangeDetail })
      );

      expect(consoleLog).toHaveBeenNthCalledWith(
        7,
        new CustomEvent("message.close")
      );
      expect(consoleLog).toHaveBeenNthCalledWith(
        8,
        new CustomEvent("scroll.into.view")
      );

      // Assert `console.info()`.
      expect(consoleInfo).toHaveBeenNthCalledWith(
        1,
        new CustomEvent("page.load")
      );
      expect(consoleInfo).toHaveBeenNthCalledWith(
        2,
        new CustomEvent("anchor.unload")
      );
      expect(consoleInfo).toHaveBeenNthCalledWith(3, "yes");
      expect(consoleInfo).toHaveBeenNthCalledWith(
        4,
        new CustomEvent("page.beforeLeave", {
          detail: {
            location: { pathname: "/home" },
            action: "POP",
          },
        })
      );
      expect(consoleInfo).toHaveBeenNthCalledWith(
        5,
        new CustomEvent("page.leave")
      );
      expect(consoleInfo).toHaveBeenNthCalledWith(
        6,
        new CustomEvent("message.close")
      );
    });

    it("resolve menu should work", async () => {
      const context = new LocationContext(kernel, {
        pathname: "/",
        search: "",
        hash: "",
        state: {},
      });
      spyOnIsLoggedIn.mockReturnValue(true);
      jest
        .spyOn(context.resolver, "resolveOne")
        .mockImplementationOnce(
          (type: any, resolveConf: ResolveConf, conf: Record<string, any>) => {
            Object.assign(conf, resolveConf.transform);
            return Promise.resolve();
          }
        );
      const resolveConf = {
        provider: "provider-a",
        transform: {
          pageTitle: "A",
          sidebarMenu: {
            title: "title-a",
            menuItems: [
              {
                text: "item-1",
              },
            ],
          },
        },
      };

      const result = await context.mountRoutes(
        [
          {
            path: "/",
            providers: [
              "provider-a",
              {
                brick: "provider-b",
                properties: {
                  args: ["good"],
                },
              },
            ],
            menu: {
              type: "resolve",
              resolve: resolveConf,
            },
            bricks: [
              {
                brick: "menu",
                properties: {
                  menu: "<% APP.getMenu('menu-1') %>",
                },
                context: [
                  {
                    name: "menu",
                    value: "<% APP.getMenu('menu-2') %>",
                  },
                ],
              },
            ],
          },
        ],
        undefined,
        getInitialMountResult()
      );

      expect(result.menuBar).toMatchObject({
        menu: {
          title: "title-a",
          menuItems: [
            {
              text: "item-1",
            },
          ],
        },
      });
    });
  });

  it("preFetchMenu should work", async () => {
    const context = new LocationContext(kernel, {
      pathname: "/hello/a/b",
      search: "",
      hash: "",
      state: {},
    });
    spyOnIsLoggedIn.mockReturnValue(true);
    jest
      .spyOn(context.resolver, "resolveOne")
      .mockImplementationOnce(
        (type: any, resolveConf: ResolveConf, conf: Record<string, any>) => {
          Object.assign(conf, resolveConf.transform);
          return Promise.resolve();
        }
      );

    await context.mountRoutes(
      [
        {
          path: "/hello/a",
          context: [
            {
              name: "menu",
              value: "<% APP.getMenu('CTX-menu') %>",
            },
          ],
          exact: false,
          providers: [],
          routes: [
            {
              path: "/hello/a/b",
              context: [
                {
                  name: "menu",
                  value: "<% APP.getMenu('menu-1') %>",
                },
              ],
              bricks: [
                {
                  brick: "menu",
                  properties: {
                    menu: "<% APP.getMenu('menu-2') %>",
                  },
                },
                {
                  brick: "tpl-a",
                },
              ],
              type: "bricks",
              exact: true,
            },
          ],
          type: "routes",
        },
      ],
      undefined,
      getInitialMountResult()
    );

    expect(jestConstructMenu).toBeCalledTimes(4);
    expect(jestConstructMenu.mock.calls[0][0]).toStrictEqual(["CTX-menu"]);
    expect(jestConstructMenu.mock.calls[1][0]).toStrictEqual([
      "menu-1",
      "menu-2",
    ]);
    expect(jestConstructMenu.mock.calls[2][0]).toStrictEqual(["tpl-a-menu"]);
    expect(jestConstructMenu.mock.calls[3][0]).toStrictEqual([
      "tpl-b-menu-inner-tpl-a",
    ]);
  });

  it("getSubStoryboardByRoute should work", () => {
    const context = new LocationContext(kernel, {
      pathname: "/1/2",
      search: "",
      hash: "",
      state: {},
    });
    const storyboard = {
      app: {
        id: "a",
        homepage: "/1",
      },
      routes: [
        {
          path: "${APP.homepage}/2",
          bricks: null,
        },
        {
          path: "${APP.homepage}/3",
          bricks: null,
        },
      ],
    } as Partial<Storyboard>;
    spyOnIsLoggedIn.mockReturnValue(true);
    expect(context.getSubStoryboardByRoute(storyboard as Storyboard)).toEqual({
      app: {
        id: "a",
        homepage: "/1",
      },
      routes: [
        {
          path: "${APP.homepage}/2",
          bricks: null,
        },
      ],
    });
  });
});