Fleet UI: Update Dropdown to use react-select 5.4 and other cleanup (#24164)

This commit is contained in:
RachelElysia
2024-12-03 09:08:11 -05:00
committed by GitHub
parent 93d6c029f4
commit f6425e686c
15 changed files with 568 additions and 98 deletions
@@ -18,7 +18,7 @@ import {
import Icon from "components/Icon";
interface INumberDropdownOption extends Omit<IDropdownOption, "value"> {
export interface INumberDropdownOption extends Omit<IDropdownOption, "value"> {
value: number; // Redefine the value property to be just number
}
@@ -1,7 +1,8 @@
// Used with old react-select dropdown and
// New react-select-5 ActionsDropdown.tsx
// New react-select-5: ActionsDropdown.tsx, DropdownWrapper.tsx
.Select > .Select-menu-outer,
.actions-dropdown {
.actions-dropdown,
.react-select__option {
.is-disabled * {
color: $ui-fleet-black-50;
}
@@ -0,0 +1,62 @@
// stories/DropdownWrapper.stories.tsx
import React from "react";
import { Meta, Story } from "@storybook/react";
import DropdownWrapper, {
IDropdownWrapper,
CustomOptionType,
} from "./DropdownWrapper";
// Define metadata for the story
export default {
title: "Components/DropdownWrapper",
component: DropdownWrapper,
argTypes: {
onChange: { action: "changed" },
},
} as Meta;
// Define a template for the stories
const Template: Story<IDropdownWrapper> = (args) => (
<DropdownWrapper {...args} />
);
// Sample options to be used in the dropdown
const sampleOptions: CustomOptionType[] = [
{ label: "Option 1", value: "option1", helpText: "Help text for option 1" },
{
label: "Option 2",
value: "option2",
tooltipContent: "Tooltip for option 2",
},
{ label: "Option 3", value: "option3", isDisabled: true },
];
// Default story
export const Default = Template.bind({});
Default.args = {
options: sampleOptions,
name: "dropdown-example",
label: "Select an option",
};
// Disabled story
export const Disabled = Template.bind({});
Disabled.args = {
...Default.args,
isDisabled: true,
};
// With Help Text story
export const WithHelpText = Template.bind({});
WithHelpText.args = {
...Default.args,
helpText: "This is some help text for the dropdown",
};
// With Error story
export const WithError = Template.bind({});
WithError.args = {
...Default.args,
error: "This is an error message",
};
@@ -0,0 +1,102 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import DropdownWrapper, { CustomOptionType } from "./DropdownWrapper";
const sampleOptions: CustomOptionType[] = [
{
label: "Option 1",
value: "option1",
tooltipContent: "Tooltip 1",
helpText: "Help text 1",
},
{
label: "Option 2",
value: "option2",
tooltipContent: "Tooltip 2",
helpText: "Help text 2",
},
];
describe("DropdownWrapper Component", () => {
const mockOnChange = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
test("renders with help text", () => {
render(
<DropdownWrapper
options={sampleOptions}
value="option1"
onChange={mockOnChange}
name="test-dropdown"
label="Test Dropdown"
helpText="This is a help text."
/>
);
expect(screen.getByText(/test dropdown/i)).toBeInTheDocument();
expect(screen.getByText(/this is a help text/i)).toBeInTheDocument();
});
test("calls onChange when an option is selected", async () => {
render(
<DropdownWrapper
options={sampleOptions}
value="option1"
onChange={mockOnChange}
name="test-dropdown"
label="Test Dropdown"
placeholder="Choose option"
/>
);
// Open the dropdown
await userEvent.click(screen.getByText(/option 1/i));
// Select Option 2
await userEvent.click(screen.getByText(/option 2/i));
expect(mockOnChange).toHaveBeenCalledWith({
helpText: "Help text 2",
label: "Option 2",
tooltipContent: "Tooltip 2",
value: "option2",
});
});
test("renders error message when provided", () => {
render(
<DropdownWrapper
options={sampleOptions}
value="option1"
onChange={mockOnChange}
name="test-dropdown"
label="Test Dropdown"
error="This is an error message."
/>
);
expect(screen.getByText(/this is an error message/i)).toBeInTheDocument();
});
test("displays no options message when no options are available", async () => {
render(
<DropdownWrapper
options={[]}
value=""
onChange={mockOnChange}
name="test-dropdown"
label="Test Dropdown"
placeholder="Choose option"
/>
);
// Open dropdown
await userEvent.click(screen.getByText(/choose option/i));
expect(screen.getByText(/no results found/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,340 @@
/**
* This is a new component built off react-select 5.4
* meant to replace Dropdown.jsx built off react-select 1.3
*
* See storybook component for current functionality
*
* Prototyped on UserForm.tsx but added and tested the following:
* Options: text, disabled, option helptext, option tooltip
* Other: label text, dropdown help text, dropdown error
*/
import classnames from "classnames";
import React from "react";
import Select, {
StylesConfig,
DropdownIndicatorProps,
OptionProps,
components,
PropsValue,
SingleValue,
} from "react-select-5";
import { COLORS } from "styles/var/colors";
import { PADDING } from "styles/var/padding";
import FormField from "components/forms/FormField";
import DropdownOptionTooltipWrapper from "components/forms/fields/Dropdown/DropdownOptionTooltipWrapper";
import Icon from "components/Icon";
const getOptionBackgroundColor = (state: any) => {
return state.isSelected || state.isFocused
? COLORS["ui-vibrant-blue-10"]
: "transparent";
};
export interface CustomOptionType {
label: string;
value: string;
tooltipContent?: string;
helpText?: string;
isDisabled?: boolean;
}
export interface IDropdownWrapper {
options: CustomOptionType[];
value?: PropsValue<CustomOptionType> | string;
onChange: (newValue: SingleValue<CustomOptionType>) => void;
name: string;
className?: string;
labelClassname?: string;
error?: string;
label?: JSX.Element | string;
helpText?: JSX.Element | string;
isSearchable?: boolean;
isDisabled?: boolean;
placeholder?: string;
menuPortalTarget?: HTMLElement | null;
}
const baseClass = "dropdown-wrapper";
const DropdownWrapper = ({
options,
value,
onChange,
name,
className,
labelClassname,
error,
label,
helpText,
isSearchable,
isDisabled = false,
placeholder,
menuPortalTarget,
}: IDropdownWrapper) => {
const wrapperClassNames = classnames(baseClass, className);
const handleChange = (newValue: SingleValue<CustomOptionType>) => {
onChange(newValue);
};
// Ability to handle value of type string or CustomOptionType
const getCurrentValue = () => {
if (typeof value === "string") {
return options.find((option) => option.value === value) || null;
}
return value;
};
interface CustomOptionProps
extends Omit<OptionProps<CustomOptionType, false>, "data"> {
data: CustomOptionType;
}
const CustomOption = (props: CustomOptionProps) => {
const { data, ...rest } = props;
const optionContent = (
<div className={`${baseClass}__option`}>
{data.label}
{data.helpText && (
<span className={`${baseClass}__help-text`}>{data.helpText}</span>
)}
</div>
);
return (
<components.Option {...rest} data={data}>
{data.tooltipContent ? (
<DropdownOptionTooltipWrapper tipContent={data.tooltipContent}>
{optionContent}
</DropdownOptionTooltipWrapper>
) : (
optionContent
)}
</components.Option>
);
};
const CustomDropdownIndicator = (
props: DropdownIndicatorProps<any, false, any>
) => {
const { isFocused, selectProps } = props;
const color =
isFocused || selectProps.menuIsOpen
? "core-fleet-blue"
: "core-fleet-black";
return (
<components.DropdownIndicator
{...props}
className={`${baseClass}__indicator`}
>
<Icon
name="chevron-down"
color={color}
className={`${baseClass}__icon`}
/>
</components.DropdownIndicator>
);
};
const customStyles: StylesConfig<CustomOptionType, false> = {
container: (provided) => ({
...provided,
width: "100%",
height: "40px",
}),
control: (provided, state) => ({
...provided,
display: "flex",
flexDirection: "row",
width: "100%",
backgroundColor: COLORS["ui-off-white"],
paddingLeft: "8px", // TODO: Update to match styleguide of (16px) when updating rest of UI (8px)
paddingRight: "8px",
cursor: "pointer",
boxShadow: "none",
borderRadius: "4px",
borderColor: state.isFocused
? COLORS["core-fleet-blue"]
: COLORS["ui-fleet-black-10"],
"&:hover": {
boxShadow: "none",
borderColor: COLORS["core-fleet-blue"],
".dropdown-wrapper__single-value": {
color: COLORS["core-vibrant-blue-over"],
},
".dropdown-wrapper__indicator path": {
stroke: COLORS["core-vibrant-blue-over"],
},
},
// When tabbing
// Relies on --is-focused for styling as &:focus-visible cannot be applied
"&.dropdown-wrapper__control--is-focused": {
".dropdown-wrapper__single-value": {
color: COLORS["core-vibrant-blue-over"],
},
".dropdown-wrapper__indicator path": {
stroke: COLORS["core-vibrant-blue-over"],
},
},
...(state.isDisabled && {
".dropdown-wrapper__single-value": {
color: COLORS["ui-fleet-black-50"],
},
".dropdown-wrapper__indicator path": {
stroke: COLORS["ui-fleet-black-50"],
},
}),
"&:active": {
".dropdown-wrapper__single-value": {
color: COLORS["core-vibrant-blue-down"],
},
".dropdown-wrapper__indicator path": {
stroke: COLORS["core-vibrant-blue-down"],
},
},
...(state.menuIsOpen && {
".dropdown-wrapper__indicator svg": {
transform: "rotate(180deg)",
transition: "transform 0.25s ease",
},
}),
}),
singleValue: (provided) => ({
...provided,
fontSize: "16px",
margin: 0,
padding: 0,
}),
dropdownIndicator: (provided) => ({
...provided,
display: "flex",
padding: "2px",
svg: {
transition: "transform 0.25s ease",
},
}),
menu: (provided) => ({
...provided,
boxShadow: "0 2px 6px rgba(0, 0, 0, 0.1)",
borderRadius: "4px",
zIndex: 6,
overflow: "hidden",
border: 0,
marginTop: 0,
maxHeight: "none",
position: "absolute",
left: "0",
animation: "fade-in 150ms ease-out",
}),
menuList: (provided) => ({
...provided,
padding: PADDING["pad-small"],
}),
valueContainer: (provided) => ({
...provided,
padding: 0,
}),
option: (provided, state) => ({
...provided,
padding: "10px 8px",
fontSize: "14px",
backgroundColor: getOptionBackgroundColor(state),
color: COLORS["core-fleet-black"],
"&:hover": {
backgroundColor: state.isDisabled
? "transparent"
: COLORS["ui-vibrant-blue-10"],
},
"&:active": {
backgroundColor: state.isDisabled
? "transparent"
: COLORS["ui-vibrant-blue-10"],
},
...(state.isDisabled && {
color: COLORS["ui-fleet-black-50"],
fontStyle: "italic",
cursor: "not-allowed",
pointerEvents: "none",
}),
// Styles for custom option
".dropdown-wrapper__option": {
display: "flex",
flexDirection: "column",
gap: "8px",
width: "100%",
},
".dropdown-wrapper__help-text": {
fontSize: "12px",
whiteSpace: "normal",
color: COLORS["ui-fleet-black-50"],
fontStyle: "italic",
},
}),
menuPortal: (base) => ({ ...base, zIndex: 999 }), // Not hidden beneath scrollable sections
noOptionsMessage: (provided) => ({
...provided,
textAlign: "left",
fontSize: "14px",
padding: "10px 8px",
}),
};
const renderLabel = () => {
const labelWrapperClasses = classnames(
`${baseClass}__label`,
labelClassname,
{
[`${baseClass}__label--error`]: !!error,
[`${baseClass}__label--disabled`]: isDisabled,
}
);
if (!label) {
return "";
}
return (
<label className={labelWrapperClasses} htmlFor={name}>
{error || label}
</label>
);
};
return (
<FormField
name={name}
label={renderLabel()}
helpText={helpText}
type="dropdown"
className={wrapperClassNames}
>
<Select<CustomOptionType, false>
classNamePrefix="react-select"
isSearchable={isSearchable}
styles={customStyles}
options={options}
components={{
Option: CustomOption,
DropdownIndicator: CustomDropdownIndicator,
IndicatorSeparator: () => null,
}}
value={getCurrentValue()}
onChange={handleChange}
isDisabled={isDisabled}
menuPortalTarget={
menuPortalTarget === undefined ? document.body : menuPortalTarget
}
noOptionsMessage={() => "No results found"}
tabIndex={isDisabled ? -1 : 0} // Ensures disabled dropdown has no keyboard accessibility
placeholder={placeholder}
/>
</FormField>
);
};
export default DropdownWrapper;
@@ -0,0 +1,14 @@
// react-select's <Select/> styles prop customizes the styling of
// the internal components and not external elements like labels
// See customStyles in DropdownWrappr.tsx
// https://react-select.com/styles
.dropdown-wrapper {
&__label {
&--error {
color: $ui-error;
}
&--disabled {
color: $ui-fleet-black-50;
}
}
}
@@ -0,0 +1 @@
export { default } from "./DropdownWrapper";
-16
View File
@@ -1,16 +0,0 @@
import PropTypes from "prop-types";
import { UserRole } from "./user";
export default PropTypes.shape({
disabled: PropTypes.bool,
label: PropTypes.string,
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
helpText: PropTypes.string,
});
export interface IRole {
disabled: boolean;
label: string;
value: UserRole;
helpText?: string;
}
@@ -1,10 +1,4 @@
import React, {
useCallback,
useContext,
useMemo,
useState,
useEffect,
} from "react";
import React, { useCallback, useContext, useMemo, useState } from "react";
import { useQuery } from "react-query";
import { Link } from "react-router";
@@ -1,7 +1,5 @@
import React from "react";
import { InjectedRouter } from "react-router";
import SandboxGate from "components/Sandbox/SandboxGate";
import SandboxMessage from "components/Sandbox/SandboxMessage";
import UsersTable from "./components/UsersTable";
const baseClass = "user-management";
@@ -17,17 +15,7 @@ const UserManagementPage = ({ router }: IUserManagementProps): JSX.Element => {
Create new users, customize user permissions, and remove users from
Fleet.
</p>
<SandboxGate
fallbackComponent={() => (
<SandboxMessage
message="User management is only available in self-managed Fleet"
utmSource="fleet-ui-users-page"
className={`${baseClass}__sandbox-message`}
/>
)}
>
<UsersTable router={router} />
</SandboxGate>
<UsersTable router={router} />
</div>
);
};
@@ -1,10 +1,9 @@
import React, { useState, useContext } from "react";
import { ITeam } from "interfaces/team";
import { UserRole } from "interfaces/user";
// ignore TS error for now until these are rewritten in ts.
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import { SingleValue } from "react-select-5";
import DropdownWrapper from "components/forms/fields/DropdownWrapper";
import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper";
import { AppContext } from "context/app";
import { roleOptions } from "../../helpers/userManagementHelpers";
@@ -13,29 +12,19 @@ interface ISelectRoleFormProps {
currentTeam?: ITeam;
teams: ITeam[];
onFormChange: (teams: ITeam[]) => void;
label: string | string[];
isApiOnly?: boolean;
}
const generateSelectedTeamData = (
allTeams: ITeam[],
updatedTeam?: any
updatedTeam?: Partial<ITeam>
): ITeam[] => {
const filtered = allTeams.map(
(teamItem): ITeam => {
const teamRole =
teamItem.id === updatedTeam?.id ? updatedTeam.role : teamItem.role;
return {
description: teamItem.description,
id: teamItem.id,
host_count: teamItem.host_count,
user_count: teamItem.user_count,
name: teamItem.name,
role: teamRole,
};
}
return allTeams.map(
(teamItem): ITeam => ({
...teamItem,
role: teamItem.id === updatedTeam?.id ? updatedTeam.role! : teamItem.role,
})
);
return filtered;
};
const SelectRoleForm = ({
@@ -43,33 +32,33 @@ const SelectRoleForm = ({
currentTeam,
teams,
onFormChange,
label,
isApiOnly,
}: ISelectRoleFormProps): JSX.Element => {
const { isPremiumTier } = useContext(AppContext);
const [selectedRole, setSelectedRole] = useState(
defaultTeamRole.toLowerCase()
);
const [selectedRole, setSelectedRole] = useState<CustomOptionType>({
value: defaultTeamRole.toLowerCase(),
label: defaultTeamRole,
});
const updateSelectedRole = (newRoleValue: UserRole) => {
const updatedTeam = { ...currentTeam };
updatedTeam.role = newRoleValue;
onFormChange(generateSelectedTeamData(teams, updatedTeam));
setSelectedRole(newRoleValue);
const updateSelectedRole = (newRoleValue: SingleValue<CustomOptionType>) => {
if (newRoleValue) {
const updatedTeam = {
...currentTeam,
role: newRoleValue.value as UserRole,
};
onFormChange(generateSelectedTeamData(teams, updatedTeam));
setSelectedRole(newRoleValue);
}
};
return (
<Dropdown
label={label}
value={selectedRole}
<DropdownWrapper
name="Team role"
options={roleOptions({ isPremiumTier, isApiOnly })}
searchable={false}
onChange={(newRoleValue: UserRole) => updateSelectedRole(newRoleValue)}
testId={`${name}-checkbox`}
value={selectedRole}
onChange={updateSelectedRole}
isSearchable={false}
/>
);
};
@@ -3,8 +3,9 @@ import React, { useState } from "react";
import { ITeam } from "interfaces/team";
import { UserRole } from "interfaces/user";
import Checkbox from "components/forms/fields/Checkbox";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import { SingleValue } from "react-select-5";
import DropdownWrapper from "components/forms/fields/DropdownWrapper";
import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper";
import { roleOptions } from "../../helpers/userManagementHelpers";
interface ITeamCheckboxListItem extends ITeam {
@@ -62,7 +63,7 @@ const generateSelectedTeamData = (
const updateFormState = (
prevTeamItems: ITeamCheckboxListItem[],
teamId: number,
newValue: UserRole | boolean | undefined
newValue: SingleValue<CustomOptionType> | boolean | undefined
): ITeamCheckboxListItem[] => {
const prevItemIndex = prevTeamItems.findIndex((item) => item.id === teamId);
const prevItem = prevTeamItems[prevItemIndex];
@@ -70,7 +71,7 @@ const updateFormState = (
if (typeof newValue === "boolean") {
prevItem.isChecked = newValue;
} else {
prevItem.role = newValue;
prevItem.role = newValue?.value as UserRole;
}
return [...prevTeamItems];
@@ -87,7 +88,7 @@ const useSelectedTeamState = (
const updateSelectedTeams = (
teamId: number,
newValue: UserRole | boolean
newValue: CustomOptionType | boolean
) => {
setTeamsFormList((prevState) => {
const updatedTeamFormList = updateFormState(prevState, teamId, newValue);
@@ -127,15 +128,15 @@ const SelectedTeamsForm = ({
>
{name}
</Checkbox>
<Dropdown
<DropdownWrapper
name={name}
value={role}
className={`${baseClass}__role-dropdown`}
options={roleOptions({ isPremiumTier: true, isApiOnly })}
searchable={false}
onChange={(newValue: UserRole) =>
updateSelectedTeams(teamItem.id, newValue)
isSearchable={false}
onChange={(newValue: SingleValue<CustomOptionType>) =>
updateSelectedTeams(teamItem.id, newValue as CustomOptionType)
}
testId={`${name}-checkbox`}
/>
</div>
);
@@ -13,7 +13,7 @@
justify-content: space-between;
.form-field--dropdown {
width: auto;
width: 154px; // Matches dropdown
}
}
@@ -370,7 +370,6 @@ const UserForm = ({
</>
) : (
<SelectRoleForm
label="Role"
currentTeam={currentTeam || formData.teams[0]}
teams={formData.teams}
defaultTeamRole={defaultTeamRole || "observer"}
@@ -1,8 +1,8 @@
import { isEqual } from "lodash";
import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper";
import { IInvite } from "interfaces/invite";
import { IUser, IUserUpdateBody, IUpdateUserFormData } from "interfaces/user";
import { IRole } from "interfaces/role";
import { IFormData } from "../components/UserForm/UserForm";
type ICurrentUserData = Pick<
@@ -10,7 +10,7 @@ type ICurrentUserData = Pick<
"global_role" | "teams" | "name" | "email" | "sso_enabled"
>;
interface IRoleOptionsParams {
export interface IRoleOptionsParams {
isPremiumTier?: boolean;
isApiOnly?: boolean;
}
@@ -58,20 +58,17 @@ const generateUpdateData = (
export const roleOptions = ({
isPremiumTier,
isApiOnly,
}: IRoleOptionsParams): IRole[] => {
const roles: IRole[] = [
}: IRoleOptionsParams): CustomOptionType[] => {
const roles: CustomOptionType[] = [
{
disabled: false,
label: "Observer",
value: "observer",
},
{
disabled: false,
label: "Maintainer",
value: "maintainer",
},
{
disabled: false,
label: "Admin",
value: "admin",
},
@@ -79,14 +76,12 @@ export const roleOptions = ({
if (isPremiumTier) {
roles.splice(1, 0, {
disabled: false,
label: "Observer+",
value: "observer_plus",
});
if (isApiOnly) {
roles.splice(3, 0, {
disabled: false,
label: "GitOps",
value: "gitops",
});