diff --git a/frontend/components/TableContainer/_styles.scss b/frontend/components/TableContainer/_styles.scss index a0664fbfc4..ea00bd55e2 100644 --- a/frontend/components/TableContainer/_styles.scss +++ b/frontend/components/TableContainer/_styles.scss @@ -237,7 +237,7 @@ } .icon { - vertical-align: sub; + vertical-align: center; } } .linkToFilteredHosts__header { diff --git a/frontend/components/TargetLabelSelector/TargetLabelSelector.tsx b/frontend/components/TargetLabelSelector/TargetLabelSelector.tsx index 7d4cb30ac1..14c508fe16 100644 --- a/frontend/components/TargetLabelSelector/TargetLabelSelector.tsx +++ b/frontend/components/TargetLabelSelector/TargetLabelSelector.tsx @@ -106,6 +106,7 @@ const LabelChooser = ({ customTargetOptions = [], onSelectCustomTarget, onSelectLabel, + disableOptions, }: ILabelChooserProps) => { const getHelpText = (value?: string) => { if (dropdownHelpText) return dropdownHelpText; @@ -138,6 +139,7 @@ const LabelChooser = ({ options={customTargetOptions} searchable={false} onChange={onSelectCustomTarget} + disabled={disableOptions} /> )}
@@ -155,8 +157,10 @@ const LabelChooser = ({ value={!!selectedLabels[label.name]} onChange={onSelectLabel} parseTarget - /> -
{label.name}
+ disabled={disableOptions} + > + {label.name} +
); })} diff --git a/frontend/components/TargetLabelSelector/_styles.scss b/frontend/components/TargetLabelSelector/_styles.scss index 4f779d19c9..b7ab374c24 100644 --- a/frontend/components/TargetLabelSelector/_styles.scss +++ b/frontend/components/TargetLabelSelector/_styles.scss @@ -10,7 +10,7 @@ &__checkboxes { display: flex; - max-height: 187px; + max-height: 189px; // Fits 5 options before scrolling flex-direction: column; border-radius: $border-radius; border: 1px solid $ui-fleet-black-10; @@ -38,6 +38,6 @@ } &__label-name { - padding-left: $pad-large; + padding-left: $pad-small; } } diff --git a/frontend/components/buttons/Button/Button.tsx b/frontend/components/buttons/Button/Button.tsx index 6ec51e596e..66c1664a80 100644 --- a/frontend/components/buttons/Button/Button.tsx +++ b/frontend/components/buttons/Button/Button.tsx @@ -8,6 +8,7 @@ export type ButtonVariant = | "default" | "alert" | "pill" + | "grey-pill" | "text-link" // Underlines on hover | "text-link-dark" // underline on hover, dark text | "brand-inverse-icon" // Green icon with text, no underline on hover @@ -146,7 +147,8 @@ class Button extends React.Component { variant === "inverse" || variant === "brand-inverse-icon" || variant === "text-icon" || - variant === "pill"; + variant === "pill" || + variant === "grey-pill"; return ( + + ))} + + + } + /> + ); + }; + + const renderHeader = () => { + return ( + <> +
+ +
+ {!isLoading && !apiError && ( + <> +
+
+

+ {lastEditedQueryName} + {storedPolicy?.critical && ( + + + + )} +

+ +
+
+ + {canRunPolicy && ( + + )} + {canEditPolicy && ( + + )} +
+
+ {lastEditedQueryResolution && ( + + )} + {renderAuthor()} + {currentTeam && ( + + )} + {renderPlatforms()} + {renderLabels()} + {storedPolicy && ( + + )} + + )} + + ); + }; + + if (!isRouteOk) { + return ; + } + + return ( + + {isLoading ? : renderHeader()} + {showQueryModal && ( + setShowQueryModal(false)} + /> + )} + + ); +}; + +export default PolicyDetailsPage; diff --git a/frontend/pages/policies/PolicyDetailsPage/_styles.scss b/frontend/pages/policies/PolicyDetailsPage/_styles.scss new file mode 100644 index 0000000000..fbc817e44e --- /dev/null +++ b/frontend/pages/policies/PolicyDetailsPage/_styles.scss @@ -0,0 +1,74 @@ +.policy-details-page { + @include vertical-page-layout; + + p { + margin: 0; // Until this is an app-wide style + } + + &__title-bar { + display: flex; + justify-content: space-between; + gap: $pad-xlarge; + } + + &__name-description, + &__platform-list, + &__labels-section { + display: flex; + flex-direction: column; + gap: $pad-small; + } + + &__platform-list { + flex-direction: row; + } + + &__action-button-container { + display: flex; + justify-content: flex-end; + min-width: max-content; + gap: $pad-medium; + } + + &__policy-name { + font-size: $large; + display: flex; + align-items: center; + gap: $pad-small; + + .critical-policy-icon { + flex-shrink: 0; + } + } + + // Override DataSet 4px gap to 8px for all instances on this page + .data-set { + gap: $pad-small; + } + + &__author-info { + display: flex; + align-items: center; + gap: $pad-small; + } + + &__platform-item { + display: flex; + align-items: center; + gap: $pad-xsmall; + } + + &__labels-help-text { + margin: 0; + font-weight: $regular; + } + + &__labels-list { + display: flex; + flex-wrap: wrap; + gap: $pad-small; + list-style: none; + margin: 0; + padding: 0; + } +} diff --git a/frontend/pages/policies/PolicyDetailsPage/index.ts b/frontend/pages/policies/PolicyDetailsPage/index.ts new file mode 100644 index 0000000000..5202c898f0 --- /dev/null +++ b/frontend/pages/policies/PolicyDetailsPage/index.ts @@ -0,0 +1 @@ +export { default } from "./PolicyDetailsPage"; diff --git a/frontend/pages/policies/PolicyPage/PolicyPage.tsx b/frontend/pages/policies/PolicyPage/PolicyPage.tsx index c034e8a641..16b1f1db5a 100644 --- a/frontend/pages/policies/PolicyPage/PolicyPage.tsx +++ b/frontend/pages/policies/PolicyPage/PolicyPage.tsx @@ -144,7 +144,9 @@ const PolicyPage = ({ }; }, []); - const [step, setStep] = useState(LIVE_POLICY_STEPS[1]); + const [step, setStep] = useState( + location.hash === "#targets" ? LIVE_POLICY_STEPS[2] : LIVE_POLICY_STEPS[1] + ); const [selectedTargets, setSelectedTargets] = useState([]); const [targetedHosts, setTargetedHosts] = useState([]); const [targetedLabels, setTargetedLabels] = useState([]); diff --git a/frontend/pages/policies/PolicyPage/components/PolicyAutomations/_styles.scss b/frontend/pages/policies/PolicyPage/components/PolicyAutomations/_styles.scss index 4241c7e7c4..b0888278ce 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyAutomations/_styles.scss +++ b/frontend/pages/policies/PolicyPage/components/PolicyAutomations/_styles.scss @@ -1,5 +1,6 @@ .policy-automations { max-width: 600px; + font-size: $x-small; &__cta-card { display: flex; diff --git a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tests.tsx b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tests.tsx index 020e0500bc..bc86eb7c80 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tests.tsx +++ b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tests.tsx @@ -1,6 +1,6 @@ import React from "react"; import { screen, waitFor } from "@testing-library/react"; -import { createCustomRenderer } from "test/test-utils"; +import { createCustomRenderer, createMockRouter } from "test/test-utils"; import { http, HttpResponse } from "msw"; import mockServer from "test/mock-server"; import userEvent from "@testing-library/user-event"; @@ -42,6 +42,8 @@ const labelSummariesHandler = http.get(baseUrl("/labels/summary"), () => { describe("PolicyForm - component", () => { const defaultProps = { + router: createMockRouter(), + teamIdForApi: 3, policyIdForEdit: mockPolicy.id, showOpenSchemaActionText: false, storedPolicy: createMockPolicy({ name: "Foo" }), @@ -127,6 +129,8 @@ describe("PolicyForm - component", () => { render( { const { user } = render( { const { user } = render( { const saveButton = screen.getByRole("button", { name: "Save" }); expect(saveButton).toBeDisabled(); - const funButton = screen.getByLabelText("Fun"); + const funButton = await screen.findByRole("checkbox", { + name: "Fun", + }); expect(funButton).not.toBeChecked(); await userEvent.click(funButton); await waitFor(() => { @@ -378,7 +388,11 @@ describe("PolicyForm - component", () => { // Set a label. await userEvent.click(screen.getByLabelText("Custom")); - await userEvent.click(screen.getByLabelText("Fun")); + await userEvent.click( + await screen.findByRole("checkbox", { + name: "Fun", + }) + ); const saveButton = screen.getByRole("button", { name: "Save" }); expect(saveButton).toBeEnabled(); @@ -398,7 +412,11 @@ describe("PolicyForm - component", () => { // Set a label. await userEvent.click(screen.getByLabelText("Custom")); - await userEvent.click(screen.getByLabelText("Fun")); + await userEvent.click( + await screen.findByRole("checkbox", { + name: "Fun", + }) + ); // Click "Include any" to open the dropdown. const includeAnyOption = screen.getByRole("option", { @@ -433,7 +451,11 @@ describe("PolicyForm - component", () => { // Set a label. await userEvent.click(screen.getByLabelText("Custom")); - await userEvent.click(screen.getByLabelText("Fun")); + await userEvent.click( + await screen.findByRole("checkbox", { + name: "Fun", + }) + ); await userEvent.click(screen.getByLabelText("All hosts")); @@ -655,53 +677,6 @@ describe("PolicyForm - component", () => { ).toBeInTheDocument(); }); - it("shows 'Viewing policy' when existing policy and user has no save permissions", () => { - const render = createCustomRenderer({ - withBackendMock: true, - context: { - app: { - currentUser: createMockUser(), - currentTeam: createMockTeamSummary(), - isGlobalObserver: true, // no save perms - isGlobalAdmin: false, - isGlobalMaintainer: false, - isTeamMaintainerOrTeamAdmin: false, - isOnGlobalTeam: true, - isPremiumTier: true, - isSandboxMode: false, - isFreeTier: false, - config: createMockConfig(), - }, - policy: { - policyTeamId: undefined, - lastEditedQueryId: mockPolicy.id, - lastEditedQueryName: mockPolicy.name, - lastEditedQueryDescription: mockPolicy.description, - lastEditedQueryBody: mockPolicy.query, - lastEditedQueryResolution: mockPolicy.resolution, - lastEditedQueryCritical: mockPolicy.critical, - lastEditedQueryPlatform: mockPolicy.platform, - lastEditedQueryLabelsIncludeAny: [], - lastEditedQueryLabelsExcludeAny: [], - defaultPolicy: false, - setLastEditedQueryName: jest.fn(), - setLastEditedQueryDescription: jest.fn(), - setLastEditedQueryBody: jest.fn(), - setLastEditedQueryResolution: jest.fn(), - setLastEditedQueryCritical: jest.fn(), - setLastEditedQueryPlatform: jest.fn(), - }, - }, - }); - - render(); - - expect(screen.getByText(/Viewing policy for/i)).toBeInTheDocument(); - expect( - screen.getByText(createMockTeamSummary().name) - ).toBeInTheDocument(); - }); - it("shows 'Creating a new policy' when there is no existing policy", () => { const render = createCustomRenderer({ withBackendMock: true, diff --git a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx index 78e7c5f90c..c3e334119b 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx +++ b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx @@ -1,20 +1,21 @@ /* eslint-disable jsx-a11y/no-noninteractive-element-to-interactive-role */ /* eslint-disable jsx-a11y/interactive-supports-focus */ -import React, { useState, useContext, useEffect, KeyboardEvent } from "react"; +import React, { useState, useContext, useEffect } from "react"; import { useQuery, useQueryClient } from "react-query"; import { Ace } from "ace-builds"; import { useDebouncedCallback } from "use-debounce"; import { size } from "lodash"; -import classnames from "classnames"; +import { InjectedRouter } from "react-router"; -import { addGravatarUrlToResource } from "utilities/helpers"; import { AppContext } from "context/app"; import { NotificationContext } from "context/notification"; import { PolicyContext } from "context/policy"; import usePlatformCompatibility from "hooks/usePlatformCompatibility"; import usePlatformSelector from "hooks/usePlatformSelector"; +import PATHS from "router/paths"; import CUSTOM_TARGET_OPTIONS from "pages/policies/helpers"; +import { getPathWithQueryParams } from "utilities/url"; import { IPolicy, IPolicyFormData } from "interfaces/policy"; import { CommaSeparatedPlatformString } from "interfaces/platform"; @@ -25,22 +26,19 @@ import { LEARN_MORE_ABOUT_BASE_LINK, } from "utilities/constants"; -import Avatar from "components/Avatar"; import SQLEditor from "components/SQLEditor"; // @ts-ignore import { validateQuery } from "components/forms/validators/validate_query"; import Button from "components/buttons/Button"; -import RevealButton from "components/buttons/RevealButton"; import Checkbox from "components/forms/fields/Checkbox"; import TooltipWrapper from "components/TooltipWrapper"; import Spinner from "components/Spinner"; import Icon from "components/Icon/Icon"; -import AutoSizeInputField from "components/forms/fields/AutoSizeInputField"; -import PageDescription from "components/PageDescription"; +// @ts-ignore +import InputField from "components/forms/fields/InputField"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; import CustomLink from "components/CustomLink"; import TargetLabelSelector from "components/TargetLabelSelector"; -import DataSet from "components/DataSet"; import labelsAPI, { getCustomLabels, @@ -55,6 +53,8 @@ import PolicyAutomations from "../PolicyAutomations"; const baseClass = "policy-form"; interface IPolicyFormProps { + router: InjectedRouter; + teamIdForApi?: number; policyIdForEdit: number | null; showOpenSchemaActionText: boolean; storedPolicy: IPolicy | undefined; @@ -74,6 +74,7 @@ interface IPolicyFormProps { onClickAutofillResolution: () => Promise; resetAiAutofillData: () => void; currentAutomatedPolicies: number[]; + onCancel?: () => void; } const validateQuerySQL = (query: string) => { @@ -89,6 +90,8 @@ const validateQuerySQL = (query: string) => { }; const PolicyForm = ({ + router, + teamIdForApi, policyIdForEdit, showOpenSchemaActionText, storedPolicy, @@ -108,15 +111,13 @@ const PolicyForm = ({ onClickAutofillResolution, resetAiAutofillData, currentAutomatedPolicies, + onCancel, }: IPolicyFormProps): JSX.Element => { const [errors, setErrors] = useState<{ [key: string]: any }>({}); // string | null | undefined or boolean | undefined const [isSaveNewPolicyModalOpen, setIsSaveNewPolicyModalOpen] = useState( false ); const [showQueryEditor, setShowQueryEditor] = useState(false); - const [isEditingName, setIsEditingName] = useState(false); - const [isEditingDescription, setIsEditingDescription] = useState(false); - const [isEditingResolution, setIsEditingResolution] = useState(false); const [selectedTargetType, setSelectedTargetType] = useState("All hosts"); const [selectedCustomTarget, setSelectedCustomTarget] = useState( @@ -239,12 +240,39 @@ const PolicyForm = ({ policyIdForEdit = policyIdForEdit || 0; - const isExistingPolicy = !!policyIdForEdit; + const isEditMode = !!policyIdForEdit && !isTeamObserver && !isGlobalObserver; const isNewTemplatePolicy = - !isExistingPolicy && + !policyIdForEdit && DEFAULT_POLICIES.find((p) => p.name === lastEditedQueryName); + /* - Observer/Observer+ and Technicians cannot edit existing policies + - Team users cannot edit inherited policies + Reroute edit existing policy page (/:policyId/edit) to policy details page (/:policyId) */ + useEffect(() => { + const isInheritedPolicy = isEditMode && storedPolicy?.team_id === null; + + const noEditPermissions = + isTeamObserver || + isGlobalObserver || + isTeamTechnician || + isGlobalTechnician || + (!isOnGlobalTeam && isInheritedPolicy); // Team user viewing inherited policy + + if ( + !isStoredPolicyLoading && // Confirms teamId for storedQuery before RBAC reroute + policyIdForEdit && + policyIdForEdit > 0 && + noEditPermissions + ) { + router.push( + getPathWithQueryParams(PATHS.POLICY_DETAILS(policyIdForEdit), { + fleet_id: teamIdForApi, + }) + ); + } + }, [policyIdForEdit, isTeamMaintainerOrTeamAdmin, isStoredPolicyLoading]); + useEffect(() => { setSelectedTargetType( !lastEditedQueryLabelsIncludeAny.length && @@ -286,9 +314,6 @@ const PolicyForm = ({ setCompatiblePlatforms(lastEditedQueryBody); }, [lastEditedQueryBody, lastEditedQueryId]); - const hasSavePermissions = - isGlobalAdmin || isGlobalMaintainer || isTeamMaintainerOrTeamAdmin; - const onLoad = (editor: Ace.Editor) => { editor.setOptions({ enableLinking: true, @@ -313,16 +338,6 @@ const PolicyForm = ({ resetAiAutofillData(); // Allows retry of AI autofill API if the SQL has changed }; - const onInputKeypress = (event: KeyboardEvent) => { - if (event.key.toLowerCase() === "enter" && !event.shiftKey) { - event.preventDefault(); - event.currentTarget.blur(); - setIsEditingName(false); - setIsEditingDescription(false); - setIsEditingResolution(false); - } - }; - const onAddPatchAutomation = async () => { if ( !storedPolicy?.patch_software?.software_title_id || @@ -348,21 +363,21 @@ const PolicyForm = ({ const promptSavePolicy = () => (evt: React.MouseEvent) => { evt.preventDefault(); - if (isExistingPolicy && !lastEditedQueryName) { + if (isEditMode && !lastEditedQueryName) { return setErrors({ ...errors, name: "Policy name must be present", }); } - if (isExistingPolicy && !isPatchPolicy && !isAnyPlatformSelected) { + if (isEditMode && !isPatchPolicy && !isAnyPlatformSelected) { return setErrors({ ...errors, name: "At least one platform must be selected", }); } - if (isPatchPolicy && isExistingPolicy) { + if (isPatchPolicy && isEditMode) { // Patch policies: only send editable fields, not query/platform const payload: IPolicyFormData = { name: lastEditedQueryName, @@ -373,14 +388,11 @@ const PolicyForm = ({ payload.critical = lastEditedQueryCritical; } onUpdate(payload); - setIsEditingName(false); - setIsEditingDescription(false); - setIsEditingResolution(false); return; } let selectedPlatforms = getSelectedPlatforms(); - if (selectedPlatforms.length === 0 && !isExistingPolicy && !defaultPolicy) { + if (selectedPlatforms.length === 0 && !isEditMode && !defaultPolicy) { // If no platforms are selected, default to all compatible platforms selectedPlatforms = getCompatiblePlatforms(); setSelectedPlatforms(selectedPlatforms); @@ -394,7 +406,7 @@ const PolicyForm = ({ setLastEditedQueryPlatform(newPlatformString); } - if (!isExistingPolicy) { + if (!isEditMode) { setIsSaveNewPolicyModalOpen(true); } else { const payload: IPolicyFormData = { @@ -423,34 +435,6 @@ const PolicyForm = ({ } onUpdate(payload); } - - setIsEditingName(false); - setIsEditingDescription(false); - setIsEditingResolution(false); - }; - - const renderAuthor = (): JSX.Element | null => { - return storedPolicy ? ( - - - - {storedPolicy.author_name === currentUser?.name - ? "You" - : storedPolicy.author_name} - - - } - /> - ) : null; }; const renderLabelComponent = (): JSX.Element | null => { @@ -476,88 +460,17 @@ const PolicyForm = ({ ); }; - const editName = () => { - if (!isEditingName) { - setIsEditingName(true); - } - }; - - const editDescription = () => { - if (!isEditingDescription) { - setIsEditingDescription(true); - } - }; - - const editResolution = () => { - if (!isEditingResolution) { - setIsEditingResolution(true); - } - }; - - const policyNameWrapperBase = `${baseClass}__policy-name-wrapper`; - const policyNameWrapperClasses = classnames(policyNameWrapperBase, { - [`${baseClass}--editing`]: isEditingName, - }); - - const policyDescriptionWrapperBase = `${baseClass}__policy-description-wrapper`; - const policyDescriptionWrapperClasses = classnames( - policyDescriptionWrapperBase, - { - [`${baseClass}--editing`]: isEditingDescription, - } - ); - - const policyResolutionWrapperBase = `${baseClass}__policy-resolution-wrapper`; - const policyResolutionWrapperClasses = classnames( - policyResolutionWrapperBase, - { - [`${baseClass}--editing`]: isEditingResolution, - } - ); - const renderName = () => { - if (isExistingPolicy) { + if (isEditMode) { return ( - { - const classes = classnames(policyNameWrapperClasses, { - [`${policyNameWrapperBase}--disabled-by-gitops-mode`]: disableChildren, - }); - return ( -
setIsEditingName(true)} - onBlur={() => setIsEditingName(false)} - onClick={editName} - > - - -
- ); - }} + setLastEditedQueryName(value)} + disabled={gitOpsModeEnabled} /> ); } @@ -572,46 +485,17 @@ const PolicyForm = ({ }; const renderDescription = () => { - if (isExistingPolicy) { + if (isEditMode) { return ( - { - const classes = classnames(policyDescriptionWrapperClasses, { - [`${policyDescriptionWrapperBase}--disabled-by-gitops-mode`]: disableChildren, - }); - return ( -
setIsEditingDescription(true)} - onBlur={() => setIsEditingDescription(false)} - onClick={editDescription} - > - - -
- ); - }} + setLastEditedQueryDescription(value)} + disabled={gitOpsModeEnabled} /> ); } @@ -620,50 +504,18 @@ const PolicyForm = ({ }; const renderResolution = () => { - if (isExistingPolicy) { + if (isEditMode) { return ( -
-
Resolve
- { - const classes = classnames(policyResolutionWrapperClasses, { - [`${policyResolutionWrapperBase}--disabled-by-gitops-mode`]: disableChildren, - }); - return ( -
setIsEditingResolution(true)} - onBlur={() => setIsEditingResolution(false)} - onClick={editResolution} - > - - -
- ); - }} - /> -
+ setLastEditedQueryResolution(value)} + disabled={gitOpsModeEnabled} + /> ); } @@ -672,7 +524,7 @@ const PolicyForm = ({ const renderPlatformCompatibility = () => { if ( - isExistingPolicy && + isEditMode && (isStoredPolicyLoading || policyIdForEdit !== lastEditedQueryId) ) { return null; @@ -711,99 +563,22 @@ const PolicyForm = ({ const renderPolicyFleetName = () => { if (isFreeTier || !currentTeam?.name) return null; - if (isExistingPolicy) { - return hasSavePermissions ? ( -

- Editing policy for {currentTeam?.name}. -

- ) : ( -

- Viewing policy for {currentTeam?.name}. -

- ); - } - - return ( + return isEditMode ? ( +

+ Editing policy for {currentTeam?.name}. +

+ ) : (

Creating a new policy for {currentTeam?.name}.

); }; - // Non-editable form used for: - // - Team observers and team observer+ viewing any of their team's policies and any inherited policies - // - Team admins and team maintainers viewing any inherited policy - // - Global observers and global observer+ viewing any team's policies and any inherited policies - // - Global technicians and team technicians viewing any team's policies and any inherited policies - const renderNonEditableForm = ( -
-
-

- {lastEditedQueryName} -

- {renderAuthor()} -
- {renderPolicyFleetName()} - {lastEditedQueryDescription && ( - - )} - {lastEditedQueryResolution && ( - <> -

- Resolve: -

-

- {lastEditedQueryResolution} -

- - )} - setShowQueryEditor(!showQueryEditor)} - /> - {showQueryEditor && ( - - )} - {renderLiveQueryWarning()} - {(isObserverPlus || - isTeamMaintainerOrTeamAdmin || - isGlobalTechnician || - isTeamTechnician) && ( // Team admin, team maintainer, any Observer+ and any Technician can run a policy -
- -
- )} - - ); - - // Editable form is used for: - // Global admins and global maintainers - // Team admins and team maintainers viewing any of their team's policies - const renderEditablePolicyForm = () => { + const renderPolicyForm = () => { // Save disabled for no platforms selected, query name blank on existing query, or sql errors const disableSaveFormErrors = isAddingAutomation || - (isExistingPolicy && !isPatchPolicy && !isAnyPlatformSelected) || + (isEditMode && !isPatchPolicy && !isAnyPlatformSelected) || (lastEditedQueryName === "" && !!lastEditedQueryId) || (selectedTargetType === "Custom" && !Object.entries(selectedLabels).some(([, value]) => { @@ -814,13 +589,57 @@ const PolicyForm = ({ return ( <>
-
-
{renderName()}
- {isExistingPolicy && renderAuthor()} -
- {renderPolicyFleetName()} + {isEditMode ? ( +
+

Edit policy

+ {renderPolicyFleetName()} +
+ ) : ( +
+
+ {renderName()} + {renderPolicyFleetName()} +
+
+ )} + {isEditMode && renderName()} {renderDescription()} {renderResolution()} + {isEditMode && !isPatchPolicy && platformSelector.render()} + {isEditMode && isPremiumTier && !isPatchPolicy && ( + + Policy will target hosts on selected platforms that{" "} + have any of these labels: + + } + disableOptions={gitOpsModeEnabled} + suppressTitle + /> + )} + {isEditMode && storedPolicy && ( + + )} + {isEditMode && + isPremiumTier && + !isPatchPolicy && + renderCriticalPolicy()} {renderPlatformCompatibility()} - {isExistingPolicy && !isPatchPolicy && platformSelector.render()} - {isExistingPolicy && isPremiumTier && !isPatchPolicy && ( - - Policy will target hosts on selected platforms that{" "} - have any of these labels: - - } - suppressTitle - /> - )} - {isExistingPolicy && storedPolicy && ( - - )} - {isExistingPolicy && isPremiumTier && renderCriticalPolicy()} {renderLiveQueryWarning()}
- {hasSavePermissions && ( - ( - - Select the platforms this -
- policy will be checked on -
- to save or run the policy. - - } - tooltipClass={`${baseClass}__button-wrap--tooltip`} - position="top" - disableTooltip={!isExistingPolicy || isAnyPlatformSelected} - underline={false} - > - - - -
- )} - /> + {isEditMode && onCancel && ( + )} + ( + + Select the platforms this +
+ policy will be checked on +
+ to save or run the policy. + + } + tooltipClass={`${baseClass}__button-wrap--tooltip`} + position="top" + disableTooltip={!isEditMode || isAnyPlatformSelected} + underline={false} + > + + + +
+ )} + /> ; } - const isInheritedPolicy = isExistingPolicy && storedPolicy?.team_id === null; - - const noEditPermissions = - isTeamObserver || - isGlobalObserver || - isTeamTechnician || - isGlobalTechnician || - (!isOnGlobalTeam && isInheritedPolicy); // Team user viewing inherited policy - - // Render non-editable form only - if (noEditPermissions) { - return renderNonEditableForm; - } - - // Render default editable form - return renderEditablePolicyForm(); + return renderPolicyForm(); }; export default PolicyForm; diff --git a/frontend/pages/policies/PolicyPage/components/PolicyForm/_styles.scss b/frontend/pages/policies/PolicyPage/components/PolicyForm/_styles.scss index 8f505877b4..7f29d1a73f 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyForm/_styles.scss +++ b/frontend/pages/policies/PolicyPage/components/PolicyForm/_styles.scss @@ -17,6 +17,24 @@ } } + &__page-header { + display: flex; + flex-direction: column; + gap: $pad-small; + } + + &__page-title { + font-size: $large; + font-weight: $bold; + margin: 0; + } + + &__page-subtitle { + font-size: $x-small; + color: $ui-fleet-black-75; + margin: 0; + } + &__title-bar { display: flex; justify-content: space-between; @@ -34,10 +52,8 @@ } } - .input-field, - .input-field__text-area { - min-height: auto; - white-space: normal; + .input-field__textarea { + min-width: 100%; } /* Hide scrollbar for Chrome, Safari and Opera */ @@ -56,7 +72,7 @@ height: 18px; } - &__policy-name, + &__policy-name-fleet-name, &__description, &__resolution { .button--text-icon { @@ -65,90 +81,31 @@ } } - &__policy-name-wrapper, - &__policy-description-wrapper, - &__policy-resolution-wrapper { + &__policy-name-fleet-name { display: flex; - align-items: flex-start; + flex-direction: column; gap: $pad-small; - width: fit-content; - - &:not(&--editing) { - &:hover { - cursor: pointer; - * { - color: $core-fleet-green; - cursor: pointer; - } - } - } - &--disabled-by-gitops-mode { - @include disabled; - } - } - - &__policy-name-wrapper { - line-height: $line-height-large; - - .no-value { - min-width: 112px; - } - } - - &__edit-icon { - opacity: 1; - transition: opacity 0.2s; - margin-left: 0; - /** Designed to be aligned with first line of text, not center of multiple - lines of text, without this, the icon will vertically center on multiline */ - align-self: initial; - height: 42px; // 24px font-size * 1.75 line height - align-items: center; // Centers the icon in the height area - - &--hide { - opacity: 0; - } - } - - &__policy-description-wrapper, - &__policy-resolution-wrapper { - .no-value { - min-width: 106px; - } - .policy-form__edit-icon { - height: 21px; // 14px font-size * 1.5 line height - } - } - - &__policy-name, - &__policy-description, - &__policy-resolution { - width: 100%; - margin: 0; - padding: 0; - border: 0; - resize: none; - white-space: normal; - background-color: transparent; - overflow: hidden; - font-size: $x-small; - } - - &__policy-name { - font-size: $large; - - &.input-field--error { - border: 1px solid $core-vibrant-red; - } } &__autofill-label { display: flex; justify-content: space-between; align-items: center; + + .autofill-tooltip-wrapper { + display: flex; // Required for vertical centering + } + + .autofill-button-tooltip { + font-weight: $regular; + } } &__button-wrap { + &--tooltip { + display: flex; + } + .policy-form__run { min-width: 64px; } diff --git a/frontend/pages/policies/PolicyPage/components/SaveNewPolicyModal/SaveNewPolicyModal.tests.tsx b/frontend/pages/policies/PolicyPage/components/SaveNewPolicyModal/SaveNewPolicyModal.tests.tsx index 0129961ca5..2d270a91ca 100644 --- a/frontend/pages/policies/PolicyPage/components/SaveNewPolicyModal/SaveNewPolicyModal.tests.tsx +++ b/frontend/pages/policies/PolicyPage/components/SaveNewPolicyModal/SaveNewPolicyModal.tests.tsx @@ -125,7 +125,9 @@ describe("SaveNewPolicyModal", () => { const saveButton = screen.getByRole("button", { name: "Save" }); expect(saveButton).toBeDisabled(); - const funButton = screen.getByLabelText("Fun"); + const funButton = await screen.findByRole("checkbox", { + name: "Fun", + }); expect(funButton).not.toBeChecked(); await userEvent.click(funButton); expect(saveButton).toBeEnabled(); @@ -149,7 +151,9 @@ describe("SaveNewPolicyModal", () => { // Set a label. await userEvent.click(screen.getByLabelText("Custom")); - await userEvent.click(screen.getByLabelText("Fun")); + await userEvent.click( + await screen.findByRole("checkbox", { name: "Fun" }) + ); await userEvent.click(screen.getByRole("button", { name: "Save" })); expect(onCreatePolicy.mock.calls[0][0].labels_include_any).toEqual([ @@ -175,7 +179,9 @@ describe("SaveNewPolicyModal", () => { // Set a label. await userEvent.click(screen.getByLabelText("Custom")); - await userEvent.click(screen.getByLabelText("Fun")); + await userEvent.click( + await screen.findByRole("checkbox", { name: "Fun" }) + ); // Click "Include any" to open the dropdown. const includeAnyOption = screen.getByRole("option", { diff --git a/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx b/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx index e366b4616f..7fce523513 100644 --- a/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx +++ b/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx @@ -259,12 +259,22 @@ const QueryEditor = ({ ); }; + const backPath = policyIdForEdit + ? getPathWithQueryParams(PATHS.POLICY_DETAILS(policyIdForEdit), { + team_id: teamIdForApi, + }) + : backToPoliciesPath(); + + const backText = policyIdForEdit ? "Back to policy" : "Back to policies"; + return (
- +
setPolicyAutofillData(null)} currentAutomatedPolicies={currentAutomatedPolicies || []} + onCancel={ + policyIdForEdit + ? () => + router.push( + getPathWithQueryParams( + PATHS.POLICY_DETAILS(policyIdForEdit), + { fleet_id: teamIdForApi } + ) + ) + : undefined + } />
); diff --git a/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tests.tsx b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tests.tsx index 4ba05513f7..fc65129848 100644 --- a/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tests.tsx +++ b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tests.tsx @@ -429,8 +429,10 @@ describe("EditQueryForm - component", () => { expect(screen.getByLabelText("All hosts")).toBeInTheDocument(); expect(screen.getByLabelText("Custom")).toBeInTheDocument(); expect(screen.getByLabelText("Custom")).toBeChecked(); - expect(screen.getByLabelText("Fun")).toBeChecked(); - expect(screen.getByLabelText("Fresh")).not.toBeChecked(); + expect(screen.getByRole("checkbox", { name: "Fun" })).toBeChecked(); + expect( + screen.getByRole("checkbox", { name: "Fresh" }) + ).not.toBeChecked(); expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); }); }); @@ -449,9 +451,11 @@ describe("EditQueryForm - component", () => { expect(screen.getByLabelText("All hosts")).toBeInTheDocument(); expect(screen.getByLabelText("Custom")).toBeInTheDocument(); expect(screen.getByLabelText("Custom")).toBeChecked(); - funButton = screen.getByLabelText("Fun"); + funButton = screen.getByRole("checkbox", { name: "Fun" }); expect(funButton).toBeChecked(); - expect(screen.getByLabelText("Fresh")).not.toBeChecked(); + expect( + screen.getByRole("checkbox", { name: "Fresh" }) + ).not.toBeChecked(); saveButton = screen.getByRole("button", { name: "Save" }); expect(saveButton).toBeEnabled(); }); diff --git a/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx index 7f949a4575..5bfe7dde91 100644 --- a/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx +++ b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx @@ -2,7 +2,6 @@ import React, { useState, useContext, useEffect, - KeyboardEvent, useCallback, useMemo, } from "react"; @@ -11,7 +10,6 @@ import { Location } from "history"; import { useQuery } from "react-query"; import { size } from "lodash"; -import classnames from "classnames"; import { useDebouncedCallback } from "use-debounce"; import { Ace } from "ace-builds"; @@ -65,7 +63,8 @@ import Slider from "components/forms/fields/Slider"; import TooltipWrapper from "components/TooltipWrapper"; import Spinner from "components/Spinner"; import Icon from "components/Icon/Icon"; -import AutoSizeInputField from "components/forms/fields/AutoSizeInputField"; +// @ts-ignore +import InputField from "components/forms/fields/InputField"; import LogDestinationIndicator from "components/LogDestinationIndicator"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; import TargetLabelSelector from "components/TargetLabelSelector"; @@ -192,6 +191,7 @@ const EditQueryForm = ({ config, isPremiumTier, isFreeTier, + currentTeam, } = useContext(AppContext); const isExistingQuery = !!queryIdForEdit; @@ -207,8 +207,6 @@ const EditQueryForm = ({ const [showQueryEditor, setShowQueryEditor] = useState( isObserverPlus || isAnyTeamObserverPlus || false ); - const [isEditingName, setIsEditingName] = useState(false); - const [isEditingDescription, setIsEditingDescription] = useState(false); const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); const [queryWasChanged, setQueryWasChanged] = useState(false); const [selectedTargetType, setSelectedTargetType] = useState(""); @@ -327,14 +325,6 @@ const EditQueryForm = ({ setLastEditedQueryBody(sqlString); }; - const onInputKeypress = (event: KeyboardEvent) => { - if (event.key.toLowerCase() === "enter" && !event.shiftKey) { - event.preventDefault(); - event.currentTarget.blur(); - setIsEditingName(false); - setIsEditingDescription(false); - } - }; const frequencyOptions = useMemo( () => getCustomDropdownOptions( @@ -413,30 +403,6 @@ const EditQueryForm = ({ } }; - const renderAuthor = (): JSX.Element | null => { - return storedQuery ? ( - - - - {storedQuery.author_name === currentUser?.name - ? "You" - : storedQuery.author_name} - - - } - /> - ) : null; - }; - const renderLabelComponent = (): JSX.Element | null => { if (!showOpenSchemaActionText) { return null; @@ -460,70 +426,20 @@ const EditQueryForm = ({ return platformCompatibility.render(); }; - const editName = () => { - if (!isEditingName) { - setIsEditingName(true); - } - }; - - const queryNameWrapperClass = `${baseClass}__query-name-wrapper`; - const queryNameWrapperClasses = classnames(queryNameWrapperClass, { - [`${baseClass}--editing`]: isEditingName, - }); - - const queryDescriptionWrapperClass = `${baseClass}__query-description-wrapper`; - const queryDescriptionWrapperClasses = classnames( - queryDescriptionWrapperClass, - { - [`${baseClass}--editing`]: isEditingDescription, - } - ); - const renderName = () => { if (isExistingQuery) { return ( - { - const classes = classnames(queryNameWrapperClasses, { - [`${queryNameWrapperClass}--disabled-by-gitops-mode`]: disableChildren, - }); - return ( -
setIsEditingName(true)} - onBlur={() => setIsEditingName(false)} - onClick={editName} - > - { - setLastEditedQueryName(lastEditedQueryName.trim()); - }} - onKeyPress={onInputKeypress} - isFocused={isEditingName} - disableTabability={disableChildren} - /> - -
- ); + setLastEditedQueryName(value)} + onBlur={() => { + setLastEditedQueryName(lastEditedQueryName.trim()); }} + disabled={gitOpsModeEnabled} /> ); } @@ -531,53 +447,18 @@ const EditQueryForm = ({ return

New report

; }; - const editDescription = () => { - if (!isEditingDescription) { - setIsEditingDescription(true); - } - }; - const renderDescription = () => { if (isExistingQuery) { return ( - { - const classes = classnames(queryDescriptionWrapperClasses, { - [`${queryDescriptionWrapperClass}--disabled-by-gitops-mode`]: disableChildren, - }); - return ( -
setIsEditingDescription(true)} - onBlur={() => setIsEditingDescription(false)} - onClick={editDescription} - > - - -
- ); - }} + setLastEditedQueryDescription(value)} + disabled={gitOpsModeEnabled} /> ); } @@ -616,12 +497,9 @@ const EditQueryForm = ({ // Observers and observer+ of existing query const renderNonEditableForm = ( -
-

- {lastEditedQueryName} -

- {renderAuthor()} -
+

+ {lastEditedQueryName} +

{renderQueryTeam()} -
- {renderName()} - {isExistingQuery && renderAuthor()} -
- {renderQueryTeam()} + {isExistingQuery ? ( +
+

Edit report

+ {renderQueryTeam()} +
+ ) : ( +
+ {renderName()} + {renderQueryTeam()} +
+ )} + {isExistingQuery && renderName()} {renderDescription()} - - {renderPlatformCompatibility()} {isExistingQuery && (
)} +
+ )} + + {renderPlatformCompatibility()} + {isExistingQuery && ( + <> )} -
+ )} {renderLiveQueryWarning()}
@@ -935,6 +825,7 @@ const EditQueryForm = ({ )} { const saveButton = screen.getByRole("button", { name: "Save" }); expect(saveButton).toBeDisabled(); - const funButton = screen.getByLabelText("Fun"); + const funButton = await screen.findByRole("checkbox", { + name: "Fun", + }); expect(funButton).not.toBeChecked(); await userEvent.click(funButton); expect(saveButton).toBeEnabled(); @@ -280,7 +282,9 @@ describe("SaveNewQueryModal", () => { // Set a label. await userEvent.click(screen.getByLabelText("Custom")); - await userEvent.click(screen.getByLabelText("Fun")); + await userEvent.click( + await screen.findByRole("checkbox", { name: "Fun" }) + ); await userEvent.click(screen.getByRole("button", { name: "Save" })); expect(saveQuery.mock.calls[0][0].labels_include_any).toEqual(["Fun"]); diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx index 98c01ed538..9c18ff0e03 100644 --- a/frontend/router/index.tsx +++ b/frontend/router/index.tsx @@ -42,6 +42,7 @@ import ManagePacksPage from "pages/packs/ManagePacksPage"; import ManagePoliciesPage from "pages/policies/ManagePoliciesPage"; import NoAccessPage from "pages/NoAccessPage"; import PackComposerPage from "pages/packs/PackComposerPage"; +import PolicyDetailsPage from "pages/policies/PolicyDetailsPage"; import PolicyPage from "pages/policies/PolicyPage"; import QueryDetailsPage from "pages/queries/details/QueryDetailsPage"; import LiveQueryPage from "pages/queries/live/LiveQueryPage"; @@ -411,7 +412,10 @@ const routes = ( - + + + + {/* deprecated URL */} diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 4bd6cf0ad5..637be790da 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -124,8 +124,10 @@ export default { `${URL_PREFIX}/reports/${queryId || "new"}/live`, REPORT_DETAILS: (queryId: number): string => `${URL_PREFIX}/reports/${queryId}`, - EDIT_POLICY: (policyId: number): string => + POLICY_DETAILS: (policyId: number): string => `${URL_PREFIX}/policies/${policyId}`, + EDIT_POLICY: (policyId: number): string => + `${URL_PREFIX}/policies/${policyId}/edit`, FORGOT_PASSWORD: `${URL_PREFIX}/login/forgot`, MFA: `${URL_PREFIX}/login/mfa`, NO_ACCESS: `${URL_PREFIX}/login/denied`,