Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ export class SharedModel {
public operatorLinkMap: Y.Map<OperatorLink>;
public elementPositionMap: Y.Map<Point>;
public debugState: Y.Map<Y.Map<BreakpointInfo>>;
// Non-graph workflow content that used to live as a per-client private field and so was
// silently overwritten by another editor's whole-content autosave (workflowSettings, and
// the Form View definition). Kept in the shared doc, keyed "settings"/"formBinding", so it
// syncs live like the graph and every autosave writes the current value, not a stale copy.
public contentMetaMap: Y.Map<unknown>;
public undoManager: Y.UndoManager;
public clientId: string;

Expand All @@ -68,6 +73,7 @@ export class SharedModel {
this.commentBoxMap = this.yDoc.getMap("commentBoxMap");
this.operatorLinkMap = this.yDoc.getMap("operatorLinkMap");
this.elementPositionMap = this.yDoc.getMap("elementPositionMap");
this.contentMetaMap = this.yDoc.getMap("contentMeta");

// Initialize Y-undo manager by aggregating intended Y-structures. Only structures included here will be undoable.
this.undoManager = new Y.UndoManager(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -891,12 +891,41 @@ describe("WorkflowActionService", () => {
sub.unsubscribe();
});

// Opening a workflow is not an edit; announcing it would save on every open.
// The definition lives in the shared model (#8315), so a co-editor's change -- a write to
// the shared map from a remote transaction -- is picked up locally and re-rendered, and
// this client's next autosave carries the current value instead of a stale private copy.
it("should pick up a co-editor's change from the shared model", () => {
const seen: unknown[] = [];
const sub = service.formBindingChanged$.subscribe(v => seen.push(v));

texeraGraph.sharedModel.contentMetaMap.set("formBinding", config);

expect(seen).toEqual([config]);
expect(service.getFormBinding()).toEqual(config);
sub.unsubscribe();
});

// Opening a workflow is not an edit; announcing it would save on every open. The seed
// runs under the reloading flag, so the shared-map observer skips it.
it("should stay silent while a workflow is being opened", () => {
const seen: unknown[] = [];
const sub = service.formBindingChanged$.subscribe(v => seen.push(v));

service.hydrateFormBinding(config);
service.reloadWorkflow(
{
...DEFAULT_WORKFLOW,
content: {
operators: [mockScanPredicate],
operatorPositions: { [mockScanPredicate.operatorID]: mockPoint },
links: [],
commentBoxes: [],
settings: undefined as any,
formBinding: config,
},
},
false,
false
);

expect(seen.length).toEqual(0);
expect(service.getFormBinding()).toEqual(config);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,17 +102,12 @@ export class WorkflowActionService {
private resultPanelOpenSubject = new Subject<boolean>();
public readonly resultPanelOpen$: Observable<boolean> = this.resultPanelOpenSubject.asObservable();

private workflowSettings: WorkflowSettings;
private workflowResetSubject = new Subject<void>();

// The Form View definition. Presentation, not structure, so it stays out of the shared
// graph (no collaborative merge) and is handled like workflowSettings -- hydrated by
// reloadWorkflow, emitted by getWorkflowContent.
private formBinding: FormBindingConfig = getDefaultFormBinding();
// Whether the opened workflow's content carried a formBinding. Kept so getWorkflowContent
// re-emits the key only when it was there (or an author has since populated it), leaving a
// plain workflow's content byte-identical -- the same rule agent-service follows.
private formBindingLoaded = false;
// The Form View definition. Presentation, not structure, but it still lives in the shared
// doc (shared-model contentMetaMap, key "formBinding") so a co-editor sees it live and no
// collaborator's whole-content autosave overwrites it with a stale copy. workflowSettings
// is kept there too (key "settings") for the same reason.
private formBindingChangeSubject = new Subject<FormBindingConfig>();
public readonly formBindingChanged$: Observable<FormBindingConfig> = this.formBindingChangeSubject.asObservable();

Expand All @@ -136,12 +131,28 @@ export class WorkflowActionService {
);
this.sharedModelChangeHandler.setConfigService(this.config);
this.workflowMetadata = DEFAULT_WORKFLOW;
this.workflowSettings = this.getDefaultSettings();
this.undoRedoService.setUndoManager(this.texeraGraph.sharedModel.undoManager);

// Watch the shared content map, re-attaching whenever the shared model is recreated
// (opening another workflow), the same way SharedModelChangeHandler re-attaches its
// graph observers. A formBinding change from a local edit or a co-editor is republished
// on formBindingChanged$ so the Form View re-renders and the existing autosave picks it
// up. The reload seed is skipped -- like the graph seed -- so opening a workflow is not
// announced as an edit and does not save on every open.
this.observeContentMeta();
this.texeraGraph.newYDocLoadedSubject.subscribe(() => this.observeContentMeta());

this.handleJointElementDrag();
}

private observeContentMeta(): void {
this.texeraGraph.sharedModel.contentMetaMap.observe(event => {
if (event.changes.keys.has("formBinding") && !this.jointGraphWrapper.getReloadingWorkflow()) {
this.formBindingChangeSubject.next(this.getFormBinding());
}
});
}

private getDefaultSettings(): WorkflowSettings {
return {
dataTransferBatchSize: this.config.env.defaultDataTransferBatchSize,
Expand Down Expand Up @@ -667,7 +678,7 @@ export class WorkflowActionService {
}

const workflowContent: WorkflowContent = workflow.content;
this.workflowSettings = workflowContent.settings || this.getDefaultSettings();
this.setWorkflowSettings(workflowContent.settings);
this.hydrateFormBinding(workflowContent.formBinding);

let operatorsAndPositions: { op: OperatorPredicate; pos: Point }[] = [];
Expand Down Expand Up @@ -747,25 +758,29 @@ export class WorkflowActionService {
}

public setWorkflowSettings(workflowSettings: WorkflowSettings | undefined): void {
if (this.workflowSettings === workflowSettings) {
return;
}

const newSettings = workflowSettings === undefined ? this.getDefaultSettings() : workflowSettings;
this.workflowSettings = newSettings;
this.texeraGraph.sharedModel.contentMetaMap.set("settings", newSettings);
}

public getWorkflowSettings(): WorkflowSettings {
return this.workflowSettings;
return (
(this.texeraGraph.sharedModel.contentMetaMap.get("settings") as WorkflowSettings) ?? this.getDefaultSettings()
);
}

/**
* Load a definition without announcing an edit. Used while opening a workflow, so
* that merely reading one does not look like a change and trigger a save.
* Load a definition into the shared model while opening a workflow. `undefined` clears the
* key so a plain workflow carries none and the one left by a previously open workflow cannot
* leak in. Called under the reloading flag, so the shared-map observer skips this seed and
* opening a workflow is not announced as an edit.
*/
public hydrateFormBinding(formBinding: FormBindingConfig | undefined): void {
this.formBindingLoaded = formBinding !== undefined;
this.formBinding = formBinding ?? getDefaultFormBinding();
const contentMeta = this.texeraGraph.sharedModel.contentMetaMap;
if (formBinding === undefined) {
contentMeta.delete("formBinding");
} else {
contentMeta.set("formBinding", formBinding);
}
}

/** A form binding worth persisting: an author populated it (fields, results, or an
Expand All @@ -775,16 +790,17 @@ export class WorkflowActionService {
}

/**
* Replace the definition as an edit: announced on `formBindingChanged$`, which
* feeds workflowChanged() and so reaches the existing autosave.
* Replace the definition as an edit. The shared map's observer republishes it on
* `formBindingChanged$`, which feeds workflowChanged() and so reaches the existing autosave.
*/
public setFormBinding(formBinding: FormBindingConfig): void {
this.formBinding = formBinding;
this.formBindingChangeSubject.next(this.formBinding);
this.texeraGraph.sharedModel.contentMetaMap.set("formBinding", formBinding);
}

public getFormBinding(): FormBindingConfig {
return this.formBinding;
return (
(this.texeraGraph.sharedModel.contentMetaMap.get("formBinding") as FormBindingConfig) ?? getDefaultFormBinding()
);
}

public getWorkflowMetadata(): WorkflowMetadata {
Expand All @@ -798,7 +814,7 @@ export class WorkflowActionService {
const links = texeraGraph.getAllLinks();
const operatorPositions: { [key: string]: Point } = {};
const commentBoxes = texeraGraph.getAllCommentBoxes();
const settings = this.workflowSettings;
const settings = this.getWorkflowSettings();

texeraGraph
.getAllOperators()
Expand All @@ -814,11 +830,13 @@ export class WorkflowActionService {
links,
commentBoxes,
settings,
// Carry formBinding only when the workflow has one (loaded with it, or an author
// populated it), so a plain workflow's content is unchanged and its save cuts no
// needless version.
...(this.formBindingLoaded || this.isFormBindingNonEmpty(this.formBinding)
? { formBinding: this.formBinding }
// Carry formBinding only when the workflow has one (opened with it, or an author
// populated it since), so a plain workflow's content is unchanged and its save cuts no
// needless version. `has` stands in for the old "loaded" flag: hydrate sets the key for
// a workflow opened with a binding and deletes it for one without.
...(this.texeraGraph.sharedModel.contentMetaMap.has("formBinding") ||
this.isFormBindingNonEmpty(this.getFormBinding())
? { formBinding: this.getFormBinding() }
: {}),
};
}
Expand Down Expand Up @@ -864,12 +882,12 @@ export class WorkflowActionService {

public setWorkflowDataTransferBatchSize(size: number): void {
if (size > 0 && size != null) {
this.setWorkflowSettings({ ...this.workflowSettings, dataTransferBatchSize: size });
this.setWorkflowSettings({ ...this.getWorkflowSettings(), dataTransferBatchSize: size });
}
}

public updateExecutionMode(mode: ExecutionMode): void {
this.setWorkflowSettings({ ...this.workflowSettings, executionMode: mode });
this.setWorkflowSettings({ ...this.getWorkflowSettings(), executionMode: mode });
}

public clearWorkflow(): void {
Expand Down
Loading