Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- React component prop interfaces should be suffixed with `Props` (e.g., `UserProps`).
- Use `const` for constants and `let` for variables that may change.
- Use `async/await` for asynchronous code.
- NEVER USE `await import(...)` inside functions. Always use static imports at the top of the file. Depending on the use case, dynamic imports may be used at the top of the file, but they should never be used inside functions.
- Use TypeScript mapped and utility types where possible to avoid creating new types.
- In any exported function or class that is generated, insert a `@hidden` tag in the JSDoc comment to indicate that it is not intended for public use.
- This tag may be removed at a later time at the author's discretion. Do not re-insert this tag if it is not already present in any JSDoc comments.
Expand Down
36 changes: 36 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# MapGuide React Layout

This context describes the viewer initialization language used to turn a fetched layout document into a ready-to-run viewer state.

## Language

**Init document**:
A layout document consumed by initialization, represented as either ApplicationDefinition or WebLayout.
_Avoid_: appdef only, raw layout, init payload

**Document fetch stage**:
The stage that obtains an init document and session metadata before payload construction.
_Avoid_: init command, monolithic init

**Init payload stage**:
The stage that transforms an init document into the INIT_APP payload shape.
_Avoid_: document fetch, session bootstrap

**Session reuse**:
Initialization metadata indicating that an existing MapGuide session was reused instead of creating a new one.
_Avoid_: warm start, cached login

## Relationships

- A **Document fetch stage** produces one **Init document** and one **Session reuse** flag
- An **Init payload stage** consumes one **Init document** and emits one INIT_APP payload

## Example dialogue

> **Dev:** "Can we skip de-arrayification for newer servers without changing payload behavior?"
> **Domain expert:** "Yes, because the **Document fetch stage** may vary normalization, while the **Init payload stage** must always produce the same INIT_APP payload shape."

## Flagged ambiguities

- "init" was used to mean both fetching documents and building payloads — resolved: split into **Document fetch stage** and **Init payload stage**.
- "DefaultViewerInitCommand class" was used to describe the init payload stage implementation — resolved: the class holds no meaningful state (all three fields are implicit parameter threading); the `protected` extension surface has no subclasses; the class boundary causes a circular dependency between `init.ts` and `init-mapguide.ts`. The **Init payload stage** is implemented as free functions in `init-mapguide.ts` orchestrated directly from the `initAppFromDocument` thunk in `init.ts`, using the thunk closure for `dispatch` and `client`.
14 changes: 14 additions & 0 deletions docs/adr/0001-dissolve-default-viewer-init-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Dissolve DefaultViewerInitCommand into free functions

`DefaultViewerInitCommand` was a class that orchestrated the init payload stage — creating runtime maps, building WebLayout/AppDef payloads, registering projections, and restoring selections on session reuse. It held three instance fields (`client`, `options`, `dispatch`) and three `protected` override points (`isArbitraryCoordSys`, `establishInitialMapNameAndSession`, `setupMaps`).

We dissolved it because:

1. All three instance fields were implicit parameter threading — the class was created, used once, and discarded. No field was meaningful state that persisted across calls.
2. The `protected` extension surface was vestigial — no subclass exists anywhere in the codebase and `@override` was present on methods that were never overridden.
3. The class boundary caused a circular import (`init.ts` → `init-mapguide.ts` → `init.ts`) that is eliminated when the class is removed.
4. The sole caller of `buildInitPayloadFromDocument` (the bridge function between the class and its consumers) was `initAppFromDocument`, a Redux thunk. The thunk already has `dispatch` in its closure and constructs `client` immediately before the call, making the bridge function unnecessary.

**Considered and rejected:** retaining the class with a local `IInitPayloadContext` data-bag to group the three threading values. This would have made the threading explicit without dissolving the class, but it would still have left the circular dependency and the vestigial `protected` surface, while providing no benefit over plain free functions.

**Outcome:** `DefaultViewerInitCommand` and `buildInitPayloadFromDocument` are removed. The init payload stage is implemented as module-level functions in `init-mapguide.ts`. `initAppFromDocument` in `init.ts` calls them directly, with `dispatch` from the thunk closure and `client` as a local variable.
156 changes: 20 additions & 136 deletions src/actions/init-command.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,7 @@
import { IInitAsyncOptions, normalizeInitPayload } from './init';
import { ActiveMapTool } from '../api/common';
import type { ReduxDispatch, Dictionary, IMapSwipePair } from '../api/common';
import { IGenericSubjectMapLayer, IInitAppActionPayload, MapInfo } from './defs';
import { ToolbarConf, convertFlexLayoutUIItems, parseWidgetsInAppDef, prepareSubMenus } from '../api/registry/command-spec';
import type { IMapSwipePair } from '../api/common';
import { IGenericSubjectMapLayer } from './defs';
import { makeUnique } from '../utils/array';
import { ApplicationDefinition, MapConfiguration, MapSetGroup } from '../api/contracts/fusion';
import { warn, info } from '../utils/logger';
import { registerCommand } from '../api/registry/command';
import { tr, registerStringBundle, DEFAULT_LOCALE } from '../api/i18n';
import { WEBLAYOUT_CONTEXTMENU } from "../constants";
import { Client } from '../api/client';
import { ActionType } from '../constants/actions';
import { ensureParameters } from '../utils/url';
import { MgError } from '../api/error';
import { strStartsWith } from '../utils/string';
import { IClusterSettings } from '../api/ol-style-contracts';
Expand Down Expand Up @@ -215,133 +205,27 @@ export function isStateless(appDef: ApplicationDefinition) {
}
}

export interface IViewerInitCommand {
attachClient(client: Client): void;
runAsync(options: IInitAsyncOptions): Promise<IInitAppActionPayload>;
}

export abstract class ViewerInitCommand<TSubject> implements IViewerInitCommand {
constructor(protected readonly dispatch: ReduxDispatch) { }
public abstract attachClient(client: Client): void;
public abstract runAsync(options: IInitAsyncOptions): Promise<IInitAppActionPayload>;
protected abstract isArbitraryCoordSys(map: TSubject): boolean;
protected abstract establishInitialMapNameAndSession(mapsByName: Dictionary<TSubject>): [string, string];
protected abstract setupMaps(appDef: ApplicationDefinition, mapsByName: Dictionary<TSubject>, config: any, warnings: string[], locale: string, pendingMapDefs?: Dictionary<MapToLoad>): Dictionary<MapInfo>;
protected async initLocaleAsync(options: IInitAsyncOptions): Promise<void> {
//English strings are baked into this bundle. For non-en locales, we assume a strings/{locale}.json
//exists for us to fetch
const { locale } = options;
if (locale != DEFAULT_LOCALE) {
const r = await fetch(`strings/${locale}.json`);
if (r.ok) {
const res = await r.json();
registerStringBundle(locale, res);
// Dispatch the SET_LOCALE as it is safe to change UI strings at this point
this.dispatch({
type: ActionType.SET_LOCALE,
payload: locale
});
info(`Registered string bundle for locale: ${locale}`);
} else {
//TODO: Push warning to init error/warning reducer when we implement it
warn(`Failed to register string bundle for locale: ${locale}`);
}
}
}
protected getExtraProjectionsFromFlexLayout(appDef: ApplicationDefinition): string[] {
//The only widget we care about is the coordinate tracker
const epsgs: string[] = [];
for (const ws of appDef.WidgetSet) {
for (const w of ws.Widget) {
if (w.Type == "CoordinateTracker") {
const ps = w.Extension.Projection || [];
for (const p of ps) {
epsgs.push(p.split(':')[1]);
}
} else if (w.Type == "CursorPosition") {
const dp = w.Extension.DisplayProjection;
if (dp) {
epsgs.push(dp.split(':')[1]);
}
/**
* @hidden
* @since 0.15
*/
export function getExtraProjectionsFromFlexLayout(appDef: ApplicationDefinition): string[] {
// The only widgets we care about are coordinate-related widgets.
const epsgs: string[] = [];
for (const ws of appDef.WidgetSet) {
for (const w of ws.Widget) {
if (w.Type == "CoordinateTracker") {
const ps = w.Extension.Projection || [];
for (const p of ps) {
epsgs.push(p.split(':')[1]);
}
}
}
return makeUnique(epsgs);
}

protected async initFromAppDefCoreAsync(appDef: ApplicationDefinition, options: IInitAsyncOptions, mapsByName: Dictionary<TSubject | IGenericSubjectMapLayer>, warnings: string[], pendingMapDefs?: Dictionary<MapToLoad>): Promise<IInitAppActionPayload> {
const {
taskPane,
hasTaskBar,
hasStatus,
hasNavigator,
hasSelectionPanel,
hasLegend,
viewSize,
widgetsByKey,
isStateless,
initialTask
} = parseWidgetsInAppDef(appDef, registerCommand);
const { locale, featureTooltipsEnabled } = options;
const config: any = {};
config.isStateless = isStateless;
const tbConf: Dictionary<ToolbarConf> = {};

//Now build toolbar layouts
for (const widgetSet of appDef.WidgetSet) {
for (const cont of widgetSet.Container) {
let tbName = cont.Name;
tbConf[tbName] = { items: convertFlexLayoutUIItems(isStateless, cont.Item, widgetsByKey, locale) };
}
for (const w of widgetSet.Widget) {
if (w.Type == "CursorPosition") {
config.coordinateProjection = w.Extension.DisplayProjection;
config.coordinateDecimals = w.Extension.Precision;
config.coordinateDisplayFormat = w.Extension.Template;
} else if (w.Type == "CursorPosition") {
const dp = w.Extension.DisplayProjection;
if (dp) {
epsgs.push(dp.split(':')[1]);
}
}
}

const mapsDict: any = mapsByName; //HACK: TS generics doesn't want to play nice with us
const maps = this.setupMaps(appDef, mapsDict, config, warnings, locale, pendingMapDefs);
if (appDef.Title) {
document.title = appDef.Title || document.title;
}
const [firstMapName, firstSessionId] = this.establishInitialMapNameAndSession(mapsDict);
const [tb, bFoundContextMenu] = prepareSubMenus(tbConf);
if (!bFoundContextMenu) {
warnings.push(tr("INIT_WARNING_NO_CONTEXT_MENU", locale, { containerName: WEBLAYOUT_CONTEXTMENU }));
}
const settings: Record<string, string> = {};
if (Array.isArray(appDef.Extension?.ViewerSettings?.Setting)) {
for (const s of appDef.Extension.ViewerSettings.Setting) {
const [sn] = s["@name"];
const [sv] = s["@value"];
settings[sn] = sv;
}
}
return normalizeInitPayload({
appSettings: settings,
activeMapName: firstMapName,
initialUrl: ensureParameters(initialTask, firstMapName, firstSessionId, locale),
featureTooltipsEnabled: featureTooltipsEnabled,
locale: locale,
maps: maps,
config: config,
capabilities: {
hasTaskPane: (taskPane != null),
hasTaskBar: hasTaskBar,
hasStatusBar: hasStatus,
hasNavigator: hasNavigator,
hasSelectionPanel: hasSelectionPanel,
hasLegend: hasLegend,
hasToolbar: (Object.keys(tbConf).length > 0),
hasViewSize: (viewSize != null)
},
toolbars: tb,
warnings: warnings,
initialActiveTool: ActiveMapTool.Pan,
mapSwipePairs: parseSwipePairs(appDef)
}, options.layout);
}
return makeUnique(epsgs);
}
Loading
Loading