q-shift / backstage-plugins

Apache License 2.0
3 stars 7 forks source link

Use <Select/> instead of <Autocomplete/> for <QuarkusVersionList/> #59

Closed cmoulliard closed 8 months ago

cmoulliard commented 8 months ago

TODO

Use <Select/> instead of <Autocomplete/> for <QuarkusVersionList/>. The following code work as RSJS FormData is getting the version selected.

Note: When the user returns back to the screen of the <Select/> then the value they selected within the popup is not anymore selected.

import React from "react";
import FormControl from "@material-ui/core/FormControl";
import {FieldExtensionComponentProps} from "@backstage/plugin-scaffolder-react";
import {Progress, Select, SelectItem} from "@backstage/core-components";
import useAsync from 'react-use/esm/useAsync';

/* Example returned by code.quarkus.io/api/streams
{
    "javaCompatibility": {
      "recommended": 17,
      "versions": [
        17,
        21
      ]
    },
    "key": "io.quarkus.platform:3.8",
    "lts": false,
    "platformVersion": "3.8.2",
    "quarkusCoreVersion": "3.8.2",
    "recommended": true,
    "status": "FINAL"
  }
 */
export interface Version {
    key: string;
    quarkusCoreVersion: string;
    platformVersion: string;
    lts: boolean;
    recommended: boolean;
    javaCompatibility: javaCompatibility[];
    status: string;
}

export interface javaCompatibility {
    recommended: boolean;
    versions: string[];
}

function userLabel(v: Version) {
    const key = v.key.split(":")
    if (v.recommended) {
        return `${key[1]} (RECOMMENDED)`;
    } else if (v.status !== "FINAL") {
        return `${key[1]} (${v.status})`;
    } else {
        return key[1];
    }
}

// TODO: Review the logic of this code against this backstage similar example to see if we can improve it:
// https://github.com/backstage/backstage/blob/master/plugins/scaffolder/src/components/fields/EntityTagsPicker/EntityTagsPicker.tsx
export const QuarkusVersionList = (props: FieldExtensionComponentProps<string>) => {
    const {
        onChange,
        rawErrors,
        required,
        formData,
    } = props;

    const quarkusVersion: Version[] = [];
    let selected: string = '';

    const codeQuarkusUrl = 'https://code.quarkus.io';
    const apiStreamsUrl = `${codeQuarkusUrl}/api/streams`

    const {loading, value} = useAsync(async () => {
       const response = await fetch(apiStreamsUrl);
       const newData = await response.json();
       return newData;
    });

    const versionItems: SelectItem[] = quarkusVersion
        ? value?.map(i => ({label: userLabel(i), value: i.key}))
        : [{label: 'Loading...', value: 'loading'}];

    if (loading) {
        return <Progress/>;
    } else {
        return (
            <FormControl
                margin="normal"
                required={required}
                error={rawErrors?.length > 0 && !formData}
            >
                <Select
                    native
                    label="Quarkus versions"
                    onChange={s => {
                        onChange(String(Array.isArray(s) ? s[0] : s))
                        console.log(`Selected : ${s}`)
                    }}
                    disabled={quarkusVersion.length === 1}
                    selected={selected}
                    items={versionItems}
                />
            </FormControl>)
    }
}
export default QuarkusVersionList;
cmoulliard commented 8 months ago

I did some minor changes to try to set selected => formData or defaultQuarkusVersion to select the proper value when we return back to the screen of the popup but we still got as first item of the list 3.8 (RECOMMENDED)

Definition of the backstage selectComponent : https://github.com/backstage/backstage/blob/master/packages/core-components/src/components/Select/Select.tsx#L143

import React, {useState} from "react";
import FormControl from "@material-ui/core/FormControl";
import {FieldExtensionComponentProps} from "@backstage/plugin-scaffolder-react";
import {Progress, Select, SelectItem} from "@backstage/core-components";
import useAsync from 'react-use/esm/useAsync';

/* Example returned by code.quarkus.io/api/streams
{
    "javaCompatibility": {
      "recommended": 17,
      "versions": [
        17,
        21
      ]
    },
    "key": "io.quarkus.platform:3.8",
    "lts": false,
    "platformVersion": "3.8.2",
    "quarkusCoreVersion": "3.8.2",
    "recommended": true,
    "status": "FINAL"
  }
 */
export interface Version {
    key: string;
    quarkusCoreVersion: string;
    platformVersion: string;
    lts: boolean;
    recommended: boolean;
    javaCompatibility: javaCompatibility[];
    status: string;
}

export interface javaCompatibility {
    recommended: boolean;
    versions: string[];
}

function userLabel(v: Version) {
    const key = v.key.split(":")
    if (v.recommended) {
        return `${key[1]} (RECOMMENDED)`;
    } else if (v.status !== "FINAL") {
        return `${key[1]} (${v.status})`;
    } else {
        return key[1];
    }
}

export const QuarkusVersionList = (props: FieldExtensionComponentProps<string>) => {
    const {
        onChange,
        rawErrors,
        required,
        formData,
    } = props;

    const [quarkusVersion, setQuarkusVersion] = useState<Version[]>([]);
    let selected: string = '';

    const codeQuarkusUrl = 'https://code.quarkus.io';
    const apiStreamsUrl = `${codeQuarkusUrl}/api/streams`

    const {loading, value} = useAsync(async () => {
        const response = await fetch(apiStreamsUrl);
        const newData = await response.json();
        setQuarkusVersion(newData)

        // If formData is not undefined and null, then we assign it as the selected value
        if (formData !== undefined && formData!== null) {
            selected = formData;
            console.log(`Quarkus Version selected : ${formData}`)
        } else {
            newData.forEach((v: Version) => {
                console.log(`Version key: ${v.key}`);
                if (v.recommended) {
                    selected = v.key
                    onChange(v.key);
                    console.log(`Recommended Quarkus Version is: ${v.key}`)
                }
            });
        }
        return newData;
    });

    const versionItems: SelectItem[] = value
        ? value?.map((i: Version) => ({label: userLabel(i), value: i.key}))
        : [{label: 'Loading...', value: 'loading'}];

    if (loading) {
        return <Progress/>;
    } else {
        return (
            <FormControl
                margin="normal"
                required={required}
                error={rawErrors?.length > 0 && !formData}
            >
                <Select
                    native
                    label="Quarkus versions"
                    onChange={s => {
                        let resp = String(Array.isArray(s) ? s[0] : s)
                        onChange(resp)
                        selected = resp;
                        console.log(`Selected : ${resp}`)
                    }}
                    disabled={quarkusVersion.length === 1}
                    selected={selected}
                    items={versionItems}
                />
            </FormControl>)
    }
}
export default QuarkusVersionList;

Action: Select the template, when screen appears select 3.2

Screenshot 2024-03-21 at 18 32 05

As you can see from console the selected key is io.quarkus.platform:3.2

Action: Click on next and return back to the previous screen

Screenshot 2024-03-21 at 18 32 25

The selected version is still io.quarkus.platform:3.2 but the popup list show alway the first item 3.8 (RECOMMENDED)

Idea: @iocanel

cmoulliard commented 8 months ago

As the RepoUrlPicker (github, azure, bitbucket, gitea, etc) which is using the backstage <Select/> component is able to keep what the user selects, then we should be able (if we compare the code) to avoid the issue commented before.

// Backstage select code: https://github.com/backstage/backstage/blob/master/packages/core-components/src/components/Select/Select.tsx#L143
/*
 * Copyright 2020 The Backstage Authors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import Box from '@material-ui/core/Box';
import Checkbox from '@material-ui/core/Checkbox';
import Chip from '@material-ui/core/Chip';
import FormControl from '@material-ui/core/FormControl';
import InputBase from '@material-ui/core/InputBase';
import InputLabel from '@material-ui/core/InputLabel';
import MenuItem from '@material-ui/core/MenuItem';
import Select from '@material-ui/core/Select';
import {
  createStyles,
  makeStyles,
  Theme,
  withStyles,
} from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import React, { useEffect, useState } from 'react';

import ClosedDropdown from './static/ClosedDropdown';
import OpenedDropdown from './static/OpenedDropdown';

/** @public */
export type SelectInputBaseClassKey = 'root' | 'input';

const BootstrapInput = withStyles(
  (theme: Theme) =>
    createStyles({
      root: {
        'label + &': {
          marginTop: theme.spacing(3),
        },
        '&.Mui-focused > div[role=button]': {
          borderColor: theme.palette.primary.main,
        },
      },
      input: {
        borderRadius: theme.shape.borderRadius,
        position: 'relative',
        backgroundColor: theme.palette.background.paper,
        border: '1px solid #ced4da',
        fontSize: theme.typography.body1.fontSize,
        padding: theme.spacing(1.25, 3.25, 1.25, 1.5),
        transition: theme.transitions.create(['border-color', 'box-shadow']),
        '&:focus': {
          background: theme.palette.background.paper,
          borderRadius: theme.shape.borderRadius,
        },
      },
    }),
  { name: 'BackstageSelectInputBase' },
)(InputBase);

/** @public */
export type SelectClassKey =
  | 'formControl'
  | 'label'
  | 'chips'
  | 'chip'
  | 'checkbox'
  | 'root';

const useStyles = makeStyles(
  (theme: Theme) =>
    createStyles({
      formControl: {
        margin: theme.spacing(1, 0),
        maxWidth: 300,
      },
      label: {
        transform: 'initial',
        fontWeight: 'bold',
        fontSize: theme.typography.body2.fontSize,
        fontFamily: theme.typography.fontFamily,
        color: theme.palette.text.primary,
        '&.Mui-focused': {
          color: theme.palette.text.primary,
        },
      },
      formLabel: {
        transform: 'initial',
        fontWeight: 'bold',
        fontSize: theme.typography.body2.fontSize,
        fontFamily: theme.typography.fontFamily,
        color: theme.palette.text.primary,
        '&.Mui-focused': {
          color: theme.palette.text.primary,
        },
      },
      chips: {
        display: 'flex',
        flexWrap: 'wrap',
      },
      chip: {
        margin: 2,
      },
      checkbox: {},

      root: {
        display: 'flex',
        flexDirection: 'column',
      },
    }),
  { name: 'BackstageSelect' },
);

/** @public */
export type SelectItem = {
  label: string;
  value: string | number;
};

/** @public */
export type SelectedItems = string | string[] | number | number[];

export type SelectProps = {
  multiple?: boolean;
  items: SelectItem[];
  label: string;
  placeholder?: string;
  selected?: SelectedItems;
  onChange: (arg: SelectedItems) => void;
  triggerReset?: boolean;
  native?: boolean;
  disabled?: boolean;
  margin?: 'dense' | 'none';
};

/** @public */
export function SelectComponent(props: SelectProps) {
  const {
    multiple,
    items,
    label,
    placeholder,
    selected,
    onChange,
    triggerReset,
    native = false,
    disabled = false,
    margin,
  } = props;
  const classes = useStyles();
  const [value, setValue] = useState<SelectedItems>(
    selected || (multiple ? [] : ''),
  );
  const [isOpen, setOpen] = useState(false);

  useEffect(() => {
    setValue(multiple ? [] : '');
  }, [triggerReset, multiple]);

  useEffect(() => {
    setValue(selected || (multiple ? [] : ''));
  }, [selected, multiple]);

  const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => {
    setValue(event.target.value as SelectedItems);
    onChange(event.target.value as SelectedItems);
  };

  const handleOpen = (event: React.ChangeEvent<any>) => {
    if (disabled) {
      event.preventDefault();
      return;
    }
    setOpen(previous => {
      if (multiple && !(event.target instanceof HTMLElement)) {
        return true;
      }
      return !previous;
    });
  };

  const handleClose = () => {
    setOpen(false);
  };

  const handleDelete = (selectedValue: string | number) => () => {
    const newValue = (value as any[]).filter(chip => chip !== selectedValue);
    setValue(newValue);
    onChange(newValue);
  };

  return (
    <Box className={classes.root}>
      <FormControl className={classes.formControl}>
        <InputLabel className={classes.formLabel}>{label}</InputLabel>
        <Select
          aria-label={label}
          value={value}
          native={native}
          disabled={disabled}
          data-testid="select"
          displayEmpty
          multiple={multiple}
          margin={margin}
          onChange={handleChange}
          open={isOpen}
          onOpen={handleOpen}
          onClose={handleClose}
          input={<BootstrapInput />}
          label={label}
          renderValue={s =>
            multiple && (value as any[]).length !== 0 ? (
              <Box className={classes.chips}>
                {(s as string[]).map(selectedValue => {
                  const item = items.find(el => el.value === selectedValue);
                  return item ? (
                    <Chip
                      key={item?.value}
                      label={item?.label}
                      clickable
                      onDelete={handleDelete(selectedValue)}
                      className={classes.chip}
                    />
                  ) : (
                    false
                  );
                })}
              </Box>
            ) : (
              <Typography>
                {(value as any[]).length === 0
                  ? placeholder || ''
                  : items.find(el => el.value === s)?.label}
              </Typography>
            )
          }
          IconComponent={() =>
            !isOpen ? <ClosedDropdown /> : <OpenedDropdown />
          }
          MenuProps={{
            anchorOrigin: {
              vertical: 'bottom',
              horizontal: 'left',
            },
            transformOrigin: {
              vertical: 'top',
              horizontal: 'left',
            },
            getContentAnchorEl: null,
          }}
        >
          {placeholder && !multiple && (
            <MenuItem value={[]}>{placeholder}</MenuItem>
          )}
          {native
            ? items &&
              items.map(item => (
                <option value={item.value} key={item.value}>
                  {item.label}
                </option>
              ))
            : items &&
              items.map(item => (
                <MenuItem key={item.value} value={item.value}>
                  {multiple && (
                    <Checkbox
                      color="primary"
                      checked={(value as any[]).includes(item.value) || false}
                      className={classes.checkbox}
                    />
                  )}
                  {item.label}
                </MenuItem>
              ))}
        </Select>
      </FormControl>
    </Box>
  );
}
cmoulliard commented 8 months ago

@iocanel :-) :-) :-)

Problem fixed using FormData with some changes

import React from "react";
import FormControl from "@material-ui/core/FormControl";
import {FieldExtensionComponentProps} from "@backstage/plugin-scaffolder-react";
import {Progress, Select, SelectItem} from "@backstage/core-components";
import useAsync from 'react-use/esm/useAsync';
// import useEffectOnce from 'react-use/lib/useEffectOnce';

/* Example returned by code.quarkus.io/api/streams
{
    "javaCompatibility": {
      "recommended": 17,
      "versions": [
        17,
        21
      ]
    },
    "key": "io.quarkus.platform:3.8",
    "lts": false,
    "platformVersion": "3.8.2",
    "quarkusCoreVersion": "3.8.2",
    "recommended": true,
    "status": "FINAL"
  }
 */
export interface Version {
    key: string;
    quarkusCoreVersion: string;
    platformVersion: string;
    lts: boolean;
    recommended: boolean;
    javaCompatibility: javaCompatibility[];
    status: string;
}

export interface javaCompatibility {
    recommended: boolean;
    versions: string[];
}

function userLabel(v: Version) {
    const key = v.key.split(":")
    if (v.recommended) {
        return `${key[1]} (RECOMMENDED)`;
    } else if (v.status !== "FINAL") {
        return `${key[1]} (${v.status})`;
    } else {
        return key[1];
    }
}

// TODO: Review the logic of this code against this backstage similar example to see if we can improve it:
// https://github.com/backstage/backstage/blob/master/plugins/scaffolder/src/components/fields/EntityTagsPicker/EntityTagsPicker.tsx
export const QuarkusVersionList = (props: FieldExtensionComponentProps<string>) => {
    const {
        onChange,
        rawErrors,
        required,
        formData,
    } = props;

    const codeQuarkusUrl = 'https://code.quarkus.io';
    const apiStreamsUrl = `${codeQuarkusUrl}/api/streams`;

    const {loading, value} = useAsync(async () => {
        const response = await fetch(apiStreamsUrl);
        const newData = await response.json();
        formData !== undefined ? formData : onChange(newData[0].key)
        return newData;
    });

    const versionItems: SelectItem[] = value
        ? value?.map((i: Version) => ({label: userLabel(i), value: i.key}))
        : [{label: 'Loading...', value: 'loading'}];

    if (loading) {
        return <Progress/>;
    } else {
        return (
            <div>
                <FormControl
                    margin="normal"
                    required={required}
                    error={rawErrors?.length > 0 && !formData}
                >
                    <Select
                        native
                        label="Quarkus versions"
                        onChange={s => {
                            onChange(String(Array.isArray(s) ? s[0] : s))
                        }}
                        selected={formData}
                        items={versionItems}
                    />
                </FormControl>
            </div>)
    }
}
export default QuarkusVersionList;