From f6425e686ce4fee38cd9f89fef87ab55101fe713 Mon Sep 17 00:00:00 2001
From: RachelElysia <71795832+RachelElysia@users.noreply.github.com>
Date: Tue, 3 Dec 2024 09:08:11 -0500
Subject: [PATCH 1/5] Fleet UI: Update Dropdown to use react-select 5.4 and
other cleanup (#24164)
---
.../TeamsDropdown/TeamsDropdown.tsx | 2 +-
.../DropdownOptionTooltipWrapper/_styles.scss | 5 +-
.../DropdownWrapper.stories.tsx | 62 ++++
.../DropdownWrapper/DropdownWrapper.tests.tsx | 102 ++++++
.../DropdownWrapper/DropdownWrapper.tsx | 340 ++++++++++++++++++
.../forms/fields/DropdownWrapper/_styles.scss | 14 +
.../forms/fields/DropdownWrapper/index.tsx | 1 +
frontend/interfaces/role.ts | 16 -
.../UsersPage/UsersPage.tsx | 8 +-
.../UserManagementPage/UserManagementPage.tsx | 14 +-
.../SelectRoleForm/SelectRoleForm.tsx | 65 ++--
.../SelectedTeamsForm/SelectedTeamsForm.tsx | 21 +-
.../components/SelectedTeamsForm/_styles.scss | 2 +-
.../components/UserForm/UserForm.tsx | 1 -
.../helpers/userManagementHelpers.ts | 13 +-
15 files changed, 568 insertions(+), 98 deletions(-)
create mode 100644 frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.stories.tsx
create mode 100644 frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tests.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 e38b1c1b13..bc59c6fb0c 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 7fcd24ad33..23bf82f070 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 0000000000..ee0d223c58
--- /dev/null
+++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.stories.tsx
@@ -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 = (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",
+};
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 0000000000..2715876f8b
--- /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
new file mode 100644
index 0000000000..51c5c3cd69
--- /dev/null
+++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx
@@ -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 | 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;
+ 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) => {
+ 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
+ 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 (
+
+ );
+ };
+
+ return (
+
+
+ );
+};
+
+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 0000000000..2a92f5ced6
--- /dev/null
+++ b/frontend/components/forms/fields/DropdownWrapper/_styles.scss
@@ -0,0 +1,14 @@
+// react-select's 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;
+ }
+ }
+}
diff --git a/frontend/components/forms/fields/DropdownWrapper/index.tsx b/frontend/components/forms/fields/DropdownWrapper/index.tsx
new file mode 100644
index 0000000000..7e83f80194
--- /dev/null
+++ b/frontend/components/forms/fields/DropdownWrapper/index.tsx
@@ -0,0 +1 @@
+export { default } from "./DropdownWrapper";
diff --git a/frontend/interfaces/role.ts b/frontend/interfaces/role.ts
deleted file mode 100644
index b645baa50d..0000000000
--- 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 63bb85c823..a60b3f0661 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";
diff --git a/frontend/pages/admin/UserManagementPage/UserManagementPage.tsx b/frontend/pages/admin/UserManagementPage/UserManagementPage.tsx
index 4f4190de78..a12c06bb7c 100644
--- a/frontend/pages/admin/UserManagementPage/UserManagementPage.tsx
+++ b/frontend/pages/admin/UserManagementPage/UserManagementPage.tsx
@@ -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.
- (
-
- )}
- >
-
-
+
);
};
diff --git a/frontend/pages/admin/UserManagementPage/components/SelectRoleForm/SelectRoleForm.tsx b/frontend/pages/admin/UserManagementPage/components/SelectRoleForm/SelectRoleForm.tsx
index a81d05df44..c0236b5f88 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";
@@ -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[] => {
- 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({
+ 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) => {
+ 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 86ffa85ee5..4f8635f995 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,15 @@ const SelectedTeamsForm = ({
>
{name}
-
- updateSelectedTeams(teamItem.id, newValue)
+ isSearchable={false}
+ onChange={(newValue: SingleValue) =>
+ updateSelectedTeams(teamItem.id, newValue as CustomOptionType)
}
- testId={`${name}-checkbox`}
/>
);
diff --git a/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/_styles.scss b/frontend/pages/admin/UserManagementPage/components/SelectedTeamsForm/_styles.scss
index 2fc392ae21..dc2629b80f 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/components/UserForm/UserForm.tsx b/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx
index 0e74b29c58..c89dc37dcc 100644
--- a/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx
+++ b/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx
@@ -370,7 +370,6 @@ const UserForm = ({
>
) : (
;
-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 a0b03b0c6eb4adbf38e1c86a06d47d9a242fe5fb Mon Sep 17 00:00:00 2001
From: RachelElysia <71795832+RachelElysia@users.noreply.github.com>
Date: Tue, 3 Dec 2024 09:09:53 -0500
Subject: [PATCH 2/5] Fleet UI: Add ModalFooter component to help handle sticky
footers (#24169)
---
frontend/components/Modal/Modal.tsx | 5 -
frontend/components/Modal/_styles.scss | 13 +-
.../ModalFooter/ModalFooter.stories.tsx | 63 ++++++++
.../components/ModalFooter/ModalFooter.tsx | 44 ++++++
frontend/components/ModalFooter/_styles.scss | 27 ++++
frontend/components/ModalFooter/index.ts | 1 +
.../RunScriptDetailsModal.tsx | 49 +++++--
.../ScriptDetailsModal/ScriptDetailsModal.tsx | 137 +++++++++++-------
.../ScriptDetailsModal/_styles.scss | 3 +-
9 files changed, 266 insertions(+), 76 deletions(-)
create mode 100644 frontend/components/ModalFooter/ModalFooter.stories.tsx
create mode 100644 frontend/components/ModalFooter/ModalFooter.tsx
create mode 100644 frontend/components/ModalFooter/_styles.scss
create mode 100644 frontend/components/ModalFooter/index.ts
diff --git a/frontend/components/Modal/Modal.tsx b/frontend/components/Modal/Modal.tsx
index d3947370a2..8049b47441 100644
--- a/frontend/components/Modal/Modal.tsx
+++ b/frontend/components/Modal/Modal.tsx
@@ -37,7 +37,6 @@ export interface IModalProps {
* */
disableClosingModal?: boolean;
className?: string;
- actionsFooter?: JSX.Element;
}
const Modal = ({
@@ -51,7 +50,6 @@ const Modal = ({
isContentDisabled = false,
disableClosingModal = false,
className,
- actionsFooter,
}: IModalProps): JSX.Element => {
useEffect(() => {
const closeWithEscapeKey = (e: KeyboardEvent) => {
@@ -127,9 +125,6 @@ const Modal = ({
)}
{children}
- {actionsFooter && (
- {actionsFooter}
- )}
);
diff --git a/frontend/components/Modal/_styles.scss b/frontend/components/Modal/_styles.scss
index ef41106731..747f6e58f2 100644
--- a/frontend/components/Modal/_styles.scss
+++ b/frontend/components/Modal/_styles.scss
@@ -25,17 +25,9 @@
&__content-wrapper {
margin-top: $pad-large;
font-size: $x-small;
- // New pattern of max height modals pushed to 4.61 with PR #24019
- overflow: visible;
.input-field {
width: 100%;
-
- &::placeholder {
- font-size: $x-small;
- font-style: italic;
- line-height: 24px;
- }
}
form .modal-cta-wrap,
@@ -142,6 +134,11 @@
}
}
+.modal-scrollable-content {
+ overflow-y: auto;
+ max-height: 705px;
+}
+
.modal-cta-wrap {
align-self: flex-end;
display: flex;
diff --git a/frontend/components/ModalFooter/ModalFooter.stories.tsx b/frontend/components/ModalFooter/ModalFooter.stories.tsx
new file mode 100644
index 0000000000..802f76f9f0
--- /dev/null
+++ b/frontend/components/ModalFooter/ModalFooter.stories.tsx
@@ -0,0 +1,63 @@
+/* eslint-disable no-alert */
+import React from "react";
+import { Meta, Story } from "@storybook/react";
+
+import Button from "components/buttons/Button";
+import Icon from "components/Icon";
+import ActionsDropdown from "components/ActionsDropdown";
+import ModalFooter from "./ModalFooter";
+
+export default {
+ title: "Components/ModalFooter",
+ component: ModalFooter,
+} as Meta;
+
+const Template: Story = (args) => (
+ >} {...args} />
+);
+
+export const Default = Template.bind({});
+Default.args = {
+ primaryButtons: (
+ <>
+ alert(`Selected action: ${value}`)}
+ placeholder="More actions"
+ isSearchable={false}
+ options={[
+ { value: "action1", label: "Action 1" },
+ { value: "action2", label: "Action 2" },
+ ]}
+ menuPlacement="top"
+ />
+
+ >
+ ),
+ secondaryButtons: (
+ <>
+
+
+ >
+ ),
+ isTopScrolling: false,
+};
+
+export const WithTopScrolling = Template.bind({});
+WithTopScrolling.args = {
+ ...Default.args,
+ isTopScrolling: true,
+};
+
+export const WithoutSecondaryButtons = Template.bind({});
+WithoutSecondaryButtons.args = {
+ primaryButtons: Default.args.primaryButtons,
+ secondaryButtons: undefined,
+ isTopScrolling: false,
+};
diff --git a/frontend/components/ModalFooter/ModalFooter.tsx b/frontend/components/ModalFooter/ModalFooter.tsx
new file mode 100644
index 0000000000..4b24cc6db0
--- /dev/null
+++ b/frontend/components/ModalFooter/ModalFooter.tsx
@@ -0,0 +1,44 @@
+import React from "react";
+import classnames from "classnames";
+import { COLORS } from "styles/var/colors";
+
+const baseClass = "modal-footer";
+
+interface IModalFooterProps {
+ primaryButtons: JSX.Element;
+ secondaryButtons?: JSX.Element;
+ className?: string;
+ /** Renders a line above action buttons to indicate scrollability */
+ isTopScrolling?: boolean;
+}
+
+const ModalFooter = ({
+ primaryButtons,
+ secondaryButtons,
+ className,
+ isTopScrolling = false,
+}: IModalFooterProps): JSX.Element => {
+ const classes = classnames(className, `${baseClass}__content-wrapper`);
+
+ return (
+
+
+ {primaryButtons}
+
+ {secondaryButtons && (
+
+ {secondaryButtons}
+
+ )}
+
+ );
+};
+
+export default ModalFooter;
diff --git a/frontend/components/ModalFooter/_styles.scss b/frontend/components/ModalFooter/_styles.scss
new file mode 100644
index 0000000000..679551e1d8
--- /dev/null
+++ b/frontend/components/ModalFooter/_styles.scss
@@ -0,0 +1,27 @@
+.modal-footer {
+ &__content-wrapper {
+ align-self: flex-end;
+ display: flex;
+ flex-direction: row-reverse;
+ padding-top: $pad-medium;
+ justify-content: space-between;
+ }
+
+ // Styles both primary-actions and secondary-actions
+ &__primary-buttons-wrapper,
+ &__secondary-buttons_wrapper {
+ display: flex;
+ justify-content: space-between;
+ gap: $pad-medium;
+ align-items: center;
+ }
+
+ // Align primary actions right if no secondary actions
+ > :last-child {
+ margin-left: auto;
+ }
+
+ .button__text-icon {
+ padding: 11px;
+ }
+}
diff --git a/frontend/components/ModalFooter/index.ts b/frontend/components/ModalFooter/index.ts
new file mode 100644
index 0000000000..6a36137070
--- /dev/null
+++ b/frontend/components/ModalFooter/index.ts
@@ -0,0 +1 @@
+export { default } from "./ModalFooter";
diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/RunScriptDetailsModal/RunScriptDetailsModal.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/components/RunScriptDetailsModal/RunScriptDetailsModal.tsx
index bbca7060f1..56dcaa2ad2 100644
--- a/frontend/pages/DashboardPage/cards/ActivityFeed/components/RunScriptDetailsModal/RunScriptDetailsModal.tsx
+++ b/frontend/pages/DashboardPage/cards/ActivityFeed/components/RunScriptDetailsModal/RunScriptDetailsModal.tsx
@@ -1,4 +1,4 @@
-import React from "react";
+import React, { useState, useEffect, useRef } from "react";
import { useQuery } from "react-query";
import scriptsAPI, { IScriptResultResponse } from "services/entities/scripts";
@@ -10,6 +10,7 @@ import Icon from "components/Icon";
import Textarea from "components/Textarea";
import DataError from "components/DataError/DataError";
import Spinner from "components/Spinner/Spinner";
+import ModalFooter from "components/ModalFooter";
const baseClass = "run-script-details-modal";
@@ -166,6 +167,17 @@ const RunScriptDetailsModal = ({
onCancel,
isHidden = false,
}: IRunScriptDetailsModalProps) => {
+ // For scrollable modal
+ const [isTopScrolling, setIsTopScrolling] = useState(false);
+ const topDivRef = useRef(null);
+ const checkScroll = () => {
+ if (topDivRef.current) {
+ const isScrolling =
+ topDivRef.current.scrollHeight > topDivRef.current.clientHeight;
+ setIsTopScrolling(isScrolling);
+ }
+ };
+
const { data, isLoading, isError } = useQuery(
["runScriptDetailsModal", scriptExecutionId],
() => {
@@ -174,6 +186,13 @@ const RunScriptDetailsModal = ({
{ refetchOnWindowFocus: false, enabled: !!scriptExecutionId }
);
+ // For scrollable modal
+ useEffect(() => {
+ checkScroll();
+ window.addEventListener("resize", checkScroll);
+ return () => window.removeEventListener("resize", checkScroll);
+ }, [data]); // Re-run when data changes
+
const renderContent = () => {
let content = <>>;
@@ -206,18 +225,24 @@ const RunScriptDetailsModal = ({
}
return (
- <>
- {content}
- >
+
+ {content}
+
);
};
const renderFooter = () => (
-
-
-
+
+ Done
+
+ }
+ />
);
return (
- {renderContent()}
+ <>
+ {renderContent()}
+ {renderFooter()}
+ >
);
};
diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/ScriptDetailsModal.tsx b/frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/ScriptDetailsModal.tsx
index ca13c7d3de..2664445127 100644
--- a/frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/ScriptDetailsModal.tsx
+++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptDetailsModal/ScriptDetailsModal.tsx
@@ -1,4 +1,10 @@
-import React, { useCallback, useContext, ReactNode } from "react";
+import React, {
+ useCallback,
+ useContext,
+ useRef,
+ useState,
+ useEffect,
+} from "react";
import { format } from "date-fns";
import {
useQuery,
@@ -15,6 +21,7 @@ import { IHostScript } from "interfaces/script";
import { IApiError, getErrorReason } from "interfaces/errors";
import Modal from "components/Modal";
+import ModalFooter from "components/ModalFooter";
import Button from "components/buttons/Button";
import Spinner from "components/Spinner";
import Icon from "components/Icon";
@@ -68,6 +75,17 @@ const ScriptDetailsModal = ({
isHidden = false,
onClickRunDetails,
}: IScriptDetailsModalProps) => {
+ // For scrollable modal
+ const [isTopScrolling, setIsTopScrolling] = useState(false);
+ const topDivRef = useRef(null);
+ const checkScroll = () => {
+ if (topDivRef.current) {
+ const isScrolling =
+ topDivRef.current.scrollHeight > topDivRef.current.clientHeight;
+ setIsTopScrolling(isScrolling);
+ }
+ };
+
const { currentUser } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
@@ -87,6 +105,14 @@ const ScriptDetailsModal = ({
enabled: !selectedScriptContent && !!selectedScriptDetails?.script_id,
}
);
+
+ // For scrollable modal
+ useEffect(() => {
+ checkScroll();
+ window.addEventListener("resize", checkScroll);
+ return () => window.removeEventListener("resize", checkScroll);
+ }, [scriptContent]); // Re-run when data changes
+
const getScriptContent = async () => {
try {
const content = selectedScriptContent || scriptContent;
@@ -157,60 +183,64 @@ const ScriptDetailsModal = ({
]
);
- const shouldShowFooter = () => {
- return !isLoadingScriptContent && selectedScriptDetails !== undefined;
- };
+ const shouldShowFooter =
+ !isLoadingScriptContent && selectedScriptDetails !== undefined;
const renderFooter = () => {
if (!shouldShowFooter) {
- return <>>;
+ return null;
}
return (
- <>
-
-
-
-
-
- {showHostScriptActions && selectedScriptDetails && (
-
-
- onSelectMoreActions(
- value,
+
+
+
+ >
+ }
+ primaryButtons={
+ <>
+ {showHostScriptActions && selectedScriptDetails && (
+
+
+ onSelectMoreActions(
+ value,
+ selectedScriptDetails as IHostScript
+ )
+ }
+ placeholder="More actions"
+ isSearchable={false}
+ options={generateActionDropdownOptions(
+ currentUser,
+ hostTeamId || null,
selectedScriptDetails as IHostScript
- )
- }
- placeholder="More actions"
- isSearchable={false}
- options={generateActionDropdownOptions(
- currentUser,
- hostTeamId || null,
- selectedScriptDetails as IHostScript
- )}
- menuPlacement="top"
- />
-
- )}
-
-
- >
+ )}
+ menuPlacement="top"
+ />
+
+ )}
+
+ >
+ }
+ />
);
};
@@ -224,7 +254,10 @@ const ScriptDetailsModal = ({
}
return (
-