Incorrect rendering of controlled Fluent UI Checkbox components

1.2k views Asked by At

I have a form that interacts with a database via REST API and presents several controlled Fluent UI components. For a multi-choice field, I built a component that displays a Stack with an arbitrary number of controlled Checkbox components. Below is the component definition.

class MultiChoiceField extends React.Component
{
  static contextType = FormContext;
  static displayName = "MultiChoiceField";

  #handlers = { change: {} };

  /**
   * Initializes the component using the information provided in the {@link Item} provided by the {@link FormContext}.
   * @constructor
   * @param {Object} props The properties provided for this component.
   */
  constructor(props)
  {
    super(props);
    this.state = { value: {} };
  }

  /**
   * Set up the component once it is added to the DOM. Context isn't available in the constructor, so we set up the
   * value here.
   * @function
   * @param {Object} nextProps The value that will be assigned to `this.props`.
   * @param {Object} nextContext The {@link FormContext} that will be assigned to `this.context`.
   * @public
   * @returns {void}
   */
  componentDidMount(nextProps, nextContext)
  {
    const choices = nextProps?.Field?.Choices?.results || [];
    let value = nextContext?.Item?.[nextProps.FieldName] || {};
    value = Array.isArray(value) ? value : (value.results || []);
    this.setState({
      value: choices.reduce((result, choice) => ({ ...result, [choice]: value.indexOf(choice) >= 0 }), {})
    });
  }

  /**
   * Update the component when it receives new props or context information.
   * @function
   * @param {Object} nextProps The value that will be assigned to `this.props`.
   * @param {Object} nextContext The {@link FormContext} that will be assigned to `this.context`.
   * @public
   * @returns {void}
   */
  componentWillReceiveProps(nextProps, nextContext)
  {
    const choices = nextProps?.Field?.Choices?.results;
    let value = nextContext.Item?.[nextProps.FieldName] || {};
    value = Array.isArray(value) ? value : (value.results || []);
    this.setState({
      value: choices.reduce((result, choice) => ({ ...result, [choice]: value.indexOf(choice) >= 0 }), {})
    });
  }

  /**
   * Get an event handler for the specified choice.
   * @function
   * @param {string} name The choice with which this event handler is associated.
   * @public
   * @returns {function} An event handler for the specified choice.
   */
  handleChange = (name) =>
  {
    const bubbleOnChange = (event, value) =>
      (this.props.onChange?.(event, Object.keys(value).filter((choice) => (value[choice]))));
    if (!this.#handlers.change[name])
    {
      this.#handlers.change[name] = (event) =>
      {
        const value = { ...this.state.value, [name]: !this.state.value[name] };
        this.setState({ value }, () => (void bubbleOnChange(event, value)));
      };
    }
    return this.#handlers.change[name];
  }

  /**
   * Render the user interface for this component as a
   * [Stack]{@link https://developer.microsoft.com/en-us/fluentui#/controls/web/stack} containing
   * [Checkbox]{@link https://developer.microsoft.com/en-us/fluentui#/controls/web/checkbox} components.
   * @function
   * @public
   * @returns {JSX} The user interface for this component.
   */
  render()
  {
    const choices = this.props.Field.Choices.results;
    return (<>
      <Fabric.Stack {...this.props.stackTokens}>
        {choices.map((choice) => (
          <Fabric.Checkbox label={choice} checked={this.state.value[choice]}
            onChange={this.handleChange(choice)} key={choice} />
        ))}
      </Fabric.Stack>
      <div
        className="errorMessage"
        id={`FormFieldDescription--${this.context.Item?.Id}__${this.props.FieldName}`}
        role="alert"
        style={{ display: this.props.errorMessage ? "" : "none" }}>
        {this.props.errorMessage}
      </div>
    </>);
  }
}

After the form retrieves the data via REST API, this component uses that data to update its state. While the state is correctly updated and the correct values are being passed to the props for each Checkbox component, the UI is misleading. For instance, the checked values below are set to false, true, false, false, and false respectively, according to the React Components inspector in Chrome DevTools.

Initial presentation of Stack containing one Checkbox with checked set to true; No checkboxes are ticked

Obviously, while the props are correctly set, the user is presented with five unticked checkboxes. When the user clicks the checkbox that should have been ticked, the state is correctly updated to reflect that all five checkboxes are unticked. This is what it looks like after the user clicks on the second checkbox.

Updated presentation of Stack containing no Checkboxes with checked set to true; The second checkbox is ticked

The user interacts with the Checkbox components and they behave as expected, but the underlying values are exactly inverted for any where the initial checked property was set to true.

1

There are 1 answers

2
Paul Rowe On

I added context to the constructor and that didn't help, so I converted this class component into a functional component. It worked as intended. Here is the code for the functional component.

const MultiChoiceField = ({ errorMessage = "", Field, FieldName, onChange, stackTokens = {} } = {}) =>
{
  const context = useContext(FormContext);
  const [value, setValue] = useState({});
  const handleChange = (choice) => (event) =>
  {
    const bubbleOnChange = (event, value) => (void onChange?.(event, value));
    const getValueAsArray = (valueNew) =>
      (Object.entries(valueNew).filter(([, value]) => (value)).map(([key]) => (key)));
    const valueNew = { ...value, [choice]: !value[choice] };
    bubbleOnChange(event, getValueAsArray(valueNew));
  };
  const updateChoices = () =>
  {
    const reduceSelected = (valueContext) => (result, choice) =>
      ({ ...result, [choice]: ~valueContext.indexOf(choice) });
    const choices = Field?.Choices?.results || [];
    let valueContext = context?.Item?.[FieldName] || {};
    valueContext = Array.isArray(valueContext) ? valueContext : (valueContext.results || []);
    setValue(choices.reduce(reduceSelected(valueContext), {}));
  };
  useEffect(updateChoices, [Field?.Choices, FieldName, context?.Item]);
  const renderChoice = (choice) => (
    <Fabric.Checkbox checked={!!value[choice]} key={choice} label={choice} onChange={handleChange(choice)} />
  );
  return (<>
    <Fabric.Stack {...stackTokens}>{(Field?.Choices?.results || []).map(renderChoice)}</Fabric.Stack>
    <div
      className="errorMessage"
      id={`FormFieldDescription--${context.Item?.Id}__${FieldName}`}
      role="alert"
      style={{ display: errorMessage ? "" : "none" }}>
      {errorMessage}
    </div>
  </>);
};
MultiChoiceField.displayName = "MultiChoiceField";

Note that the interface is the same and the internal storage of state is essentially the same.