Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Question: how to use initial value as default value, and can be overwrite by IP passed from connected component. #1049

Open
linonetwo opened this issue Aug 18, 2023 · 4 comments

Comments

@linonetwo
Copy link
Contributor

Replay Initial IPs

Human:
This is a noflo graph with three nodes, and I've add several initialvalue to the TextField node:

{"caseSensitive":false,"properties":{"name":""},"inports":{},"outports":{},"groups":[],"processes":{"ui/TextField_4jqop":{"component":"ui/TextField","metadata":{"label":"TextField","x":396,"y":180,"width":72,"height":72}},"ui/ResultText_ogbu0":{"component":"ui/ResultText","metadata":{"label":"ResultText","x":540,"y":180,"width":72,"height":72}},"core/Output_946va":{"component":"core/Output","metadata":{"label":"Output","x":684,"y":180,"width":72,"height":72}}},"connections":[{"src":{"process":"ui/TextField_4jqop","port":"out"},"tgt":{"process":"ui/ResultText_ogbu0","port":"in"},"metadata":{}},{"src":{"process":"ui/ResultText_ogbu0","port":"out"},"tgt":{"process":"core/Output_946va","port":"in"},"metadata":{}},{"src":{"process":"ui/ResultText_ogbu0","port":"out"},"tgt":{"process":"ui/TextField_4jqop","port":"control"},"metadata":{"route":0}},{"data":"Label","tgt":{"process":"ui/TextField_4jqop","port":"label"}},{"data":"Desc","tgt":{"process":"ui/TextField_4jqop","port":"desc"}},{"data":"Intro","tgt":{"process":"ui/TextField_4jqop","port":"intro"}},{"data":"Placeholder","tgt":{"process":"ui/TextField_4jqop","port":"placeholder"}},{"data":true,"tgt":{"process":"ui/TextField_4jqop","port":"control"}}]}

Here are source typescript code for two custom nodes:

/* eslint-disable @typescript-eslint/strict-boolean-expressions */
/* eslint-disable unicorn/no-null, @typescript-eslint/require-await */
// Load the NoFlo interface
import { Component } from 'noflo';
import type { ITextFieldProps, UIEffectsContext } from '../types/UIEffectsContext';

export const getComponent = () => new TextField();
class TextField extends Component {
  description = 'Wait for user input some text.';
  icon = 'commenting-o';
  uiElementID?: string;
  uiEffects?: UIEffectsContext;

  constructor() {
    super();

    // Define the component's inports
    this.inPorts.add('control', {
      datatype: 'bang',
      description: 'Trigger the input box to show.',
    });

    this.inPorts.add('label', {
      datatype: 'string',
      description: 'Name of this input, should be a short text.',
      required: true,
    });
    this.inPorts.add('desc', {
      datatype: 'string',
      description: 'Description text show on the input box. Can be a one line long text.',
    });
    this.inPorts.add('intro', {
      datatype: 'string',
      description: 'Introduction text show before the input box. Can be a multiple line long text.',
    });
    this.inPorts.add('placeholder', {
      datatype: 'string',
      description: 'Text to display when the input box is empty.',
    });

    this.inPorts.add('ui_effects', {
      datatype: 'object',
      description: 'Used by system, inject UI related methods.',
      required: true,
    });

    this.outPorts.add('out', {
      datatype: 'string',
    });

    // Register a process handler for incoming data
    this.process((input, output) => {
      this.uiEffects ??= input.getData('ui_effects') as UIEffectsContext | undefined;
      if (this.uiEffects === undefined) return;
      // If 'in' port is not triggered, return
      if (!input.hasData('control')) return;
      const control = input.getData('control') as undefined | null | true;
      // rapidly receive null here, still stuck
      if (control !== true) return;
      const label = input.getData('label') as string;
      const desc = input.getData('desc') as string | undefined;
      const intro = input.getData('intro') as string | undefined;
      const placeholder = input.getData('placeholder') as string | undefined;
      const props: ITextFieldProps = {
        label,
        description: desc,
        introduction: intro,
        placeholder,
      };
      // If we already have an UI element created, update it. Otherwise, create a new one.
      if (this.uiElementID === undefined) {
        this.uiElementID = this.uiEffects.addElement({ type: 'textField', props });
        // wait for result, and sent to outPort
        // TODO: change to async await when https://github.com/noflo/noflo/issues/1047 is fixed.
        void this.uiEffects.onSubmit(this.uiElementID).then(resultText => {
          this.uiElementID = undefined;
          output.sendDone({ out: resultText });
        });
      } else {
        this.uiEffects.updateElementProps({ id: this.uiElementID, props });
      }
    });
  }
}
/* eslint-disable unicorn/no-null, @typescript-eslint/require-await */
// Load the NoFlo interface
import { Component } from 'noflo';
import type { IResultTextProps, UIEffectsContext } from '../types/UIEffectsContext';

export const getComponent = () => new ResultText();
class ResultText extends Component {
  description = 'Result text from workflow or LLM.';
  icon = 'check-square-o';
  uiElementID?: string;
  uiEffects?: UIEffectsContext;

  constructor() {
    super();

    this.inPorts.add('ui_effects', {
      datatype: 'object',
      description: 'Used by system, inject UI related methods.',
      required: true,
    });

    // Define the component's inports
    this.inPorts.add('in', {
      datatype: 'string',
      description: 'Result text to display.',
    });

    this.outPorts.add('out', {
      datatype: 'bang',
    });

    // Register a process handler for incoming data
    this.process((input, output) => {
      this.uiEffects ??= input.getData('ui_effects') as UIEffectsContext | undefined;
      if (this.uiEffects === undefined) return;
      if (!input.hasData('in')) return;
      const content = input.getData('in') as string;
      if (content === null || content === undefined) return;
      const props: IResultTextProps = {
        content,
      };
      this.uiElementID = this.uiEffects.addElement({ type: 'textResult', props });
      output.sendDone({ out: true });
    });
  }

  async tearDown() {
    if (this.uiElementID !== undefined) {
      // set to submit state
      this.uiEffects?.submitElement?.(this.uiElementID, null);
    }
  }
}

I've connect TextField node to ResultText and then back to TextField node, so it works like a REPL. In first execution, the TextField renders UI with props, where I construct props using initial IPs (contains data like Label and Desc), but when it loop back to TextField node from ResultText, the initial value are used, so this time, props will be empty. But I hope I can always apply same initial value for selected ports (maybe add an option to "replay" them?), how to do this in noflo?


GPT4:
In NoFlo, the initial packets (IPs) are usually sent only once during the graph's startup. This means when you loop from the ResultText node back to the TextField node, the initial IPs aren't sent again, leading to the observed behavior.

To achieve the behavior you want, where the initial values are applied every time, you need a mechanism that can "store and replay" those initial IPs.

Here's a suggested approach:

  1. Use a State Node: Create a custom stateful component that receives the initial IPs, stores them, and then sends them out whenever it receives a bang from the ResultText node. This way, you effectively "replay" the initial IPs after every cycle.

  2. Graph Modifications: Connect this state node between the ResultText node and the TextField node. The sequence would be:

    • TextField -> ResultText
    • ResultText -> StateNode (on receiving a bang)
    • StateNode -> TextField (sends the initial IPs)
  3. State Node Implementation:

Here's a very basic TypeScript example for this StateNode:

import { Component } from 'noflo';

export const getComponent = () => new StateNode();
class StateNode extends Component {
  description = 'Stores and replays IPs on receiving a bang';
  initialValues: Record<string, any> = {};

  constructor() {
    super();

    this.inPorts.add('in', {
      datatype: 'all',
      description: 'Initial values to store and replay.',
    });

    this.inPorts.add('trigger', {
      datatype: 'bang',
      description: 'Trigger to send out stored IPs.',
    });

    this.outPorts.add('out', {
      datatype: 'all',
    });

    // Process handler
    this.process((input, output) => {
      // If we have data on the 'in' port, store it
      if (input.hasData('in')) {
        const data = input.getData('in');
        this.initialValues['in'] = data;
      }

      // If we receive a bang on the 'trigger' port, replay the stored IPs
      if (input.hasData('trigger')) {
        input.getData('trigger');  // Consume the bang
        if (this.initialValues['in']) {
          output.send({
            out: this.initialValues['in']
          });
        }
      }

      output.done();
    });
  }
}

Now, modify your graph to integrate this new StateNode in the loop, and it should replay the initial IPs on every cycle.


Human:
This is the react component I'm using to edit the initial value:

/* eslint-disable unicorn/no-null */
import DeleteIcon from '@mui/icons-material/Delete';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { Avatar, Card, CardActions, CardContent, CardHeader, Collapse, IconButton, IconButtonProps, TextField } from '@mui/material';
import { red } from '@mui/material/colors';
import { IChangeEvent } from '@rjsf/core';
import Form from '@rjsf/mui';
import { RJSFSchema, UiSchema } from '@rjsf/utils';
import validator from '@rjsf/validator-ajv8';
import useDebouncedCallback from 'beautiful-react-hooks/useDebouncedCallback';
import { GraphNode } from 'fbp-graph/lib/Types';
import { JSONSchema7TypeName } from 'json-schema';
import { FC, useCallback, useContext, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { IFBPLibrary, INoFloProtocolComponentPort, INoFloUIComponent, INoFloUIComponentPort } from 'the-graph';
import { FBPGraphReferenceContext } from '../hooks/useContext';
import { useSelectedNodeComponent } from '../hooks/useSelectedNodeComponent';
import { NoFloIcon } from './NoFloIcon';
import { SearchComponentsAutocomplete } from './SearchComponents';

const ListContainer = styled.div`
  display: flex;
  flex-direction: column;
  `;
const ItemContainer = styled(Card)`
  display: flex;
  flex-direction: column;
  margin-top: 0.3em;
  & .rjsf .MuiGrid-item {
    padding-top: 0;
  }
`;
interface ExpandMoreProps extends IconButtonProps {
  expand: boolean;
}

const ExpandMore = styled((props: ExpandMoreProps) => {
  const { expand, ...other } = props;
  return <IconButton {...other} />;
})(({ theme, expand }) => ({
  transform: expand ? 'rotate(180deg)' : 'rotate(0deg)',
  marginLeft: 'auto',
  transition: theme.transitions.create('transform', {
    duration: theme.transitions.duration.shortest,
  }),
}));

const uiSchema: UiSchema = {
  'ui:submitButtonOptions': {
    props: {
      disabled: true,
    },
    norender: true,
  },
};

interface NodeDetailPanelProps {
  library: IFBPLibrary;
  selectedNodes: GraphNode[];
}

/**
 * noflo's port can have type like 'all' or 'int' or 'bang', which is not exist in json schema, we transform them to appropriate type.
 */
const nofloPortTypeToRJSFType = (type: INoFloProtocolComponentPort['type']): JSONSchema7TypeName => {
  switch (type) {
    case 'all': {
      return 'string';
    }
    case 'int': {
      return 'number';
    }
    case 'bang': {
      return 'boolean';
    }
    default: {
      return type;
    }
  }
};

const generateSchemaFromPort = (port: INoFloUIComponentPort, options: { titleSuffix: string }): RJSFSchema => {
  return {
    title: `${port.name} (${options.titleSuffix})`,
    type: nofloPortTypeToRJSFType(port.type),
    default: port.default,
    enum: Array.isArray(port.values) ? port.values : undefined,
    description: port.description,
  };
};
const generateSchemaForMetadata = (metadata: Record<string, unknown>): RJSFSchema => {
  // json schema to edit height, label, width, x, y
  return {
    title: 'metadata',
    type: 'object',
    properties: {
      height: {
        type: 'number',
        default: metadata.height as number | undefined,
      },
      width: {
        type: 'number',
        default: metadata.width as number | undefined,
      },
      x: {
        type: 'number',
        default: metadata.x as number | undefined,
      },
      y: {
        type: 'number',
        default: metadata.y as number | undefined,
      },
    },
  };
};

export const NodeDetailPanel: FC<NodeDetailPanelProps> = ({ selectedNodes, library }) => {
  const nodes = useSelectedNodeComponent(selectedNodes, library);
  if (selectedNodes.length === 0) return null;

  return (
    <ListContainer>
      {nodes.map((node) => (
        <NodeItem
          key={node.node.id}
          node={node.node}
          component={node.component}
          library={library}
        />
      ))}
    </ListContainer>
  );
};

interface NodeItemProps {
  component: INoFloUIComponent;
  library: IFBPLibrary;
  node: GraphNode;
}
const NodeItem: FC<NodeItemProps> = ({ node, component, library }) => {
  const { t } = useTranslation();
  const inPorts = component.inports ?? [];
  const inPortSchemas: RJSFSchema = {
    type: 'object',
    properties: Object.fromEntries(inPorts.map((port) => [port.name, generateSchemaFromPort(port, { titleSuffix: t('Workflow.InPort') })])),
  };
  const [moreFormExpanded, setMoreFormExpanded] = useState(false);
  const fbpGraphReference = useContext(FBPGraphReferenceContext);
  const initializers = fbpGraphReference?.current?.initializers;
  const inPortData = useMemo(() => {
    const nodeInitialData: Record<string, unknown> = {};
    initializers?.forEach?.(initialInformationPackets => {
      if (initialInformationPackets.to.node === node.id) {
        nodeInitialData[initialInformationPackets.to.port] = initialInformationPackets.from.data as unknown;
      }
    });
    return nodeInitialData;
  }, [node, initializers]);

  const handleInPortsChange = useDebouncedCallback((formData: Record<string, unknown>) => {
    const fbpGraph = fbpGraphReference?.current;
    if (fbpGraph === undefined) return;
    // update actual data in the graph
    Object.entries(formData).forEach(([key, value]) => {
      if (value === undefined) return;
      fbpGraph.removeInitial(node.id, key);
      fbpGraph.addInitial(value, node.id, key);
    });
  }, [fbpGraphReference, node.id]);

  const handleMetadataChange = useDebouncedCallback((formData: Record<string, unknown>) => {
    // TODO: update node's metadata
  }, [fbpGraphReference, node.id]);

  return (
    <ItemContainer>
      <CardHeader
        avatar={
          <Avatar sx={{ bgcolor: red[500] }} aria-label='recipe'>
            <NoFloIcon icon={component.icon} />
          </Avatar>
        }
        title={<TextField value={node.metadata?.label as string ?? ''} label={t('Workflow.NodeLabel')} />}
      />
      <CardContent>
        <SearchComponentsAutocomplete
          label={t('Workflow.CurrentNodeComponentType')}
          library={library}
          onClick={(component) => {}}
          variant='standard'
          defaultValue={node.component}
        />
        <ControlledForm initialValue={inPortData} schema={inPortSchemas} updateGraphValue={handleInPortsChange} />
      </CardContent>
      <CardActions disableSpacing>
        <IconButton aria-label='delete'>
          <DeleteIcon />
        </IconButton>
        <ExpandMore
          expand={moreFormExpanded}
          onClick={() => {
            setMoreFormExpanded(!moreFormExpanded);
          }}
          aria-expanded={moreFormExpanded}
          aria-label='show more'
        >
          <ExpandMoreIcon />
        </ExpandMore>
      </CardActions>
      <Collapse in={moreFormExpanded} timeout='auto' unmountOnExit>
        <CardContent>
          {(node.metadata !== undefined) && (
            <ControlledForm initialValue={node.metadata} schema={generateSchemaForMetadata(node.metadata)} updateGraphValue={handleMetadataChange} />
          )}
        </CardContent>
      </Collapse>
    </ItemContainer>
  );
};

/**
 * Form with local data source so typing is smooth, and update remote graph data source when data changed (usually debounced).
 */
function ControlledForm(
  { initialValue, schema, updateGraphValue }: { initialValue: Record<string, unknown>; schema: RJSFSchema; updateGraphValue: (value: Record<string, unknown>) => void },
) {
  /** data source for controlled form, for smoother type experience */
  const [formData, formDataSetter] = useState(initialValue);
  const onChange = useCallback((data: IChangeEvent<any, RJSFSchema, any>, id?: string | undefined) => {
    /**
     * Input data is flattened, even you want to input an Object, it will be just string.
     */
    const formData = data.formData as Record<string, unknown>;
    // update local data for UI display
    formDataSetter(formData);
    updateGraphValue(formData);
  }, [updateGraphValue]);
  return <Form formData={formData} schema={schema} validator={validator} uiSchema={uiSchema} onChange={onChange} />;
}

Maybe in the form that edit initial value for inPorts, for each field, I can add a check box "always set this value", if checked, I add metadata in the IP to inform the component to save the data, and reuse the data in future execution of process method, if it gets undefined from that inPort. Is this a good solution? Is there better solution?


GPT4:
From your description, you want to add a checkbox to each input field in the form, which, when checked, saves the data for reuse in future executions. This approach provides user flexibility to decide which data is essential for them, but there are some considerations to make.

  1. User Experience and Complexity: Adding a checkbox to every input might complicate the user interface. Users may need to spend extra time understanding the functionality and implications of these checkboxes.

  2. Data Integrity and Consistency: By allowing users to save specific data for reuse, you're assuming that the data will remain valid or relevant in the future. This may not always be the case, and could lead to unexpected outcomes if the data becomes outdated or incorrect.

  3. Data Storage: Remember that storing more data can increase memory consumption, especially if there are many of these nodes with many saved initial values.

  4. Node Mutability: If a node's behavior or its expected inputs change in the future, these saved initial values could become irrelevant or even detrimental.

Given these considerations, here are a couple of alternative solutions:

Option A: Default Values in Schemas

Instead of relying on user-set checkboxes, you can define default values in your JSON Schema. If an input field is not provided a value, the default will be used. This is a common pattern and ensures a fallback for unspecified fields.

However, this won't be dynamic and won't adjust based on user past behavior.

Option B: Local Storage / Session Storage

Another approach is to save user inputs in local or session storage, so when they revisit the form, their previous inputs are pre-filled. This way, you're not altering the node's behavior based on past executions but providing a more user-friendly experience.

Option C: "Remember my choice" Option

Instead of a checkbox for every input field, have a single checkbox or toggle button for the whole form that says "Remember these values". When checked, you can store the form data, and when this node is revisited, you can pre-fill the values.

If you decide to stick with your current approach, consider providing tooltips or help messages next to the checkboxes, explaining their purpose. Moreover, consider implementing a mechanism to review or clear these saved values.

Regardless of the solution, remember to inform the user adequately about the behavior of these checkboxes or saved states to avoid any confusion or unexpected behavior.


Human:
I will use Option A: Default Values, but I will treat values set in the NodeDetailPanel UI as default value, So I need a way in the component to know if value is passed from connected component or from initial packet, if it is from connected component then it won't be replayed, if it is from initial packet, then it is saved and used as default value, which can be overwrite by IP passed from connected component.


GPT4:
Great choice! Option A offers a good balance between flexibility and simplicity.

Given your needs, you can use the following approach:

  1. Distinguish Source of Data: The key requirement here is the ability to differentiate between an IP (initial packet) and data passed from a connected component.

    • You could potentially modify the structure of the data you pass to include metadata indicating its source. For instance:
    {
      value: "actual data",
      source: "initial" // or "connected"
    }
    • Based on the source metadata, you can decide whether to use the value as a default or not.
  2. Store the Initial Value:

    • Whenever you receive an IP, update a stored 'default value' for the component in question.

    • This stored default value is only replaced if you receive another IP in the future.

  3. Check Value On Data Receipt:

    • Whenever you receive data, check its source.

    • If the data is from a connected component and is undefined or not present, use the stored default value. If the data is another IP, update the stored default value.

Here's a potential code snippet to help visualize this:

interface DataPacket {
  value: any;
  source: "initial" | "connected";
}

const MyComponent: FC<PropsType> = () => {
  const [defaultValue, setDefaultValue] = useState(null);
  const [currentValue, setCurrentValue] = useState(null);

  useEffect(() => {
    // Data receiving handler
    function onDataReceived(data: DataPacket) {
      if (data.source === "initial") {
        setDefaultValue(data.value);
        setCurrentValue(data.value);
      } else if (data.source === "connected") {
        if (data.value !== undefined) {
          setCurrentValue(data.value);
        } else {
          setCurrentValue(defaultValue);
        }
      }
    }

    // Imagine an event system or subscription model
    eventSystem.subscribe("dataReceived", onDataReceived);

    return () => {
      // Cleanup subscription
      eventSystem.unsubscribe("dataReceived", onDataReceived);
    };
  }, [defaultValue]);

  return (
    // Render logic using `currentValue`
  );
};

The logic above assumes that the component can distinguish the source of its data, and responds accordingly. The "event system" or subscription model will depend on how you've structured data flow in your application, so adjust accordingly.


Human:
Does noflo have API to distinguish an IP is sent by `fbpGraph.addInitial(value, node.id, key);`?


GPT4:
As of my last training data in September 2021, NoFlo uses the concept of "Initial Information Packets" (IIPs) to pass initial data into a flow graph when it starts. When using the addInitial method, you're essentially adding an IIP to the graph.

However, natively distinguishing between an IIP and a regular Information Packet (IP) sent during the flow's operation isn't explicitly supported out of the box by NoFlo. In the standard flow of NoFlo, IIPs are sent once when the network starts, and they're just like any other IP after that.

That said, if you need to distinguish between them in your application logic, there are workarounds:

  1. Metadata in the IP: As previously suggested, you can modify the structure of the IP itself to include some metadata. This requires that every component understands this structure, which might not be ideal if you're working with existing components or if you're aiming for a clean separation of concerns.

  2. Custom Initialization Process: You could customize the graph initialization process to send IIPs with a recognizable pattern or tag. For instance, you could prefix IIP data with some string or wrap it in a specific way, then remove or unwrap it in the first component that handles it.

  3. Component State: Components could keep an internal state flag indicating if they've received an IIP yet or not. When they first receive data, they'll know it's the IIP. This approach, however, assumes that an IIP will always be sent before any other IPs, which is typically the case but might vary depending on your flow's logic.

  4. Custom Component for IIP Handling: Create a custom component that only deals with handling and tagging IIPs. The graph sends IIPs to this component, which then tags them and forwards them to the rest of the network. This is a more modular approach and keeps the IIP handling logic separate from other components.

If NoFlo has introduced new features after 2021 or if there have been other developments in the community to address this, you might want to check the latest NoFlo documentation or community forums.


Human:
I'm using fbpGraph.addInitial(value, node.id, key, { initial: true }); where its type is (method) Graph.addInitial(data: any, node: string, port: string, metadata?: PropertyMap | undefined): Graph, so I added metadata here. How to get this metadata in process method of noflo component?


GPT4:
If you're adding metadata to the Initial Information Packet (IIP) using fbpGraph.addInitial(), you can access this metadata inside the component. Here's a step-by-step guide to achieve this:

  1. Adding the Initial Information Packet with Metadata:

You've already done this:

fbpGraph.addInitial(value, node.id, key, { initial: true });
  1. Accessing the Metadata in a NoFlo Component:

In a NoFlo component's process function, you have access to the Information Packet (IP). The metadata you attached to the IP can be accessed from this IP object.

Here's a simplified example of how you might do this in a component:

const noflo = require('noflo');

exports.getComponent = function () {
  const c = new noflo.Component();

  c.inPorts.add('in', {
    datatype: 'all',
  });

  c.outPorts.add('out', {
    datatype: 'all',
  });

  c.process((input, output) => {
    // Check if there's data at the 'in' port
    if (!input.hasData('in')) return;

    // Get the IP
    const ip = input.get('in');

    // Access metadata from the IP
    const isInitial = ip.metadata?.initial;

    if (isInitial) {
      // Handle the initial packet logic here
    }

    // Continue processing...

    output.sendDone({
      out: ip.data,
    });
  });

  return c;
};

In this example, we get the IP using input.get('in'). The metadata that you've attached when setting the IIP can then be accessed with ip.metadata. You can then use this metadata in your logic as required.

Remember, always ensure that the IP exists and that the metadata property you're trying to access is present before using it to avoid potential errors.


Human:


导出时间 2023年8月18日 下午3:53.

@linonetwo
Copy link
Contributor Author

Summerize:

I'm using a helper getDataOrDefault

import ProcessInput from 'noflo/lib/ProcessInput';

/**
 * Treat values set in the NodeDetailPanel UI as default value.
 *
 * So we need a way in the component to know if value is passed from connected component or from initial packet, if it is from connected component then it won't be replayed, if it is from initial packet, then it is saved and used as default value, which can be overwrite by IP passed from connected component.
 */
export function getDataOrDefault<T>(key: string, input: ProcessInput, defaultValues: Record<string, unknown> = {}): T | undefined {
  const ip = input.get(key);
  if (ip === null || ip === undefined) {
    return defaultValues[key] as T;
  }
  if (Array.isArray(ip)) {
    // TODO: handle array, why will this be an array of IP?
    return defaultValues[key] as T;
  }
  if (ip.initial) {
    defaultValues[key] = ip.data;
    return defaultValues[key] as T;
  }
  return ip.data as T ?? defaultValues[key] as T;
}

and in the component

/* eslint-disable @typescript-eslint/strict-boolean-expressions */
/* eslint-disable unicorn/no-null, @typescript-eslint/require-await */
// Load the NoFlo interface
import { getDataOrDefault } from '@/pages/Workflow/GraphEditor/utils/getDataOrDefault';
import { Component } from 'noflo';
import type { ITextFieldProps, UIEffectsContext } from '../types/UIEffectsContext';

export const getComponent = () => new TextField();
class TextField extends Component {
  description = 'Wait for user input some text.';
  icon = 'commenting-o';
  uiElementID?: string;
  uiEffects?: UIEffectsContext;
  defaultValues: Record<string, string> = {};

  constructor() {
    super();

    // Define the component's inports
    this.inPorts.add('control', {
      datatype: 'bang',
      description: 'Trigger the input box to show.',
    });

    this.inPorts.add('label', {
      datatype: 'string',
      description: 'Name of this input, should be a short text.',
      required: true,
    });
    this.inPorts.add('desc', {
      datatype: 'string',
      description: 'Description text show on the input box. Can be a one line long text.',
    });
    this.inPorts.add('intro', {
      datatype: 'string',
      description: 'Introduction text show before the input box. Can be a multiple line long text.',
    });
    this.inPorts.add('placeholder', {
      datatype: 'string',
      description: 'Text to display when the input box is empty.',
    });

    this.inPorts.add('ui_effects', {
      datatype: 'object',
      description: 'Used by system, inject UI related methods.',
      required: true,
    });

    this.outPorts.add('out', {
      datatype: 'string',
    });

    // Register a process handler for incoming data
    this.process((input, output, context) => {
      this.uiEffects ??= input.getData('ui_effects') as UIEffectsContext | undefined;
      if (this.uiEffects === undefined) {
        this.deactivate(context);
        return;
      }
      // If 'in' port is not triggered, return
      if (!input.hasData('control')) {
        this.deactivate(context);
        return;
      }
      const control = input.getData('control') as undefined | null | true;
      // rapidly receive null here, still stuck
      if (control !== true) {
        this.deactivate(context);
        return;
      }
      const props: ITextFieldProps = {
        label: getDataOrDefault('label', input, this.defaultValues),
        description: getDataOrDefault('desc', input, this.defaultValues),
        introduction: getDataOrDefault('intro', input, this.defaultValues),
        placeholder: getDataOrDefault('placeholder', input, this.defaultValues),
      };
      // If we already have an UI element created, update it. Otherwise, create a new one.
      if (this.uiElementID === undefined) {
        this.uiElementID = this.uiEffects.addElement({ type: 'textField', props });
        // wait for result, and sent to outPort
        // TODO: change to async await when https://github.com/noflo/noflo/issues/1047 is fixed.
        void this.uiEffects.onSubmit(this.uiElementID).then(resultText => {
          this.uiElementID = undefined;
          output.sendDone({ out: resultText });
        });
      } else {
        this.uiEffects.updateElementProps({ id: this.uiElementID, props });
        this.deactivate(context);
      }
    });
  }

  async tearDown() {
    if (this.uiElementID !== undefined) {
      // set to submit state
      this.uiEffects?.submitElement?.(this.uiElementID, null);
    }
  }
}

@bergie
Copy link
Member

bergie commented Aug 25, 2023

InPorts can have a default out of the box, just use the default key

@linonetwo
Copy link
Contributor Author

InPorts can have a default out of the box, just use the default key

That's different, default field is set by component developer, and a "graph-developer" may want to set a "default value of this node on this graph" that overwrite the default field. This in-graph-default-value is set using Node Inspector/Node Detail Panel.

So I still need the solution described above.

@bergie bergie reopened this Aug 25, 2023
@bergie
Copy link
Member

bergie commented Aug 25, 2023

On graph level you'd use an IIP. If you also export the inport, then consumers of your graph have an opportunity to override.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants