yangzhang75 commented on code in PR #8839:
URL: https://github.com/apache/texera/pull/8839#discussion_r4199145634


##########
frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts:
##########
@@ -887,40 +894,67 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
     // The schema's own title ("Attributes", "Limit", "File") -- the reader's 
title when unnamed.
     // Falls back to this, not the lower-camel key ("fileName"), which would 
read inconsistently.
     const schemaLabel = (source.props?.label as string) || binding.propertyKey;
-    field.key = binding.id;
+    // The field keeps the property's own key, as on the operator property 
panel: a widget that
+    // reaches a sibling or itself by key (the HuggingFace picker resets 
`modelId` and its
+    // task-scoped siblings on a task change) must find the same controls and 
model keys here.
     field.props = {
       ...(field.props ?? {}),
       label: binding.displayName || schemaLabel,
     };
 
     const form = new FormGroup({});
-    // Seed the model with the operator's other properties as read-only 
context, not just this
-    // input's own value: some custom widgets read a sibling to decide what to 
show -- the
-    // HuggingFace model picker reads `task` to load the right models and 
label the field. Only
-    // this binding's value is ever written back (see below); the context is 
never persisted, and
-    // it is cloned so a widget that mutates it cannot reach through to the 
real operator.
+    // The card's model is the operator's properties, as the panel's is: the 
exposed value, and
+    // every other property as context a widget may read or change -- the 
HuggingFace model picker
+    // reads `task` to load the right models and, when the task is changed, 
resets the task-scoped
+    // siblings as it does on the panel. The copy is cloned so nothing reaches 
through to the real
+    // operator; what changes is written back below, as one edit, the way the 
panel writes.
     const model: Record<string, unknown> = {
       ...cloneDeep(operator?.operatorProperties ?? {}),
-      [binding.id]: cloneDeep(resolved.value),
+      [binding.propertyKey]: cloneDeep(resolved.value),
     };
+    // The other properties ride along as plain controls (formly builds only 
the exposed field), so
+    // a widget finds its siblings on the card's form exactly as on the 
panel's -- the HuggingFace
+    // picker writes `task` through its control, which is also what announces 
the change -- and a
+    // control-level write lands in the model, which is what is written back.
+    const schemaKeys = Object.keys((schema as { properties?: Record<string, 
unknown> }).properties ?? {});
+    const siblingKeys = [...new Set([...schemaKeys, 
...Object.keys(model)])].filter(key => key !== binding.propertyKey);
+    for (const key of siblingKeys) {
+      const control = new FormControl(model[key] ?? null);
+      control.valueChanges.pipe(takeUntil(this.formsRebuilt), 
untilDestroyed(this)).subscribe(value => {
+        model[key] = value;
+      });
+      form.addControl(key, control);
+    }
     if (this.canEdit) {
       form.valueChanges
         .pipe(debounceTime(FORM_DEBOUNCE_TIME_MS), 
takeUntil(this.formsRebuilt), untilDestroyed(this))
         .subscribe(() => {
-          // Formly emits the schema's empty default while building the 
control, before any edit;
-          // writing that back silently wiped the operator's real value (both 
views edit one
-          // workflow). So only accept a dirtied form, or a value that differs 
from the operator's
-          // without being emptier (some controls set values without marking 
dirty).
-          const next = model[binding.id];
-          const current = 
this.formBindingService.readValue(binding.operatorID, binding.propertyKey);
+          // Whatever differs from the operator is written back: the bound 
property, and the
+          // siblings a widget changed with it. Formly emits the schema's 
empty default for the bound
+          // control while building it, before any edit; writing that back 
silently wiped the
+          // operator's real value (both views edit one workflow). So the 
bound property is taken
+          // only from a dirtied form, or when it differs without being 
emptier (some controls set
+          // values without marking dirty).
+          const current = 
(this.workflowActionService.getTexeraGraph().getOperator(binding.operatorID)
+            ?.operatorProperties ?? {}) as Record<string, unknown>;
           const isEmpty = (v: unknown) => v === undefined || v === null || v 
=== "";
-          const unchanged = JSON.stringify(next ?? null) === 
JSON.stringify(current ?? null);
-          if (unchanged || (!form.dirty && isEmpty(next) && 
!isEmpty(current))) {
+          // An empty string and an absent property mean the same to an 
operator, and formly fills a
+          // blank for a string the operator never had: neither is a change of 
the other.
+          const asJson = (value: unknown) => JSON.stringify(value === "" ? 
null : value ?? null);
+          const differs = (key: string) => asJson(model[key]) !== 
asJson(current[key]);
+          const bound = binding.propertyKey;
+          const blankBeforeEdit = !form.dirty && isEmpty(model[bound]) && 
!isEmpty(current[bound]);
+          const patch = Object.fromEntries(
+            Object.keys(model)
+              .filter(key => differs(key) && !(key === bound && 
blankBeforeEdit))
+              .map(key => [key, model[key]])

Review Comment:
   Confirmed and fixed in 29cec391. The card now diffs against its own baseline 
-- a clone of what it was built from, advanced by each write -- instead of 
against the operator as it is now, so the patch is only what this card changed. 
A sibling the canvas, a co-editor or another card on the same operator changed 
in the meantime is no longer in it, and the blank-build-default guard moved to 
the same baseline. Two tests cover it (only the changed key is sent; a second 
edit sends only the second change), both deletion-checked.



##########
frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts:
##########
@@ -724,6 +724,13 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
     if (!active || !this.host.nativeElement.contains(active)) {
       return false;
     }
+    // A dropdown (nz-select) keeps the focus on its search box after a pick. 
That is typing only
+    // while the list is open and a filter can be typed; a closed dropdown 
that merely holds the
+    // focus held every rebuild, so a change made elsewhere (the canvas, a 
co-editor) never showed.
+    const dropdown = active.closest(".ant-select");
+    if (dropdown) {
+      return dropdown.classList.contains("ant-select-open");

Review Comment:
   Right, fixed in 29cec391. The held rebuild is now flushed by a click or a 
key anywhere on the page as well as by focusout, through the same deferred 
re-check, so closing the dropdown releases it even though the focus stays in 
its search box. A filter still being typed is unaffected, since the re-check 
runs after the tick and the predicate still sees an open list. Covered by a 
host-binding test in the rendered spec plus the negative case.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to