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

Proposal for new editors system #1166

Open
wants to merge 81 commits into
base: master
Choose a base branch
from
Open

Proposal for new editors system #1166

wants to merge 81 commits into from

Conversation

saskliutas
Copy link
Member

@saskliutas saskliutas commented Dec 20, 2024

Proposal for new editor system

Proposal for the new editors system. This would replace existing editors system in order to simplify requirements for custom editors. Also it removes static EditorManager in favor of editors registry controlled through React context.

API overview:

  • EditorsRegistry - Application should use it to register their on custom editors or custom editors provided by some dependencies so that they would be accessible to all components in the application that uses editors system:

    • EditorsRegistryProvider - adds supplied editors to the registry hold in React context. It supports nesting multiple EditorsRegistryProvider to allow registering custom editors specific for some component that have higher priority than the ones registered near the root of the application.
    • useEditor - hook to get the editor that should be used to edit the supplied value. First it looks for applicable editor in EditorsRegistry and if none was found it fallbacks to the default editors.
  • EditorRenderer - wrapper around EditorRegistry that provides a convenient way to render editors for specific value.

  • useCommittableValue - custom React hooks that provides a convenient way to add cancel/commit actions on Esc/Enter key click to the editor.

  • Value - type for all values that are supported by editors.

  • ValueMetadata - type for additional metadata that can be supplied to editors alongside value itself. It can be extended when implementing custom editors. (E.g. passing available choices and icons to the enum editor that is rendered as button group)

  • createEditorSpec - an utility function to that provides a convenient way to defined editor spec for typed editors.

  • PropertyRecordEditor - React component that allows to use existing PropertyRecord type with the new editors system.

Usage

Some examples of the new editors system usage.

Rendering editor and registering custom editors

Defining component that renders value editor:

function EditingComponent(props: { value: Value; metadata: ValueMetadata }) {
  const onChange = (newValue: Value) => {
    // handle value change
  };
  return (
    <EditorRenderer
      value={value}
      metadata={metadata}
      onChange={onChange}
      size="small"
    />
  );
}

Defining component that renders value editor with Commit/Cancel actions:

function EditingComponent(props: {
  initialValue: Value;
  metadata: ValueMetadata;
  onCommit: (committedValue: Value) => void;
  onCancel: () => void;
}) {
  // `onKeydown` callback returned by `useCommittableValue` will invoke `onCommit` with current value when `ENTER` is pressed or
  // `onCancel` when `ESC` key is pressed.
  // Additionally `commit` or `cancel` callback can be invoked to do commit or cancel manually.
  const { value, onChange, onKeydown, commit, cancel } = useCommittableValue({
    initialValue,
    onCommit,
    onCancel,
  });

  return (
    // cancel edit when editor is blurred
    <span onKeydown={onKeydown} onBlur={cancel}>
      <EditorRenderer
        value={value}
        metadata={metadata}
        onChange={onChange}
        commit={commit}
        cancel={cancel}
      />
    </span>
  );
}

Registering custom editors to be available through out all application:

import {
  CustomBooleanEditorSpec,
  CustomNumericEditorSpec,
} from "./customEditors";

const rootEditors: EditorSpec[] = [
  CustomBooleanEditorSpec,
  CustomNumericEditorSpec,
];

function App() {
  return (
    <EditorsRegistryProvider editors={rootEditors}>
      {/* Render whole application components tree. Components down the tree will be able to use custom editors */}
    </EditorsRegistryProvider>
  );
}

Registering custom editors that should be available only for specific component:

// setup custom editors for whole application
import {
  CustomBooleanEditorSpec,
  CustomNumericEditorSpec,
} from "./customEditors";

const rootEditors: EditorSpec[] = [
  CustomBooleanEditorSpec,
  CustomNumericEditorSpec,
];

function App() {
  return (
    <EditorsRegistryProvider editors={rootEditors}>
      <EditingComponent />
    </EditorsRegistryProvider>
  );
}
// setup custom editors for specific component
import { SpecialNumericEditorSpec } from "./specialEditors";

const customEditors: EditorSpec[] = [SpecialNumericEditorSpec];

function EditingComponent(props: EditingComponentProps) {
  return (
    <EditorsRegistryProvider editors={customEditors}>
      {/* SpecialNumericEditorSpec has higher priority than CustomBooleanEditorSpec and CustomNumericEditorSpec registered at the root of application */}
      <EditorRenderer {...props} />
    </EditorsRegistryProvider>
  );
}

Defining custom editors

The goal of the new editors system is to remove the need for static editor registration and provide more convenient API for implementing custom editors. Current API has quite a lot optional properties that do not make sense (propertyRecord is optional but if it is undefined there is no way to figure out what to render):

Custom editor using old editor system and react class components:

interface CustomBooleanEditorState {
  currentValue: boolean;
}

class CustomBooleanEditor
  extends React.PureComponent<PropertyEditorProps, CustomBooleanEditorState>
  implements TypeEditor
{
  private _inputElement = React.createRef<HTMLInputElement>();
  public override readonly state: Readonly<CustomBooleanEditorState> = {
    currentValue: false,
  };

  public async getPropertyValue(): Promise<PropertyValue | undefined> {
    // this is an optional prop for some reason.
    const record = this.props.propertyRecord;
    let propertyValue: PropertyValue | undefined;

    if (record && record.value.valueFormat === PropertyValueFormat.Primitive) {
      propertyValue = {
        valueFormat: PropertyValueFormat.Primitive,
        value: this.state.currentValue,
        displayValue: "",
      };
    }

    return propertyValue;
  }

  public get htmlElement(): HTMLElement | null {
    return this._inputElement.current;
  }

  public get hasFocus(): boolean {
    return document.activeElement === this._inputElement.current;
  }

  public override componentDidUpdate() {
    const { propertyRecord } = this.props;
    if (
      propertyRecord &&
      propertyRecord.value.valueFormat === PropertyValueFormat.Primitive
    ) {
      this.setState({ currentValue: propertyRecord.value.value as boolean });
    }
    this.setState({ currentValue: false });
  }

  public override render() {
    return (
      <ToggleSwitch
        ref={this._inputElement}
        checked={this.state.currentValue}
        onChange={(e) => {
          const newValue = e.currentTarget.checked;
          this.setState({ currentValue: newValue }, () => {
            if (!this.props.propertyRecord || !this.props.onCommit) return;
            this.props.onCommit({
              propertyRecord: this.props.propertyRecord,
              newValue: {
                valueFormat: PropertyValueFormat.Primitive,
                value: newValue,
                displayValue: "",
              },
            });
          });
        }}
      />
    );
  }
}

class CustomBooleanPropertyEditor extends PropertyEditorBase {
  public get reactNode(): React.ReactNode {
    return <CustomBooleanEditor />;
  }
}

Custom editor using old system and react functional components:

const CustomBooleanEditor = React.forwardRef<TypeEditor, PropertyEditorProps>(
  (props, ref) => {
    const inputRef = React.useRef<HTMLInputElement>(null);
    const getCurrentValue = () => {
      if (
        props.propertyRecord &&
        props.propertyRecord.value.valueFormat === PropertyValueFormat.Primitive
      ) {
        return props.propertyRecord.value.value as boolean;
      }
      return false;
    };
    const currentValue = getCurrentValue();

    React.useImperativeHandle(
      ref,
      () => ({
        getPropertyValue: async () => {
          let propertyValue: PropertyValue | undefined;
          if (
            props.propertyRecord &&
            props.propertyRecord.value.valueFormat ===
              PropertyValueFormat.Primitive
          ) {
            propertyValue = {
              valueFormat: PropertyValueFormat.Primitive,
              value: currentValue,
              displayValue: "",
            };
          }
          return propertyValue;
        },
        htmlElement: inputRef.current,
        hasFocus: document.activeElement === inputRef.current,
      }),
      [currentValue, props.propertyRecord]
    );

    return (
      <ToggleSwitch
        ref={inputRef}
        checked={currentValue}
        onChange={(e) => {
          if (!props.propertyRecord || !props.onCommit) return;
          props.onCommit({
            propertyRecord: props.propertyRecord,
            newValue: {
              valueFormat: PropertyValueFormat.Primitive,
              value: e.target.checked,
              displayValue: "",
            },
          });
        }}
      />
    );
  }
);

export class CustomBooleanPropertyEditor extends PropertyEditorBase {
  public get reactNode(): React.ReactNode {
    return <CustomBooleanEditor />;
  }
}

Custom boolean editor using new system:

export const CustomBoolEditorSpec: EditorSpec = createEditorSpec({
  isMetadataSupported: (metadata): metadata is ValueMetadata =>
    metadata.type === "bool",
  isValueSupported: Value.isBoolean,
  Editor: CustomBooleanEditor,
});

export function CustomBooleanEditor(
  props: EditorProps<ValueMetadata, BooleanValue>
) {
  const currentValue = props.value?.value ?? false;
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newValue = { value: e.target.checked };
    props.onChange(newValue);
    props.onFinish();
  };

  return <ToggleSwitch checked={currentValue} onChange={handleChange} />;
}

The new system removes all the code that was associated with class components and accessing values through editor ref. It is not clear if that was used/useful so the chosen approach is to add something similar later if that is still needed. Majority of that was used by EditorContainer that is replaced by useCommittableValue hook in the new system.

Units supports

New editors system was aimed to provide better support for units. There is base component that should help with that FormattedNumericInput. It should be easy to write an editor on top of it that would know how to find Parser/Formatter for specific unit.

TODO

  • Rewrite existing editors that support PropertyEditorParams from PropertyRecord. Need to find a way how to sunset those PropertyEditorParams in the future but in mean time if should be possible to maintain what is already there in the old system.
  • Need more work on webfont icons referenced by Tools in PropertyEditorParams. The initial approach is to maintain internal registry (iconName: string) => ReactNode that would hold currently used icons. Open for suggestions on this one.
  • Investigate more if current approach can be easily integrated with unit format overrides.
  • Do we need CommitingEditor as general solutions for committing entered values only on Enter/Blur or each components should have it's own logic to handle such workflows?
  • Add visual tests.
  • Add unit tests.
  • Deprecate old editors. Created separate issue: Deprecate old editors system #1224
  • Remove Presentation from test-app (used for debugging and testing editors feature parity)

Copy link
Collaborator

@GerardasB GerardasB left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good for the most part, however I'd really like to reduce the API surface to a minimum before merging this in (instead of thinking about all the what-if use-cases).
One more major thing to address is the addition of core-quantity peer dep.

I like the iconName -> React.ReactNode mapping for abstract iconSpec types. However, let's do this in a separate PR.

@@ -53,24 +49,6 @@ export function createEditorFrontstageProvider(): UiItemsProvider {
icon: <SvgEdit />,
}),
],
getToolbarItems: () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this removed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CreateArcTool, CreateLineStringTool are not exported from "@itwin/editor-frontend" after version bump

@@ -102,7 +102,10 @@
"@itwin/itwinui-icons-react": "^2.8.0",
"@itwin/itwinui-layouts-react": "~0.4.1",
"@itwin/itwinui-layouts-css": "~0.4.0",
"@itwin/presentation-backend": "4.4.0",
Copy link
Collaborator

@GerardasB GerardasB Dec 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these presentation dependencies necessary to showcase the new editors? Should we simply create a new frontstage for editors and use tool settings/dummy data in one of widgets?
Need to think about names, current EditorFrontstage is really for opening iModels in read-write.
(I see the comment now, that this is actually planned to be removed)

Comment on lines 17 to 19
export const editorsRegistryContext = React.createContext<EditorsRegistry>({
editors: [],
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export const editorsRegistryContext = React.createContext<EditorsRegistry>({
editors: [],
});
export const EditorsRegistryContext = React.createContext<EditorsRegistry>({
editors: [],
});
EditorsRegistryContext.displayName = "uifw:EditorsRegistryContext";

Comment on lines 30 to 34
/**
* A type that makes a specific properties required in a type.
* @beta
*/
export type RequiredProps<TProps, TKey extends keyof TProps> = Omit<
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/**
* A type that makes a specific properties required in a type.
* @beta
*/
export type RequiredProps<TProps, TKey extends keyof TProps> = Omit<
/**
* A type that makes a specific properties required in a type.
* @internal
*/
export type RequiredProps<TProps, TKey extends keyof TProps> = Omit<

onChange: (value: TValue) => void;
onFinish: () => void;
disabled?: boolean;
size?: "small" | "large";
Copy link
Collaborator

@GerardasB GerardasB Dec 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

size should probably not be controlled per editor?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those props are pass to each editor implementation by Editor (EditorContainer would be more suitable name I guess).

Copy link
Member

@grigasp grigasp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed most of the stuff in components-react and imodel-components-react.

@saskliutas saskliutas marked this pull request as ready for review February 20, 2025 13:19
@saskliutas saskliutas requested review from a team as code owners February 20, 2025 13:19
Copy link
Member

@grigasp grigasp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the legacy APIs be deprecated in this PR? Or are you planning to do that separately?

@saskliutas
Copy link
Member Author

Should the legacy APIs be deprecated in this PR? Or are you planning to do that separately?

I want to do that in separate PR. Have an issue for that #1224.

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

Successfully merging this pull request may close these issues.

4 participants