backstage / backstage

Backstage is an open framework for building developer portals
https://backstage.io/
Apache License 2.0
27.58k stars 5.85k forks source link

🚀 Feature: Ability to search allowedOwners in RepoUrlPicker #15007

Closed cell-kibet closed 1 year ago

cell-kibet commented 1 year ago

🔖 Feature description

Expected Behavior

First of all, amazing work you guys are doing! When multiple allowedOwners are in the template, the user has to scroll through a long list before selecting one while filling out the rest of the template form. The user would like to search and easily pick one from the long list.

Actual Behavior

Searching for one value of GitLab allowedOwners for the RepoUrlPicker I have a very long list of GitLab allowedOwners for the RepoUrlPicker and requesting if a search feature can be added to quicken picking of GitLab groups.

Steps to Reproduce

Use a template with RepoUrlPicker containing multiple allowedOwners

- title: Choose a location
  required:
    - repoUrl
  properties:
    repoUrl:
      title: Repository Location
      type: string
      ui:field: RepoUrlPicker
      ui:options:
        allowedHosts:
          - gitlab.com
        allowedOwners:
          - some-gitlab-owner-A
          - some-gitlab-owner-B
          - some-gitlab-owner-C
          - some-gitlab-owner-D
          - some-gitlab-owner-E
          - some-gitlab-owner-F
          - some-gitlab-owner-G
          - some-gitlab-owner-H
          - some-gitlab-owner-I
          - some-gitlab-owner-J
          - some-gitlab-owner-K
        requestUserCredentials:
          secretsKey: USER_OAUTH_TOKEN

Ability to search the allowedOwners during creation of template

🎤 Context

In case there is a very long list of allowedOwners, it is cumbersome to scroll theough the number of options or list applied on the Template's RepoUrlPicker allowedOwners. A filter feature would really be useful to easily pick out one or two from the long drop-down list.

Your Environment

This behaviour happens in Safari and Firefox, though I mainly work in Firefox.

Output of yarn backstage-cli info: yarn run v1.22.19

OS: Darwin 22.1.0 - darwin/arm64 node: v16.17.1 yarn: 1.22.19 cli: 0.21.1 (installed) backstage: 1.8.2

Dependencies: @backstage/app-defaults 1.0.8 @backstage/backend-common 0.16.0 @backstage/backend-plugin-api 0.1.4 @backstage/backend-tasks 0.3.7 @backstage/catalog-client 1.1.2 @backstage/catalog-model 1.1.3 @backstage/cli-common 0.1.10 @backstage/cli 0.21.1 @backstage/config-loader 1.1.6 @backstage/config 1.0.4 @backstage/core-app-api 1.2.0 @backstage/core-components 0.12.0, 0.9.5 @backstage/core-plugin-api 1.1.0 @backstage/errors 1.1.3 @backstage/integration-react 1.1.6 @backstage/integration 1.4.0 @backstage/plugin-api-docs 0.8.11 @backstage/plugin-app-backend 0.3.38 @backstage/plugin-auth-backend 0.17.1 @backstage/plugin-auth-node 0.2.7 @backstage/plugin-catalog-backend-module-gitlab 0.1.9 @backstage/plugin-catalog-backend 1.5.1 @backstage/plugin-catalog-common 1.0.8 @backstage/plugin-catalog-graph 0.2.23 @backstage/plugin-catalog-import 0.9.1 @backstage/plugin-catalog-node 1.2.1 @backstage/plugin-catalog-react 1.2.1 @backstage/plugin-catalog 1.6.1 @backstage/plugin-github-actions 0.5.11 @backstage/plugin-kubernetes-backend 0.8.0 @backstage/plugin-kubernetes-common 0.4.4 @backstage/plugin-kubernetes 0.7.4 @backstage/plugin-newrelic 0.3.29 @backstage/plugin-org 0.6.0 @backstage/plugin-permission-common 0.7.1 @backstage/plugin-permission-node 0.7.1 @backstage/plugin-permission-react 0.4.7 @backstage/plugin-proxy-backend 0.2.32 @backstage/plugin-scaffolder-backend 1.8.0 @backstage/plugin-scaffolder-common 1.2.2 @backstage/plugin-scaffolder 1.8.0 @backstage/plugin-search-backend-module-pg 0.4.2 @backstage/plugin-search-backend-node 1.0.4 @backstage/plugin-search-backend 1.1.1 @backstage/plugin-search-common 1.1.1 @backstage/plugin-search-react 1.2.1 @backstage/plugin-search 1.0.4 @backstage/plugin-sonarqube-backend 0.1.3 @backstage/plugin-sonarqube 0.5.0 @backstage/plugin-tech-radar 0.5.18 @backstage/plugin-techdocs-backend 1.4.1 @backstage/plugin-techdocs-module-addons-contrib 1.0.6 @backstage/plugin-techdocs-node 1.4.2 @backstage/plugin-techdocs-react 1.0.6 @backstage/plugin-techdocs 1.4.0 @backstage/plugin-user-settings 0.5.1 @backstage/release-manifests 0.0.7 @backstage/test-utils 1.2.2 @backstage/theme 0.2.16 @backstage/types 1.0.1 @backstage/version-bridge 1.0.2

✌️ Possible Implementation

Enable ability to search a word from the drop-down list generated

👀 Have you spent some time to check if this feature request has been raised before?

🏢 Have you read the Code of Conduct?

Are you willing to submit PR?

None

freben commented 1 year ago

Am I understanding it right, that you refer primarily to essentially a little edit box that when you start typing, it filters the possible hits to those that match what's entered? That makes sense! Would you be able to contribute that? 🙏

cell-kibet commented 1 year ago

Yes, that's right.. kindly point me in the right direction please..

jhaals commented 1 year ago

Could this be potentially related or fixed by #15063?

benjdlambert commented 1 year ago

Hmm I think that this is more that the dropdown would be replaced with the Autocomplete component from @material-ui/lab instead, so that it has a list of options that you can choose from. That wouldn't be fixed with #15063 but it's interesting that it could also be powered by an Autocomplete

cell-kibet commented 1 year ago

That is absolutely right @benjdlambert ..

benjdlambert commented 1 year ago

Seems like a nice addition, is this something you would like to contribute @cell-kibet?

cell-kibet commented 1 year ago

Let me go through the code and docs and give it a try. Any input and direction is welcome

benjdlambert commented 1 year ago

I think this is the component that you want to use instead: https://v4.mui.com/components/autocomplete

And I think for GitLab you'd want to edit this file: https://github.com/backstage/backstage/blob/master/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx#L46-L57 and replace that Select component with a Autocomplete component.

It might be possible to always render an Autocomplete but turn off freeSolo if there is allowedOwners so that you can freely type something like the Input does underneath. But feel free to experiment.

Once you have something working, the allowedOwners prop is also used in the other files too that you could probably update pretty safely, like the GitHub Azure and Bitbucket ones too. Or wherever allowedOwners is used. :)

cell-kibet commented 1 year ago

Seems GitlabRepoPicker.tsx imports "Select" from core-components file : https://github.com/backstage/backstage/blob/master/packages/core-components/src/components/Select/Select.tsx which uses the @material-ui/lab Select component.

After swapping "Select" with "Autocomplete" in file: https://github.com/backstage/backstage/blob/master/packages/core-components/src/components/Select/Select.tsx, am stuck here:

import Box from '@material-ui/core/Box';
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
import FormControl from '@material-ui/core/FormControl';
import InputBase from '@material-ui/core/InputBase';
import InputLabel from '@material-ui/core/InputLabel';
import Autocomplete from '@material-ui/lab/Autocomplete';
import TextField from '@material-ui/core/TextField';
import {
  createStyles,
  makeStyles,
  Theme,
  withStyles,
} from '@material-ui/core/styles';
import React, { useEffect, useState } from 'react';

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

const BootstrapInput = withStyles(
  (theme: Theme) =>
    createStyles({
      root: {
        'label + &': {
          marginTop: theme.spacing(3),
        },
      },
      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']),
        fontFamily: 'Helvetica Neue',
        '&: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)} 0px`,
        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] = React.useState<SelectedItems>(
    selected || (multiple ? [] : ''),
  );
  const [isOpen, setOpen] = useState(false);

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

  useEffect(() => {
    if (selected !== undefined) {
      setValue(selected);
    }
  }, [selected]);

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

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

  const handleClickAway = () => {
    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}>
      <ClickAwayListener onClickAway={handleClickAway}>
        <FormControl className={classes.formControl}>
          <InputLabel className={classes.formLabel}>{label}</InputLabel>
          <Autocomplete
            aria-label={label}
            disabled={disabled}
            data-testid="select"
            multiple={multiple}
            options={items}
            onClick={handleClick}
            open={isOpen}
            tabIndex={0}
            renderInput={params => (
                <TextField
                {...params}
                label={label}
                margin="dense"
                FormHelperTextProps={{ margin: 'dense', style: { marginLeft: 0 } }}
                variant="outlined"
                InputProps={params.InputProps}

              />
            )}
            />
        </FormControl>
      </ClickAwayListener>
    </Box>
  );
}
benjdlambert commented 1 year ago

@cell-kibet so I actually think that we might not want to adjust the @backstage/core-components/Select to be an Autocomplete I think it's fine to continue using that Select component. I think that we should remove the usage of that Select component inside the picker itself and use an Autocomplete instead.

cell-kibet commented 1 year ago

Thank you @benjdlambert . Managed to get the Interface working by editing https://github.com/backstage/backstage/blob/master/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx to below:

import React from 'react';
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import Input from '@material-ui/core/Input';
import InputLabel from '@material-ui/core/InputLabel';
import { Select, SelectItem } from '@backstage/core-components';
import Autocomplete from '@material-ui/lab/Autocomplete';
import { RepoUrlPickerState } from './types';
import TextField from '@material-ui/core/TextField';

export const GitlabRepoPicker = (props: {
  allowedOwners?: string[];
  allowedRepos?: string[];
  state: RepoUrlPickerState;
  onChange: (state: RepoUrlPickerState) => void;
  rawErrors: string[];
}) => {
  const { allowedOwners = [], state, onChange, rawErrors } = props;
  const ownerItems: SelectItem[] = allowedOwners
    ? allowedOwners.map(i => ({ label: i, value: i }))
    : [{ label: 'Loading...', value: 'loading' }];

  const { owner } = state;

  return (
    <>
      <FormControl
        margin="normal"
        required
        error={rawErrors?.length > 0 && !owner}
      >
        {allowedOwners?.length ? (
          <Autocomplete
          aria-label="Owner Available"
          options={ownerItems}
          getOptionLabel={(option) => option.label || "error getting label"}
          disabled={allowedOwners.length === 1}
          data-testid="select"
          onChange={selected =>
            onChange({
              owner: String(Array.isArray(selected) ? selected[0] : selected),
            })
          }
          tabIndex={0}
          renderInput={params => (
              <TextField
              {...params}
              placeholder="Owner Available"
              margin="dense"
              FormHelperTextProps={{ margin: 'dense', style: { marginLeft: 0 } }}
              variant="outlined"
              InputProps={params.InputProps}    
            />
          )}
          />
        ) : (
          <>
            <InputLabel htmlFor="ownerInput">Owner</InputLabel>
            <Input
              id="ownerInput"
              onChange={e => onChange({ owner: e.target.value })}
              value={owner}
            />
          </>
        )}
        <FormHelperText>
          GitLab namespace where this repository will belong to. It can be the
          name of organization, group, subgroup, user, or the project.
        </FormHelperText>
      </FormControl>
    </>
  );
};

Kindly test

benjdlambert commented 1 year ago

@cell-kibet nice - do you want to raise a PR with these changes in the other places too? Like the GithubRepoUrlPicker and the other pickers that also live alongside it for the other providers too and we can do a bigger test then on the branch?

Nice work so far! :pray:

cell-kibet commented 1 year ago

@cell-kibet nice - do you want to raise a PR with these changes in the other places too? Like the GithubRepoUrlPicker and the other pickers that also live alongside it for the other providers too and we can do a bigger test then on the branch?

Nice work so far! 🙏

Hey @benjdlambert kindly add my account to the repo to push to a new branch

benjdlambert commented 1 year ago

@cell-kibet we encourage you to fork the project, and push to your own branches there and then raise a PR back to the original project :)

github-actions[bot] commented 1 year ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.