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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,21 @@ it no longer has.
colours it with `.workflow-node-group .group-initial-mark { color: … }`.
It takes 24 px of the title's room, and a frame is not made wider for its
title: a long title in a narrow frame runs past it sooner.
- **A view can be of the instance, not of the definition.** A producer marks
a view it offers on a node with `of: 'instance'` (`meta.views`:
`[{ id, label, of? }]`). By name, a file answers for the definition it
holds under that name; for a unit made per instance (by a factory, with
the instance's parameters) that is another one. A view so marked is asked
of the editor's root, by the path of instances that leads to the node
(`rootNetwork` and `instancePath`, with `view`), as a drill-down into a
nested unit already falls back to; its file is asked by name only when the
root has no answer, and the reader is then told that what is shown is the
definition's (a warning, with the root's reason). The mark stays on the
view's crumb, so the trail leads back to the same view; it is acted on
only for a trail that reaches the node through an instance at every step.
It is said of a view a node offers, and acted on by the model source that
asks a sidecar for its graph: a product whose graph comes from a CLI's
plan asks by name, as before, as does any view without the mark.
- **Lines of text in a node, read close in.** A producer puts lines of text
in a node with `meta.lines` (`[{ text, kind? }]`, or plain strings; six at
most, each cut at 48 characters): what a reader zooms in for and the
Expand Down Expand Up @@ -634,6 +649,13 @@ it no longer has.

### Fixed

- **A view no longer falls back to the structure on a refresh.** The host's
refresh (a keystroke or a save in any source file, a run) repeats the
file, the name and the trail of what is shown, and named no view: a
synthesis view, or any other, was replaced by the unit's structure under a
breadcrumb that still said the view. The view is now taken from the
trail's last crumb when the request names none and is for that crumb's
unit.
- **A wire into a closed bus of the unit's ports runs the way it does.** A
bundle of the unit's ports that mixes directions (a handshake's `data` and
`valid` in, its `ready` and `space` out) is drawn as an input, and every
Expand Down
7 changes: 6 additions & 1 deletion packages/diagram-client/src/editing-action-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ export namespace WorkflowOpenViewAction {
workflowName: string;
/** The instance the view is reached through, when through a node. */
workflowInstanceName?: string;
/** `'instance'`: the view is of that instance, not of the definition the file holds (`wf:views`' `of`). */
viewOf?: 'instance';
}

export function is(action: unknown): action is Action {
Expand Down Expand Up @@ -947,7 +949,10 @@ export class WorkflowOpenViewActionHandler implements IActionHandler {
return;
}
void this.actionDispatcher.dispatch(
this.navigationUi.requestView(action.uri, action.workflowName, action.workflowInstanceName, action.view, action.label) as never
this.navigationUi.requestView(
action.uri, action.workflowName, action.workflowInstanceName, action.view, action.label,
action.viewOf === 'instance' ? 'instance' : undefined
) as never
);
}
}
Expand Down
20 changes: 16 additions & 4 deletions packages/diagram-client/src/navigation-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ type NavigationCrumb = {
/** The crumb is a view of the workflow other than its structure. */
view?: string;
viewLabel?: string;
/** The view is of the instance the crumb was reached through, not of the definition its file holds. */
viewOf?: 'instance';
};

/** What a crumb says: the instance or workflow, then the view when it is one. */
Expand Down Expand Up @@ -727,11 +729,16 @@ export class WorkflowNavigationUi {
workflowName: string,
workflowInstanceName: string | undefined,
view: string,
viewLabel: string | undefined
viewLabel: string | undefined,
viewOf?: 'instance'
): RequestModelAction {
const currentSourceUri = this.editorContext.sourceUri;
// The crumb keeps what the view is of, so that coming back to it by
// the trail asks for the same thing the node did.
const trail = this.buildNavigationTrail(currentSourceUri, targetSourceUri, workflowName, workflowInstanceName)
.map((crumb, index, all) => (index === all.length - 1 ? { ...crumb, view, ...(viewLabel ? { viewLabel } : {}) } : crumb));
.map((crumb, index, all) => (index === all.length - 1
? { ...crumb, view, ...(viewLabel ? { viewLabel } : {}), ...(viewOf ? { viewOf } : {}) }
: crumb));
this.noteNavigate(targetSourceUri, workflowName, trail);
return this.requestModel(targetSourceUri, workflowName, undefined, trail);
}
Expand Down Expand Up @@ -1177,12 +1184,13 @@ export class WorkflowNavigationUi {
: '';
const view = typeof (item as any)?.view === 'string' ? (item as any).view.trim() : '';
const viewLabel = typeof (item as any)?.viewLabel === 'string' ? (item as any).viewLabel.trim() : '';
const viewOf = (item as any)?.viewOf === 'instance' ? { viewOf: 'instance' as const } : {};
if (sourceUri !== '' && workflowName !== '') {
out.push({
sourceUri,
workflowName,
...(workflowInstanceName !== '' ? { workflowInstanceName } : {}),
...(view !== '' ? { view, ...(viewLabel !== '' ? { viewLabel } : {}) } : {})
...(view !== '' ? { view, ...(viewLabel !== '' ? { viewLabel } : {}), ...viewOf } : {})
});
}
}
Expand Down Expand Up @@ -1214,7 +1222,11 @@ export class WorkflowNavigationUi {
? { workflowInstanceName: entry.workflowInstanceName.trim() }
: {}),
...(typeof entry.view === 'string' && entry.view.trim() !== ''
? { view: entry.view.trim(), ...(entry.viewLabel?.trim() ? { viewLabel: entry.viewLabel.trim() } : {}) }
? {
view: entry.view.trim(),
...(entry.viewLabel?.trim() ? { viewLabel: entry.viewLabel.trim() } : {}),
...(entry.viewOf === 'instance' ? { viewOf: 'instance' as const } : {})
}
: {})
};
const prev = normalized[normalized.length - 1];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ export function openLeaf(target: GModelElement): Action[] {
...(typeof first.label === 'string' ? { label: first.label } : {}),
uri,
workflowName: displayNameFromQualifiedName(referencedName.trim()),
...(typeof instanceName === 'string' && instanceName.trim() !== '' ? { workflowInstanceName: instanceName.trim() } : {})
...(typeof instanceName === 'string' && instanceName.trim() !== '' ? { workflowInstanceName: instanceName.trim() } : {}),
...(first.of === 'instance' ? { viewOf: 'instance' } : {})
} as Action];
}
if (clientBehavior().doubleClickOpensSource !== true) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,22 @@ describe('double-click on a node that is not a network instance', () => {
}]);
});

it('says so when the view is of the instance, and only then', () => {
behavior({});
const node = (views: unknown) => leafNode({
[WorkflowDiagramMetadata.REFERENCED_URI]: LEAF_URI,
[WorkflowDiagramMetadata.REFERENCED_ENTITY_NAME]: 'adder',
[WorkflowDiagramMetadata.VIEWS]: views,
'wf:entityInstanceName': 'u_add'
});
expect(doubleClick(node([{ id: 'inside', label: 'Inside', of: 'instance' }]))).toEqual([{
kind: 'dialogram.openView', view: 'inside', label: 'Inside', uri: LEAF_URI, workflowName: 'adder',
workflowInstanceName: 'u_add', viewOf: 'instance'
}]);
expect(doubleClick(node([{ id: 'inside', of: 'definition' }]))[0].viewOf).toBeUndefined();
expect(doubleClick(node([{ id: 'inside' }]))[0].viewOf).toBeUndefined();
});

it('opens a view without the source flag', () => {
behavior({});
const node = leafNode({
Expand Down
93 changes: 93 additions & 0 deletions packages/diagram-client/test/view-of-instance-trail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* A view of the instance it is reached through (`wf:views` entry with
* `of: 'instance'`), on the client's side of the trail.
*
* The view's crumb keeps the mark, and the trail the request carries has it,
* so the server asks for the view by the path of instances, now and whenever
* the trail leads back to the crumb.
*/
import 'reflect-metadata';
import { afterEach, describe, expect, it } from 'vitest';
import { WorkflowNavigationUi } from '../src/navigation-ui';
import { WorkflowOpenViewActionHandler } from '../src/editing-action-handlers';

const ROOT = 'file:///w/decoder.py';
const UNIT = 'file:///w/interpolation.py';

/** A navigation UI with no page behind it: what `requestView` needs and no more. */
function navigation(): any {
const ui: any = Object.create(WorkflowNavigationUi.prototype);
ui.editorContext = { sourceUri: ROOT, diagramType: 'workflow-diagram' };
ui.stacksBySourceUri = new Map();
return ui;
}

const trailOf = (action: any): any[] => JSON.parse(action.options['wf:navTrail']);

afterEach(() => {
delete (globalThis as any).__calDiagramContext;
});

describe('a view of the instance it is reached through', () => {
it('is marked on its crumb, which the request carries', () => {
(globalThis as any).__calDiagramContext = { workflowName: 'decoder' };
const action = navigation().requestView(UNIT, 'Interpolation', 'interpolation', 'actor', 'Actor', 'instance');

expect(action.options).toMatchObject({ sourceUri: UNIT, networkName: 'Interpolation', 'wf:view': 'actor' });
expect(trailOf(action)).toEqual([
{ sourceUri: ROOT, workflowName: 'decoder' },
{ sourceUri: UNIT, workflowName: 'Interpolation', workflowInstanceName: 'interpolation', view: 'actor', viewLabel: 'Actor', viewOf: 'instance' }
]);
});

it('ends a trail that leads to it through every instance on the way: the editor’s own stack', () => {
// The editor's document is the root; a nested unit was drilled into before.
const NESTED = 'file:///w/motion.py';
const ui = navigation();
ui.stacksBySourceUri.set(ROOT, [
{ sourceUri: ROOT, workflowName: 'decoder' },
{ sourceUri: NESTED, workflowName: 'motion', workflowInstanceName: 'motion_Y' }
]);
const action = ui.requestView(UNIT, 'Interpolation', 'interpolation', 'actor', 'Actor', 'instance');

expect(trailOf(action)).toEqual([
{ sourceUri: ROOT, workflowName: 'decoder' },
{ sourceUri: NESTED, workflowName: 'motion', workflowInstanceName: 'motion_Y' },
{ sourceUri: UNIT, workflowName: 'Interpolation', workflowInstanceName: 'interpolation', view: 'actor', viewLabel: 'Actor', viewOf: 'instance' }
]);
// And the stack the next request starts from is that trail, the mark with it.
expect(ui.stacksBySourceUri.get(UNIT).at(-1)).toMatchObject({ view: 'actor', viewOf: 'instance' });
});

it('is not marked on the crumb of a view of the definition', () => {
(globalThis as any).__calDiagramContext = { workflowName: 'decoder' };
const action = navigation().requestView(UNIT, 'Interpolation', 'interpolation', 'synthesis', undefined);

expect(trailOf(action)[1]).toEqual({ sourceUri: UNIT, workflowName: 'Interpolation', workflowInstanceName: 'interpolation', view: 'synthesis' });
});

it('is still marked when the trail comes back from the server', () => {
const ui = navigation();
const sent = [
{ sourceUri: ROOT, workflowName: 'decoder' },
{ sourceUri: UNIT, workflowName: 'Interpolation', workflowInstanceName: 'interpolation', view: 'actor', viewOf: 'instance' },
// A mark the platform does not know, and one on a crumb that is no view, are dropped.
{ sourceUri: 'file:///w/other.py', workflowName: 'Other', workflowInstanceName: 'o', view: 'x', viewOf: 'something' },
{ sourceUri: 'file:///w/plain.py', workflowName: 'Plain', workflowInstanceName: 'p', viewOf: 'instance' }
];
expect(ui.parseTrail(JSON.stringify(sent)).map((crumb: any) => crumb.viewOf)).toEqual([undefined, 'instance', undefined, undefined]);
});

it('is passed on by the handler of the node’s action', () => {
const asked: unknown[][] = [];
const handler: any = new WorkflowOpenViewActionHandler();
handler.actionDispatcher = { dispatch: async () => undefined };
handler.navigationUi = { requestView: (...args: unknown[]) => { asked.push(args); return {}; } };
const base = { kind: 'dialogram.openView', view: 'actor', label: 'Actor', uri: UNIT, workflowName: 'Interpolation', workflowInstanceName: 'interpolation' };
handler.handle({ ...base, viewOf: 'instance' });
handler.handle(base);
handler.handle({ ...base, viewOf: 'anything else' });

expect(asked.map(args => args[5])).toEqual(['instance', undefined, undefined]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,8 @@ export class WorkflowContextMenuItemProvider extends ContextMenuItemProvider {
label: view.label,
uri: referencedUri,
workflowName: displayNameFromQualifiedName(referencedName.trim()),
...(typeof instanceName === 'string' && instanceName.trim() !== '' ? { workflowInstanceName: instanceName.trim() } : {})
...(typeof instanceName === 'string' && instanceName.trim() !== '' ? { workflowInstanceName: instanceName.trim() } : {}),
...(view.of === 'instance' ? { viewOf: 'instance' } : {})
} as any]
}));
}
Expand Down
66 changes: 63 additions & 3 deletions packages/diagram-server/src/server/graph-load-request-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,35 @@ export interface NavigationTrailEntry {
/** The crumb is a view of the workflow other than its structure. */
view?: string;
viewLabel?: string;
/** The view is of the instance the crumb was reached through (`UnitView.of`). */
viewOf?: 'instance';
}

/** One view a producer offers of a unit, as `meta.views` lists it. */
export interface UnitView {
id: string;
label: string;
/**
* `'instance'`: the view is of this instance, not of the definition its
* file holds under that name. For a unit made per instance (by a factory,
* with the instance's parameters) the two differ, and the file cannot
* answer for the instance: the view is asked of the root, by the path of
* instances that leads to it, as a drill-down into a nested unit is.
*
* It is said of a view a node offers (a view of the unit shown, offered
* on the canvas, is reached through no node), and it is acted on by a
* model source that can ask a root by a path of instances: one that
* cannot asks by name, as before.
*/
of?: 'instance';
}

/** The view id on a request option or root arg, or nothing. */
export function parseViewArg(raw: unknown): string | undefined {
return typeof raw === 'string' && raw.trim() !== '' ? raw.trim() : undefined;
}

/** `meta.views` as the producer wrote it: `[{ id, label }]`, else nothing. */
/** `meta.views` as the producer wrote it: `[{ id, label, of? }]`, else nothing. */
export function parseViewsMeta(raw: unknown): UnitView[] | undefined {
if (!Array.isArray(raw)) {
return undefined;
Expand All @@ -57,7 +72,7 @@ export function parseViewsMeta(raw: unknown): UnitView[] | undefined {
continue;
}
const label = typeof item?.label === 'string' && item.label.trim() !== '' ? item.label.trim() : id;
out.push({ id, label });
out.push({ id, label, ...(item?.of === 'instance' ? { of: 'instance' as const } : {}) });
}
return out.length > 0 ? out : undefined;
}
Expand Down Expand Up @@ -239,12 +254,13 @@ export function parseNavigationTrailArg(raw: unknown): NavigationTrailEntry[] {
: '';
const view = typeof item?.view === 'string' ? item.view.trim() : '';
const viewLabel = typeof item?.viewLabel === 'string' ? item.viewLabel.trim() : '';
const viewOf = item?.viewOf === 'instance' ? { viewOf: 'instance' as const } : {};
if (sourceUri !== '' && workflowName !== '') {
out.push({
sourceUri: normalizeSourceUriKey(sourceUri),
workflowName,
...(workflowInstanceName !== '' ? { workflowInstanceName } : {}),
...(view !== '' ? { view, ...(viewLabel !== '' ? { viewLabel } : {}) } : {})
...(view !== '' ? { view, ...(viewLabel !== '' ? { viewLabel } : {}), ...viewOf } : {})
});
}
}
Expand Down Expand Up @@ -300,6 +316,50 @@ export function normalizeGraphLoadErrors(raw: unknown): NormalizedGraphLoadError
.filter((entry): entry is NormalizedGraphLoadError => entry !== undefined);
}

/**
* Whether the view a request asks for is of the instance it was reached
* through (the trail's last crumb says so, and it is that view): such a view
* is asked of the root first, by the instance path (`deriveTrailFallback`).
*
* Only when the trail is whole: every crumb under the root reached through
* an instance. As a last resort after a load that failed, a path with a step
* missing does no harm; asked first, it would lead to another instance, or
* to one of the same name higher up, and that answer would be shown.
*/
export function viewIsOfInstance(opts: Record<string, unknown>): boolean {
const view = parseViewArg(opts[VIEW_ARG]);
const trail = parseNavigationTrailArg(opts[NAV_TRAIL_ARG]);
const last = trail[trail.length - 1];
return view !== undefined && trail.length >= 2 && last.view === view && last.viewOf === 'instance'
&& trail.slice(1).every(crumb => typeof crumb.workflowInstanceName === 'string' && crumb.workflowInstanceName.trim() !== '');
}

/**
* A request's options with the view its trail ends at, when the request
* names none and is for that crumb's unit.
*
* The client names the view of the trail's last crumb on every request it
* makes. The host does not: a refresh (a keystroke, a save, a run) repeats
* the file, the name and the trail, and would bring back the unit's
* structure under a breadcrumb that still says the view. The trail is what
* says where the reader is, so it settles what is shown.
*/
export function withTrailView(sourceUri: string, opts: Record<string, unknown>): Record<string, unknown> {
if (parseViewArg(opts[VIEW_ARG]) !== undefined) {
return opts;
}
const trail = parseNavigationTrailArg(opts[NAV_TRAIL_ARG]);
const last = trail[trail.length - 1];
if (!last?.view || last.sourceUri !== normalizeSourceUriKey(sourceUri)) {
return opts;
}
const asked = typeof opts.networkName === 'string' ? opts.networkName.trim() : '';
if (asked !== '' && asked !== last.workflowName) {
return opts;
}
return { ...opts, [VIEW_ARG]: last.view };
}

/**
* Derive a graph-source fallback (root workflow file + instance path) from the cross-file
* breadcrumb trail when the caller didn't explicitly supply `cal:graphSourceUri`.
Expand Down
Loading
Loading