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

SelectControl: improve prop types for single vs multiple selection #47390

Merged
merged 11 commits into from
Mar 7, 2023

Conversation

ciampo
Copy link
Contributor

@ciampo ciampo commented Jan 24, 2023

What?

Type the props for the SelectControl component with more details, taking advantage of the fact that that the value and onChange prop types are affected by whether the component has multiple selection enabled or not

Why?

The way SelectControl is currently typed is not precise and can lead to hacky types workarounds, like in #46721

How?

Rewrite SelectControlProps by making the result of the union of two types:

  • when multiple is true, we can assume that value (and therefore the argument of onChange) is of type string[]
  • otherwise, we can assume that value (and the onChange argument) is of type string

Testing Instructions

  • Review types
  • Make sure that SelectControl works as expected in Storybook, and that Storybook docs still look correct
  • Make sure that the component behaves as expected across the editor (i.e. in QueryControls when editing the settings of the "Latest posts" block)

@ciampo ciampo changed the title SelectControl: improve value types SelectControl: improve value and onChange prop types Jan 24, 2023
@ciampo
Copy link
Contributor Author

ciampo commented Mar 2, 2023

Hey @sirreal , I'd love to get your help on this (although it's definitely not urgent!).

What I'd like to land on, is a way for SelectControl to automatically infer whether the multiple prop is true — in that case, it should expect a value of type string[]. Otherwise, if the multiple prop is not specified (or false), the value prop should be of type string.

The onChange property should also change (its first argument should be typeof value, whatever type value is).

Is there a way to write types so that a consumer of SelectControl gets the correct narrowed types automatically? ie.

<SelectControl 
  multiple={ true }
  // This should error, but it's currently ok because at the moment `value` can be both `string` or `string[]`
  value="example"
/>

Thank you!

@ciampo ciampo force-pushed the fix/select-control-types branch from 6907390 to 43efc9f Compare March 2, 2023 15:05
@sirreal
Copy link
Member

sirreal commented Mar 2, 2023

👋 I took a look and have an example implementation to share.

Whenever we say "this needs to be this or that" we're talking about a union type. In this case we have multiple as a discriminator, giving us a discriminated union. That means that we can be one type or another type depending on multiple. This should give us all the ingredients necessary to implement this 😁

These are the important bits. We make the props we want for either side of the union, one with multiple: true and the other with an optional multiple?: false (undefined or omission also serve to discriminate to this side of the union). These two interfaces contain the types that change depending on multiple.

For convenience, we put the shared props in another interface and each side of the union extends that.

interface PropsBase {
  readonly aNumber: number;
  readonly aString: string;
}

interface PropsMultiple extends PropsBase {
  readonly multiple: true;
  readonly value: ReadonlyArray<string>;
  readonly onChange: (values: ReadonlyArray<string>) => void;
}

interface PropsSingle extends PropsBase {
  readonly multiple?: false;
  readonly value: string;
  readonly onChange: (values: string) => void;
}

type Props = PropsMultiple | PropsSingle;

Then, when we go to use the component, based on the presence or absence (truthiness) of multiple, we get the results we're looking for:

import type { FC } from 'react';

declare const Komponent: FC<Props>;

<Komponent aNumber={0} aString='a' value='a' onChange={(val:string) => val.toLowerCase()}/>;
// @ts-expect-error This has multiple but is a single string!
<Komponent aNumber={0} aString='a' multiple value='a' onChange={(val:string) => val.toLowerCase()}/>;

<Komponent aNumber={0} aString='a' multiple value={['a']} onChange={(val:readonly string[]) => val.map(s=>s.toLowerCase())}/>;
// @ts-expect-error This is an array of strings but doesn't have multiple!
<Komponent aNumber={0} aString='a'  value={['a']} onChange={(val:readonly string[]) => val.map(s=>s.toLowerCase())}/>;

@ciampo ciampo force-pushed the fix/select-control-types branch from 43efc9f to bdcf2a5 Compare March 6, 2023 17:40
@ciampo ciampo self-assigned this Mar 6, 2023
@ciampo ciampo force-pushed the fix/select-control-types branch from bdcf2a5 to bef66de Compare March 6, 2023 17:43
Comment on lines +34 to +39
const SelectControlWithState: ComponentStory< typeof SelectControl > = (
props
) => {
const [ selection, setSelection ] = useState< string[] >();

if ( props.multiple ) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This felt slightly hacky, but I couldn't figure out a much more elegant way.

Basically, since the value of SelectControl is controlled with the selection variable from internal state, I couldn't find an easy way to get TypeScript to automatically understand when selection can be used as a string and when it can be used as a string[].

Therefore, I decided to have selection to always be string[], and I've added some code to "adapt" the array to a single value for when multiple !== true.

In doing do, I also had to "override" the multiple prop passed to SelectControl to avoid a TypeScript error.

Copy link
Member

Choose a reason for hiding this comment

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

For stories this seems reasonable.

Copy link
Member

Choose a reason for hiding this comment

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

Agreed with Jon here, I believe it also makes it quite explicit and transparent what we're aiming to do, which is always useful for a story/test/example.

// `TreeSelect` inherits props from `SelectControl`, but only
// in single selection mode (ie. when the `multiple` prop is not defined).
export interface TreeSelectProps
extends Omit< SelectControlSingleSelectionProps, 'value' | 'multiple' > {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We can omit the multiple prop from TreeSelect (since it doesn't seem to be relevant to the component). This simplified the component's prop types and removes a bunch of TS errors, allowing us to remove hacks like the ones in the QueryControls component

@ciampo
Copy link
Contributor Author

ciampo commented Mar 6, 2023

Hey @sirreal , I pushed some changes to the component. The types should now work as expected, but it would be great to get some final feedback from you before I mark the PR as ready for final review.

I added some self-review comments to point out the most interesting aspects of the PR.

@ciampo ciampo added [Type] Code Quality Issues or PRs that relate to code quality [Package] Components /packages/components labels Mar 6, 2023
@ciampo ciampo changed the title SelectControl: improve value and onChange prop types SelectControl: improve props types for single vs multiple selection Mar 6, 2023
@ciampo ciampo changed the title SelectControl: improve props types for single vs multiple selection SelectControl: improve prop types for single vs multiple selection Mar 6, 2023
@github-actions
Copy link

github-actions bot commented Mar 6, 2023

Flaky tests detected in ebe1e8f.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/4354320245
📝 Reported issues:

Copy link
Member

@sirreal sirreal left a comment

Choose a reason for hiding this comment

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

I looked this over and the changes seem reasonable to me 👍

packages/components/src/select-control/index.tsx Outdated Show resolved Hide resolved
Comment on lines +34 to +39
const SelectControlWithState: ComponentStory< typeof SelectControl > = (
props
) => {
const [ selection, setSelection ] = useState< string[] >();

if ( props.multiple ) {
Copy link
Member

Choose a reason for hiding this comment

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

For stories this seems reasonable.

@ciampo ciampo force-pushed the fix/select-control-types branch from bef66de to 4a7c322 Compare March 7, 2023 10:03
@ciampo ciampo marked this pull request as ready for review March 7, 2023 10:05
@ciampo ciampo requested a review from ajitbohra as a code owner March 7, 2023 10:05
@ciampo
Copy link
Contributor Author

ciampo commented Mar 7, 2023

After solving a couple of TypeScript hurdles, this PR is now ready for review

Copy link
Member

@tyxla tyxla left a comment

Choose a reason for hiding this comment

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

Looks great 👍 Nice work unionizing the single and multiple selection scenarios 🚀

packages/components/src/select-control/types.ts Outdated Show resolved Hide resolved
@@ -39,7 +41,7 @@ function UnforwardedSelectControl(
label,
multiple = false,
onBlur = noop,
onChange = noop,
onChange,
Copy link
Member

Choose a reason for hiding this comment

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

Breaking change?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It shouldn't be, since I've added optional chaining when calling it in the file, which should have the same effect as defaulting to noop

Comment on lines +34 to +39
const SelectControlWithState: ComponentStory< typeof SelectControl > = (
props
) => {
const [ selection, setSelection ] = useState< string[] >();

if ( props.multiple ) {
Copy link
Member

Choose a reason for hiding this comment

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

Agreed with Jon here, I believe it also makes it quite explicit and transparent what we're aiming to do, which is always useful for a story/test/example.

};

const classes = classNames( 'components-select-control', className );

/* eslint-disable jsx-a11y/no-onchange */
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I realised that this rule was not relevant anymore — removing this line doesn't seem to cause any ESLint errors. Furthermore, the rule is deprecated

@ciampo ciampo enabled auto-merge (squash) March 7, 2023 12:54
@ciampo ciampo merged commit 583d748 into trunk Mar 7, 2023
@ciampo ciampo deleted the fix/select-control-types branch March 7, 2023 13:22
@github-actions github-actions bot added this to the Gutenberg 15.4 milestone Mar 7, 2023
@bph bph added the Needs Dev Note Requires a developer note for a major WordPress release cycle label Apr 4, 2023
@bph
Copy link
Contributor

bph commented Apr 4, 2023

I am not certain that there is actually an API change or if this just converted the component to typescript to improve the DX experience. Flagged for Dev note to get a mention in the Misc Editor Dev note for 6.3

@ciampo
Copy link
Contributor Author

ciampo commented Apr 4, 2023

Hey @bph , this PR only improves the types for the SelectControl props, no API changes were made. I don't think we need a dev note for this.

@bph
Copy link
Contributor

bph commented Apr 4, 2023

Thank you.

@bph bph removed the Needs Dev Note Requires a developer note for a major WordPress release cycle label Apr 4, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Package] Components /packages/components [Type] Code Quality Issues or PRs that relate to code quality
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants