From b65f8c81ea6fd84a05a2c5392ecf87d1fea14f8c Mon Sep 17 00:00:00 2001 From: RachelElysia Date: Tue, 26 Nov 2024 09:42:51 -0500 Subject: [PATCH 1/7] Dropdown work and cleanups only --- .../TeamsDropdown/TeamsDropdown.tsx | 2 +- .../DropdownOptionTooltipWrapper/_styles.scss | 5 +- .../DropdownWrapper.stories.tsx | 105 ++++++ .../DropdownWrapper/DropdownWrapper.tsx | 325 ++++++++++++++++++ .../forms/fields/DropdownWrapper/_styles.scss | 0 .../forms/fields/DropdownWrapper/index.tsx | 1 + .../validators/validate_presence/index.ts | 2 +- frontend/interfaces/role.ts | 16 - .../UsersPage/UsersPage.tsx | 17 +- .../UserManagementPage/UserManagementPage.tsx | 14 +- .../SelectRoleForm/SelectRoleForm.tsx | 64 ++-- .../SelectedTeamsForm/SelectedTeamsForm.tsx | 22 +- .../components/SelectedTeamsForm/_styles.scss | 2 +- .../helpers/userManagementHelpers.ts | 13 +- 14 files changed, 488 insertions(+), 100 deletions(-) create mode 100644 frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.stories.tsx create mode 100644 frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx create mode 100644 frontend/components/forms/fields/DropdownWrapper/_styles.scss create mode 100644 frontend/components/forms/fields/DropdownWrapper/index.tsx delete mode 100644 frontend/interfaces/role.ts diff --git a/frontend/components/TeamsDropdown/TeamsDropdown.tsx b/frontend/components/TeamsDropdown/TeamsDropdown.tsx index e38b1c1b13f3..bc59c6fb0c67 100644 --- a/frontend/components/TeamsDropdown/TeamsDropdown.tsx +++ b/frontend/components/TeamsDropdown/TeamsDropdown.tsx @@ -18,7 +18,7 @@ import { import Icon from "components/Icon"; -interface INumberDropdownOption extends Omit { +export interface INumberDropdownOption extends Omit { value: number; // Redefine the value property to be just number } diff --git a/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/_styles.scss b/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/_styles.scss index 7fcd24ad33ed..23bf82f07066 100644 --- a/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/_styles.scss +++ b/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/_styles.scss @@ -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; } diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.stories.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.stories.tsx new file mode 100644 index 000000000000..0a2b390799f0 --- /dev/null +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.stories.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { Meta, Story } from "@storybook/react"; +import DropdownWrapper, { IDropdownWrapper } from "./DropdownWrapper"; + +export default { + title: "Components/Forms/DropdownWrapper", + component: DropdownWrapper, + argTypes: { + onChange: { action: "changed" }, + }, +} as Meta; + +const Template: Story = (args) => ( + +); + +export const Default = Template.bind({}); +Default.args = { + options: [ + { label: "Option 1", value: "observer", isDisabled: false }, + { label: "Option 2", value: "maintainer", isDisabled: false }, + { label: "Option 3", value: "admin", isDisabled: false }, + ], + value: null, + name: "default-dropdown", + label: "Default Dropdown", +}; + +export const WithSearchable = Template.bind({}); +WithSearchable.args = { + ...Default.args, + isSearchable: true, + name: "searchable-dropdown", + label: "Searchable Dropdown", +}; + +export const Disabled = Template.bind({}); +Disabled.args = { + ...Default.args, + isDisabled: true, + name: "disabled-dropdown", + label: "Disabled Dropdown", +}; + +export const WithError = Template.bind({}); +WithError.args = { + ...Default.args, + error: "This is an error message", + name: "error-dropdown", + label: "Dropdown with Error", +}; + +export const WithTooltip = Template.bind({}); +WithTooltip.args = { + options: [ + { + label: "Option 1", + value: "Observer", + helpText: "This is help text for Option 1", + isDisabled: false, + }, + { + label: "Option 2", + value: "Maintainer", + helpText: "This is help text for Option 2", + isDisabled: false, + }, + { + label: "Option 3", + value: "Admin", + helpText: "This is help text for Option 3", + isDisabled: false, + }, + ], + value: null, + name: "tooltip-dropdown", + label: "Dropdown with Tooltips", +}; + +export const WithHelpText = Template.bind({}); +WithHelpText.args = { + options: [ + { + label: "Option 1", + value: "observer", + helpText: "This is help text for Option 1", + isDisabled: false, + }, + { + label: "Option 2", + value: "maintainer", + helpText: "This is help text for Option 2", + isDisabled: false, + }, + { + label: "Option 3", + value: "admin", + helpText: "This is help text for Option 3", + isDisabled: false, + }, + ], + value: null, + name: "helptext-dropdown", + label: "Dropdown with Help Text", +}; diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx new file mode 100644 index 000000000000..2fa9261c7c85 --- /dev/null +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx @@ -0,0 +1,325 @@ +/** + * This is a new component built off react-select 5.4 + * meant to replace Dropdown.jsx built off react-select 1.3 + * + * Prototyped on UserForm.tsx but added and tested the following: + * Options: text, disabled, helptext, + * Label: text + */ + +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 | string; + onChange: (newValue: SingleValue) => void; + name: string; + className?: string; + labelClassname?: string; + error?: string; + label?: JSX.Element | string; + helpText?: JSX.Element | string; + isSearchable?: boolean; + isDisabled?: boolean; +} + +const baseClass = "dropdown-wrapper"; + +const DropdownWrapper = ({ + options, + value, + onChange, + name, + className, + labelClassname, + error, + label, + helpText, + isSearchable, + isDisabled = false, +}: IDropdownWrapper) => { + console.log("onChange", onChange); + console.log("value", value); + console.log("options", options); + const wrapperClassNames = classnames(baseClass, className); + + const handleChange = (newValue: SingleValue) => { + 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, "data"> { + data: CustomOptionType; + } + + const CustomOption = (props: CustomOptionProps) => { + const { data, ...rest } = props; + + const optionContent = ( +
+ {data.label} + {data.helpText && ( + {data.helpText} + )} +
+ ); + + return ( + + {data.tooltipContent ? ( + + {optionContent} + + ) : ( + optionContent + )} + + ); + }; + + const CustomDropdownIndicator = ( + props: DropdownIndicatorProps + ) => { + const { isFocused, selectProps } = props; + const color = + isFocused || selectProps.menuIsOpen + ? "core-fleet-blue" + : "core-fleet-black"; + + return ( + + + + ); + }; + + const customStyles: StylesConfig = { + 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 + }; + + const renderLabel = () => { + const labelWrapperClasses = classnames( + `${baseClass}__label`, + labelClassname, + { [`${baseClass}__label--error`]: !!error } + ); + + if (!label) { + return ""; + } + + return ( + + ); + }; + + return ( + + + classNamePrefix="react-select" + isSearchable={isSearchable} + styles={customStyles} + options={options} + components={{ + // Control: CustomControl, + Option: CustomOption, + DropdownIndicator: CustomDropdownIndicator, + IndicatorSeparator: () => null, + }} + value={getCurrentValue()} + onChange={handleChange} + isDisabled={isDisabled} + menuPortalTarget={document.body} + tabIndex={isDisabled ? -1 : 0} // Ensures disabled dropdown has no keyboard accessibility + /> + + ); +}; + +export default DropdownWrapper; diff --git a/frontend/components/forms/fields/DropdownWrapper/_styles.scss b/frontend/components/forms/fields/DropdownWrapper/_styles.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/frontend/components/forms/fields/DropdownWrapper/index.tsx b/frontend/components/forms/fields/DropdownWrapper/index.tsx new file mode 100644 index 000000000000..7e83f801949e --- /dev/null +++ b/frontend/components/forms/fields/DropdownWrapper/index.tsx @@ -0,0 +1 @@ +export { default } from "./DropdownWrapper"; diff --git a/frontend/components/forms/validators/validate_presence/index.ts b/frontend/components/forms/validators/validate_presence/index.ts index 9f422bef198d..2ac019f4a8c4 100644 --- a/frontend/components/forms/validators/validate_presence/index.ts +++ b/frontend/components/forms/validators/validate_presence/index.ts @@ -1,3 +1,3 @@ export default (actual: any): boolean => { - return !!actual; + return actual !== null && actual !== undefined && actual.trim() !== ""; }; diff --git a/frontend/interfaces/role.ts b/frontend/interfaces/role.ts deleted file mode 100644 index b645baa50db7..000000000000 --- a/frontend/interfaces/role.ts +++ /dev/null @@ -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; -} diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/UsersPage.tsx b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/UsersPage.tsx index 63bb85c823b8..96bb6b44f55c 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/UsersPage.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/UsersPage.tsx @@ -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"; @@ -25,7 +19,7 @@ import TableContainer from "components/TableContainer"; import TableDataError from "components/DataError"; import Spinner from "components/Spinner"; import TableCount from "components/TableContainer/TableCount"; -import CreateUserModal from "pages/admin/UserManagementPage/components/CreateUserModal"; +import AddUserModal from "pages/admin/UserManagementPage/components/AddUserModal"; import EditUserModal from "../../../UserManagementPage/components/EditUserModal"; import { IFormData, @@ -469,6 +463,9 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => { sesConfigured={sesConfigured} canUseSso={canUseSso} isSsoEnabled={userEditing?.sso_enabled} + isTwoFactorAuthenticationEnabled={ + userEditing?.two_factor_authentication_enabled + } isModifiedByGlobalAdmin={isGlobalAdmin} currentTeam={currentTeamDetails} isUpdatingUsers={isUpdatingUsers} @@ -476,8 +473,8 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => { /> )} {showCreateUserModal && currentTeamDetails && ( - { Create new users, customize user permissions, and remove users from Fleet.

- ( - - )} - > - - + ); }; diff --git a/frontend/pages/admin/UserManagementPage/components/SelectRoleForm/SelectRoleForm.tsx b/frontend/pages/admin/UserManagementPage/components/SelectRoleForm/SelectRoleForm.tsx index a81d05df4421..4ddd800a9939 100644 --- a/frontend/pages/admin/UserManagementPage/components/SelectRoleForm/SelectRoleForm.tsx +++ b/frontend/pages/admin/UserManagementPage/components/SelectRoleForm/SelectRoleForm.tsx @@ -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"; @@ -19,23 +18,14 @@ interface ISelectRoleFormProps { const generateSelectedTeamData = ( allTeams: ITeam[], - updatedTeam?: any + updatedTeam?: Partial ): 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 +33,33 @@ const SelectRoleForm = ({ currentTeam, teams, onFormChange, - label, isApiOnly, }: ISelectRoleFormProps): JSX.Element => { const { isPremiumTier } = useContext(AppContext); - const [selectedRole, setSelectedRole] = useState( - defaultTeamRole.toLowerCase() - ); - - const updateSelectedRole = (newRoleValue: UserRole) => { - const updatedTeam = { ...currentTeam }; - - updatedTeam.role = newRoleValue; + const [selectedRole, setSelectedRole] = useState({ + value: defaultTeamRole.toLowerCase(), + label: defaultTeamRole, + }); - onFormChange(generateSelectedTeamData(teams, updatedTeam)); - - setSelectedRole(newRoleValue); + const updateSelectedRole = (newRoleValue: SingleValue) => { + if (newRoleValue) { + const updatedTeam = { + ...currentTeam, + role: newRoleValue.value as UserRole, + }; + onFormChange(generateSelectedTeamData(teams, updatedTeam)); + setSelectedRole(newRoleValue); + } }; return ( - updateSelectedRole(newRoleValue)} - testId={`${name}-checkbox`} + value={selectedRole} + onChange={updateSelectedRole} + isSearchable={false} /> ); }; diff --git a/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/SelectedTeamsForm.tsx b/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/SelectedTeamsForm.tsx index 86ffa85ee5ad..5a735d5ede26 100644 --- a/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/SelectedTeamsForm.tsx +++ b/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/SelectedTeamsForm.tsx @@ -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 | 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,16 @@ const SelectedTeamsForm = ({ > {name} - - updateSelectedTeams(teamItem.id, newValue) + isSearchable={false} + onChange={(newValue: SingleValue) => + updateSelectedTeams(teamItem.id, newValue as CustomOptionType) } - testId={`${name}-checkbox`} + // testId={`${name}-checkbox`} /> ); diff --git a/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/_styles.scss b/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/_styles.scss index 2fc392ae2124..dc2629b80fd2 100644 --- a/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/_styles.scss +++ b/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/_styles.scss @@ -13,7 +13,7 @@ justify-content: space-between; .form-field--dropdown { - width: auto; + width: 154px; // Matches dropdown } } diff --git a/frontend/pages/admin/UserManagementPage/helpers/userManagementHelpers.ts b/frontend/pages/admin/UserManagementPage/helpers/userManagementHelpers.ts index 60bc3ba6c3ad..60ba9120b8a8 100644 --- a/frontend/pages/admin/UserManagementPage/helpers/userManagementHelpers.ts +++ b/frontend/pages/admin/UserManagementPage/helpers/userManagementHelpers.ts @@ -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", }); From b156b0706610cc46f250cf7ac5ab204a984bcd01 Mon Sep 17 00:00:00 2001 From: RachelElysia Date: Tue, 26 Nov 2024 09:50:11 -0500 Subject: [PATCH 2/7] Fix imports --- .../TeamDetailsWrapper/UsersPage/UsersPage.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/UsersPage.tsx b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/UsersPage.tsx index 96bb6b44f55c..a60b3f0661ed 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/UsersPage.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/UsersPage.tsx @@ -19,7 +19,7 @@ import TableContainer from "components/TableContainer"; import TableDataError from "components/DataError"; import Spinner from "components/Spinner"; import TableCount from "components/TableContainer/TableCount"; -import AddUserModal from "pages/admin/UserManagementPage/components/AddUserModal"; +import CreateUserModal from "pages/admin/UserManagementPage/components/CreateUserModal"; import EditUserModal from "../../../UserManagementPage/components/EditUserModal"; import { IFormData, @@ -463,9 +463,6 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => { sesConfigured={sesConfigured} canUseSso={canUseSso} isSsoEnabled={userEditing?.sso_enabled} - isTwoFactorAuthenticationEnabled={ - userEditing?.two_factor_authentication_enabled - } isModifiedByGlobalAdmin={isGlobalAdmin} currentTeam={currentTeamDetails} isUpdatingUsers={isUpdatingUsers} @@ -473,8 +470,8 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => { /> )} {showCreateUserModal && currentTeamDetails && ( - Date: Tue, 26 Nov 2024 09:56:16 -0500 Subject: [PATCH 3/7] More cleanup --- .../components/forms/fields/DropdownWrapper/DropdownWrapper.tsx | 2 +- .../components/SelectedTeamsForm/SelectedTeamsForm.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx index 2fa9261c7c85..75e7187a5a2a 100644 --- a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx @@ -3,7 +3,7 @@ * meant to replace Dropdown.jsx built off react-select 1.3 * * Prototyped on UserForm.tsx but added and tested the following: - * Options: text, disabled, helptext, + * Options: text, disabled, helptext, tooltip * Label: text */ diff --git a/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/SelectedTeamsForm.tsx b/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/SelectedTeamsForm.tsx index 5a735d5ede26..4f8635f995a2 100644 --- a/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/SelectedTeamsForm.tsx +++ b/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/SelectedTeamsForm.tsx @@ -137,7 +137,6 @@ const SelectedTeamsForm = ({ onChange={(newValue: SingleValue) => updateSelectedTeams(teamItem.id, newValue as CustomOptionType) } - // testId={`${name}-checkbox`} /> ); From ee88c36be439ca1d9b7819b28429d76b1f6d750d Mon Sep 17 00:00:00 2001 From: RachelElysia Date: Tue, 26 Nov 2024 10:37:14 -0500 Subject: [PATCH 4/7] Fix disabled and error label color --- .../DropdownWrapper.stories.tsx | 109 ++++++------------ .../DropdownWrapper/DropdownWrapper.tsx | 14 ++- .../forms/fields/DropdownWrapper/_styles.scss | 12 ++ .../validators/validate_presence/index.ts | 2 +- 4 files changed, 54 insertions(+), 83 deletions(-) diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.stories.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.stories.tsx index 0a2b390799f0..ee0d223c58ca 100644 --- a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.stories.tsx +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.stories.tsx @@ -1,105 +1,62 @@ +// stories/DropdownWrapper.stories.tsx + import React from "react"; import { Meta, Story } from "@storybook/react"; -import DropdownWrapper, { IDropdownWrapper } from "./DropdownWrapper"; +import DropdownWrapper, { + IDropdownWrapper, + CustomOptionType, +} from "./DropdownWrapper"; +// Define metadata for the story export default { - title: "Components/Forms/DropdownWrapper", + title: "Components/DropdownWrapper", component: DropdownWrapper, argTypes: { onChange: { action: "changed" }, }, } as Meta; +// Define a template for the stories const Template: Story = (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: [ - { label: "Option 1", value: "observer", isDisabled: false }, - { label: "Option 2", value: "maintainer", isDisabled: false }, - { label: "Option 3", value: "admin", isDisabled: false }, - ], - value: null, - name: "default-dropdown", - label: "Default Dropdown", -}; - -export const WithSearchable = Template.bind({}); -WithSearchable.args = { - ...Default.args, - isSearchable: true, - name: "searchable-dropdown", - label: "Searchable Dropdown", + options: sampleOptions, + name: "dropdown-example", + label: "Select an option", }; +// Disabled story export const Disabled = Template.bind({}); Disabled.args = { ...Default.args, isDisabled: true, - name: "disabled-dropdown", - label: "Disabled Dropdown", }; +// 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", - name: "error-dropdown", - label: "Dropdown with Error", -}; - -export const WithTooltip = Template.bind({}); -WithTooltip.args = { - options: [ - { - label: "Option 1", - value: "Observer", - helpText: "This is help text for Option 1", - isDisabled: false, - }, - { - label: "Option 2", - value: "Maintainer", - helpText: "This is help text for Option 2", - isDisabled: false, - }, - { - label: "Option 3", - value: "Admin", - helpText: "This is help text for Option 3", - isDisabled: false, - }, - ], - value: null, - name: "tooltip-dropdown", - label: "Dropdown with Tooltips", -}; - -export const WithHelpText = Template.bind({}); -WithHelpText.args = { - options: [ - { - label: "Option 1", - value: "observer", - helpText: "This is help text for Option 1", - isDisabled: false, - }, - { - label: "Option 2", - value: "maintainer", - helpText: "This is help text for Option 2", - isDisabled: false, - }, - { - label: "Option 3", - value: "admin", - helpText: "This is help text for Option 3", - isDisabled: false, - }, - ], - value: null, - name: "helptext-dropdown", - label: "Dropdown with Help Text", }; diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx index 75e7187a5a2a..f14ead0f9c59 100644 --- a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx @@ -2,9 +2,11 @@ * 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, helptext, tooltip - * Label: text + * Options: text, disabled, option helptext, option tooltip + * Other: label text, dropdown help text, dropdown error */ import classnames from "classnames"; @@ -68,9 +70,6 @@ const DropdownWrapper = ({ isSearchable, isDisabled = false, }: IDropdownWrapper) => { - console.log("onChange", onChange); - console.log("value", value); - console.log("options", options); const wrapperClassNames = classnames(baseClass, className); const handleChange = (newValue: SingleValue) => { @@ -279,7 +278,10 @@ const DropdownWrapper = ({ const labelWrapperClasses = classnames( `${baseClass}__label`, labelClassname, - { [`${baseClass}__label--error`]: !!error } + { + [`${baseClass}__label--error`]: !!error, + [`${baseClass}__label--disabled`]: isDisabled, + } ); if (!label) { diff --git a/frontend/components/forms/fields/DropdownWrapper/_styles.scss b/frontend/components/forms/fields/DropdownWrapper/_styles.scss index e69de29bb2d1..8bd7de893720 100644 --- a/frontend/components/forms/fields/DropdownWrapper/_styles.scss +++ b/frontend/components/forms/fields/DropdownWrapper/_styles.scss @@ -0,0 +1,12 @@ +// react-select's customStyles styles the internal components +// and not external elements like labels +.dropdown-wrapper { + &__label { + &--error { + color: $ui-error; + } + &--disabled { + color: $ui-fleet-black-50; + } + } +} diff --git a/frontend/components/forms/validators/validate_presence/index.ts b/frontend/components/forms/validators/validate_presence/index.ts index 2ac019f4a8c4..9f422bef198d 100644 --- a/frontend/components/forms/validators/validate_presence/index.ts +++ b/frontend/components/forms/validators/validate_presence/index.ts @@ -1,3 +1,3 @@ export default (actual: any): boolean => { - return actual !== null && actual !== undefined && actual.trim() !== ""; + return !!actual; }; From 14ae80c864f2639c9b288890cb3cdb38656bd1df Mon Sep 17 00:00:00 2001 From: RachelElysia Date: Tue, 26 Nov 2024 10:47:45 -0500 Subject: [PATCH 5/7] Update empty searchable to match current empty searchable string and styling --- .../forms/fields/DropdownWrapper/DropdownWrapper.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx index f14ead0f9c59..50cff488fc7f 100644 --- a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx @@ -272,6 +272,12 @@ const DropdownWrapper = ({ }, }), menuPortal: (base) => ({ ...base, zIndex: 999 }), // Not hidden beneath scrollable sections + noOptionsMessage: (provided) => ({ + ...provided, + textAlign: "left", + fontSize: "14px", + padding: "10px 8px", + }), }; const renderLabel = () => { @@ -318,6 +324,7 @@ const DropdownWrapper = ({ onChange={handleChange} isDisabled={isDisabled} menuPortalTarget={document.body} + noOptionsMessage={() => "No results found"} tabIndex={isDisabled ? -1 : 0} // Ensures disabled dropdown has no keyboard accessibility /> From 67dc67532b4ea011e182b2dd5218e285b5eb4aed Mon Sep 17 00:00:00 2001 From: RachelElysia Date: Mon, 2 Dec 2024 15:59:17 -0500 Subject: [PATCH 6/7] Add placeholder and tests --- .../DropdownWrapper/DropdownWrapper.tests.tsx | 102 ++++++++++++++++++ .../DropdownWrapper/DropdownWrapper.tsx | 3 + 2 files changed, 105 insertions(+) create mode 100644 frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tests.tsx diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tests.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tests.tsx new file mode 100644 index 000000000000..2715876f8ba4 --- /dev/null +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tests.tsx @@ -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( + + ); + + 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( + + ); + + // 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( + + ); + + expect(screen.getByText(/this is an error message/i)).toBeInTheDocument(); + }); + + test("displays no options message when no options are available", async () => { + render( + + ); + + // Open dropdown + await userEvent.click(screen.getByText(/choose option/i)); + + expect(screen.getByText(/no results found/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx index 50cff488fc7f..c74ec754585c 100644 --- a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx @@ -53,6 +53,7 @@ export interface IDropdownWrapper { helpText?: JSX.Element | string; isSearchable?: boolean; isDisabled?: boolean; + placeholder?: string; } const baseClass = "dropdown-wrapper"; @@ -69,6 +70,7 @@ const DropdownWrapper = ({ helpText, isSearchable, isDisabled = false, + placeholder, }: IDropdownWrapper) => { const wrapperClassNames = classnames(baseClass, className); @@ -326,6 +328,7 @@ const DropdownWrapper = ({ menuPortalTarget={document.body} noOptionsMessage={() => "No results found"} tabIndex={isDisabled ? -1 : 0} // Ensures disabled dropdown has no keyboard accessibility + placeholder={placeholder} /> ); From afdff184303a80241980d69c20c03267e97b8849 Mon Sep 17 00:00:00 2001 From: RachelElysia Date: Mon, 2 Dec 2024 16:37:27 -0500 Subject: [PATCH 7/7] Updates re: review comments --- .../forms/fields/DropdownWrapper/DropdownWrapper.tsx | 7 +++++-- .../components/forms/fields/DropdownWrapper/_styles.scss | 6 ++++-- .../components/SelectRoleForm/SelectRoleForm.tsx | 1 - .../UserManagementPage/components/UserForm/UserForm.tsx | 1 - 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx index c74ec754585c..51c5c3cd695f 100644 --- a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx @@ -54,6 +54,7 @@ export interface IDropdownWrapper { isSearchable?: boolean; isDisabled?: boolean; placeholder?: string; + menuPortalTarget?: HTMLElement | null; } const baseClass = "dropdown-wrapper"; @@ -71,6 +72,7 @@ const DropdownWrapper = ({ isSearchable, isDisabled = false, placeholder, + menuPortalTarget, }: IDropdownWrapper) => { const wrapperClassNames = classnames(baseClass, className); @@ -317,7 +319,6 @@ const DropdownWrapper = ({ styles={customStyles} options={options} components={{ - // Control: CustomControl, Option: CustomOption, DropdownIndicator: CustomDropdownIndicator, IndicatorSeparator: () => null, @@ -325,7 +326,9 @@ const DropdownWrapper = ({ value={getCurrentValue()} onChange={handleChange} isDisabled={isDisabled} - menuPortalTarget={document.body} + menuPortalTarget={ + menuPortalTarget === undefined ? document.body : menuPortalTarget + } noOptionsMessage={() => "No results found"} tabIndex={isDisabled ? -1 : 0} // Ensures disabled dropdown has no keyboard accessibility placeholder={placeholder} diff --git a/frontend/components/forms/fields/DropdownWrapper/_styles.scss b/frontend/components/forms/fields/DropdownWrapper/_styles.scss index 8bd7de893720..2a92f5ced65a 100644 --- a/frontend/components/forms/fields/DropdownWrapper/_styles.scss +++ b/frontend/components/forms/fields/DropdownWrapper/_styles.scss @@ -1,5 +1,7 @@ -// react-select's customStyles styles the internal components -// and not external elements like labels +// react-select's