diff --git a/changes/45408-read-only-target-in-schedule-auto-updates-modal b/changes/45408-read-only-target-in-schedule-auto-updates-modal new file mode 100644 index 00000000000..284435b259e --- /dev/null +++ b/changes/45408-read-only-target-in-schedule-auto-updates-modal @@ -0,0 +1 @@ +- Added read-only display of software target in the "Schedule auto updates" modal diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tests.tsx index 2612dac9a3a..7f7db49cb8e 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tests.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tests.tsx @@ -5,11 +5,10 @@ import { createMockAppStoreApp, } from "__mocks__/softwareMock"; -import { act, screen, waitFor } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import mockServer from "test/mock-server"; import { createCustomRenderer } from "test/test-utils"; -import { ILabelSummary } from "interfaces/label"; import createMockUser from "__mocks__/userMock"; @@ -21,32 +20,12 @@ const baseUrl = (path: string) => { return `/api/latest/fleet${path}`; }; -const mockLabels: ILabelSummary[] = [ - { - id: 1, - name: "Fun", - description: "Computers that like to have a good time", - label_type: "regular", - }, - { - id: 2, - name: "Fresh", - description: "Laptops with dirty mouths", - label_type: "regular", - }, +const mockLabels = [ + { id: 1, name: "Fun" }, + { id: 2, name: "Fresh" }, ]; -const labelSummariesHandler = http.get(baseUrl("/labels/summary"), () => { - return HttpResponse.json({ - labels: mockLabels, - }); -}); - describe("Edit Auto Update Config Modal", () => { - beforeEach(() => { - mockServer.use(labelSummariesHandler); - }); - const render = createCustomRenderer({ withBackendMock: true, context: { @@ -161,10 +140,12 @@ describe("Edit Auto Update Config Modal", () => { await waitFor(() => { expect(enableAutoUpdatesCheckbox).not.toBeChecked(); // Verify that the maintenance window fields are not shown. - const startTimeField = screen.queryByText("Earliest start time"); - const endTimeField = screen.queryByText("Latest start time"); - expect(startTimeField).not.toBeInTheDocument(); - expect(endTimeField).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("Earliest start time") + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("Latest start time") + ).not.toBeInTheDocument(); }); }); @@ -366,8 +347,8 @@ describe("Edit Auto Update Config Modal", () => { }); }); - describe("Target options", () => { - it("Shows 'All hosts' if no labels are configured for the title", async () => { + describe("Target options (read-only)", () => { + it("Shows 'all hosts' text when no labels are configured", async () => { render( { onExit={jest.fn()} /> ); - expect(screen.getByLabelText("All hosts")).toBeInTheDocument(); - expect(screen.getByLabelText("Custom")).toBeInTheDocument(); - expect(screen.getByLabelText("All hosts")).toBeChecked(); - expect(screen.getByLabelText("Custom")).not.toBeChecked(); + expect( + screen.getByText(/Update settings will apply to/i) + ).toBeInTheDocument(); + expect( + screen.getByText("all hosts", { exact: false }) + ).toBeInTheDocument(); }); - it("Shows label options if labels are configured for the title", async () => { + + it("Shows label names when labels are configured for the title", async () => { render( { onExit={jest.fn()} /> ); + expect(screen.getByText(mockLabels[1].name)).toBeInTheDocument(); + expect( + screen.getByText(/Update settings will only apply to hosts that/i) + ).toBeInTheDocument(); + }); - // Wait until target section has rendered and request has had a chance to resolve - await screen.findByLabelText("Custom"); - - // Now wait specifically for one label to appear - const freshLabel = await screen.findByRole("checkbox", { - name: mockLabels[1].name, - }); - expect(freshLabel).toBeInTheDocument(); - expect(freshLabel).toBeChecked(); - - const funLabel = screen.getByRole("checkbox", { - name: mockLabels[0].name, - }); - expect(funLabel).toBeInTheDocument(); - expect(funLabel).not.toBeChecked(); + it("Shows label names with 'have all' text when labels_include_all is configured", async () => { + render( + + ); + expect(screen.getByText(mockLabels[0].name)).toBeInTheDocument(); + expect( + screen.getByText(/have all/i, { exact: false }) + ).toBeInTheDocument(); }); - it("Requires at least one label to be selected if 'Custom' is selected", async () => { - const { user } = render( + it("Shows label names with 'don't have any' text when labels_exclude_any is configured", async () => { + render( { onExit={jest.fn()} /> ); - // Wait for labels to load - await screen.findByRole("checkbox", { name: mockLabels[1].name }); - const customOption = screen.getByLabelText("Custom"); - expect(customOption).toBeChecked(); - const labelOption = screen.getByRole("checkbox", { - name: mockLabels[1].name, - }); - expect(labelOption).toBeChecked(); - await user.click(labelOption); - expect(labelOption).not.toBeChecked(); - const saveButton = screen.getByRole("button", { - name: "Save", - }); - expect(saveButton).toBeDisabled(); + expect(screen.getByText(mockLabels[0].name)).toBeInTheDocument(); + expect( + screen.getByText(/don't have any/i, { exact: false }) + ).toBeInTheDocument(); + }); + + it("Shows 'To edit the target' hint in both cases", async () => { + render( + + ); + expect( + screen.getByText(/To edit the target, close this modal/i) + ).toBeInTheDocument(); }); }); @@ -498,9 +495,7 @@ describe("Edit Auto Update Config Modal", () => { const enableAutoUpdatesCheckbox = screen.getByRole("checkbox", { name: "Enable auto updates", }); - await act(() => { - enableAutoUpdatesCheckbox.click(); - }); + await user.click(enableAutoUpdatesCheckbox); await waitFor(() => { expect(enableAutoUpdatesCheckbox).toBeChecked(); }); @@ -512,9 +507,7 @@ describe("Edit Auto Update Config Modal", () => { name: "Save", }); expect(saveButton).toBeEnabled(); - await act(() => { - saveButton.click(); - }); + await user.click(saveButton); await waitFor(() => { expect(requestSpy).toHaveBeenCalledWith({ auto_update_enabled: true, @@ -528,7 +521,7 @@ describe("Edit Auto Update Config Modal", () => { }); }); - it("Sends the correct payload when 'All hosts' is selected as the target", async () => { + it("Preserves existing label target in payload when saving", async () => { const { user } = render( { onExit={jest.fn()} /> ); - const allHostsRadio = screen.getByLabelText("All hosts"); - expect(allHostsRadio).toBeInTheDocument(); - await user.click(allHostsRadio); const saveButton = screen.getByRole("button", { name: "Save", }); expect(saveButton).toBeEnabled(); await user.click(saveButton); - await waitFor(() => { - expect(requestSpy).toHaveBeenCalledWith({ - auto_update_enabled: false, - labels_include_any: [], - labels_exclude_any: [], - labels_include_all: [], - fleet_id: 1, - }); - }); - }); - - it("Sends the correct payload when specific labels are selected as the target", async () => { - const { user } = render( - - ); - const saveButton = screen.getByRole("button", { - name: "Save", - }); - expect(saveButton).toBeEnabled(); - await act(() => { - user.click(saveButton); - }); await waitFor(() => { expect(requestSpy).toHaveBeenCalledWith({ auto_update_enabled: false, diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tsx index db2f69ef875..7ee6f46356e 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tsx @@ -1,37 +1,28 @@ import React, { useContext, useState } from "react"; import classnames from "classnames"; import { ISoftwareTitleDetails, IAppStoreApp } from "interfaces/software"; -import { ILabelSummary } from "interfaces/label"; - -import { useQuery } from "react-query"; import { NotificationContext } from "context/notification"; import useGitOpsMode from "hooks/useGitOpsMode"; import softwareAPI from "services/entities/software"; -import labelsAPI, { getCustomLabels } from "services/entities/labels"; import Card from "components/Card"; import Modal from "components/Modal"; import ModalFooter from "components/ModalFooter"; import Checkbox from "components/forms/fields/Checkbox"; -import { DropdownTargetLabelSelector } from "components/TargetLabelSelector"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; import { - CUSTOM_TARGET_OPTIONS, generateSelectedLabels, getCustomTarget, getDisplayedSoftwareName, - generateHelpText, getTargetType, } from "pages/SoftwarePage/helpers"; import InputField from "components/forms/fields/InputField"; import Button from "components/buttons/Button"; -import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; - import { ISoftwareAutoUpdateConfigFormValidation, ISoftwareAutoUpdateConfigInputValidation, @@ -41,6 +32,61 @@ import { const baseClass = "edit-auto-update-config-modal"; const formClass = "edit-auto-update-config-form"; +const getReadonlyTargetContent = ( + appStoreApp: IAppStoreApp | null +): JSX.Element => { + if (appStoreApp?.labels_include_any?.length) { + return ( + <> +

+ Update settings will only apply to hosts that have any of these + labels: +

+
    + {appStoreApp.labels_include_any.map((label) => ( +
  • {label.name}
  • + ))} +
+ + ); + } + if (appStoreApp?.labels_include_all?.length) { + return ( + <> +

+ Update settings will only apply to hosts that have all of these + labels: +

+
    + {appStoreApp.labels_include_all.map((label) => ( +
  • {label.name}
  • + ))} +
+ + ); + } + if (appStoreApp?.labels_exclude_any?.length) { + return ( + <> +

+ Update settings will only apply to hosts that{" "} + don't have any of these labels: +

+
    + {appStoreApp.labels_exclude_any.map((label) => ( +
  • {label.name}
  • + ))} +
+ + ); + } + return ( +

+ Update settings will apply to all hosts. +

+ ); +}; + // Schema for the form data that will be used in the UI // and sent to the API. export interface ISoftwareAutoUpdateConfigFormData { @@ -68,7 +114,7 @@ const EditAutoUpdateConfigModal = ({ const { renderFlash } = useContext(NotificationContext); const { gitOpsModeEnabled } = useGitOpsMode("software"); - const formClassNames = classnames(formClass, { + const clsNames = classnames(formClass, { [`edit-auto-update-config-form--disabled`]: gitOpsModeEnabled, }); @@ -84,15 +130,6 @@ const EditAutoUpdateConfigModal = ({ ), }); - // Fetch labels for DropdownTargetLabelSelector - const { data: labels } = useQuery( - ["custom_labels"], - () => labelsAPI.summary(teamId).then((res) => getCustomLabels(res.labels)), - { - ...DEFAULT_USE_QUERY_OPTIONS, - } - ); - const [ formValidation, setFormValidation, @@ -167,27 +204,6 @@ const EditAutoUpdateConfigModal = ({ } }; - const onSelectTargetType = (value: string) => { - const newData = { ...formData, targetType: value }; - setFormData(newData); - setFormValidation(validateFormData(newData)); - }; - - const onSelectCustomTargetOption = (value: string) => { - const newData = { ...formData, customTarget: value }; - setFormData(newData); - setFormValidation(validateFormData(newData)); - }; - - const onSelectLabel = ({ name, value }: { name: string; value: boolean }) => { - const newData = { - ...formData, - labelTargets: { ...formData.labelTargets, [name]: value }, - }; - setFormData(newData); - setFormValidation(validateFormData(newData)); - }; - const earliestStartTimeError = formValidation.autoUpdateStartTime?.message || (formValidation.windowLength?.message ? "Earliest start time" : undefined); @@ -205,7 +221,7 @@ const EditAutoUpdateConfigModal = ({ return ( -
+
@@ -276,21 +292,14 @@ const EditAutoUpdateConfigModal = ({
- +
+
Target
+ {getReadonlyTargetContent(softwareTitle.app_store_app)} +

+ To edit the target, close this modal and select{" "} + Actions > Edit software. +

+
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/_styles.scss index b7f8f202a5c..6de8bddcd38 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/_styles.scss +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/_styles.scss @@ -48,6 +48,42 @@ } } } - + + &__target-readonly { + display: flex; + flex-direction: column; + gap: $pad-small; + } + + &__target-description, + &__target-edit-hint { + font-size: $x-small; + color: $ui-fleet-black-75; + margin: 0; + } + + &__target-labels-list { + list-style: none; + padding: 0; + margin: 0; + border: 1px solid $ui-fleet-black-10; + border-radius: $border-radius-medium; + overflow: hidden; + + li { + padding: 0 $pad-large; + height: 40px; + display: flex; + align-items: center; + font-size: $x-small; + color: $core-fleet-black; + border-bottom: 1px solid $ui-fleet-black-10; + + &:last-child { + border-bottom: none; + } + } + } + } } diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/helpers.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/helpers.tsx index bbe8d688914..bb75bca31dc 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/helpers.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/helpers.tsx @@ -9,7 +9,6 @@ export interface ISoftwareAutoUpdateConfigFormValidation { isValid: boolean; autoUpdateStartTime?: ISoftwareAutoUpdateConfigInputValidation; autoUpdateEndTime?: ISoftwareAutoUpdateConfigInputValidation; - targets?: ISoftwareAutoUpdateConfigInputValidation; windowLength?: ISoftwareAutoUpdateConfigInputValidation; } @@ -116,20 +115,6 @@ const FORM_VALIDATIONS: IFormValidations = { }, ], }, - targets: { - validations: [ - { - name: "custom_labels_selected", - isValid: (formData: ISoftwareAutoUpdateConfigFormData) => { - return ( - formData.targetType !== "Custom" || - Object.values(formData.labelTargets).filter((v) => v).length > 0 - ); - }, - message: `At least one label target must be selected`, - }, - ], - }, windowLength: { validations: [ { @@ -160,7 +145,7 @@ export const validateFormData = ( }; // If auto updates are not enabled, skip further validations. Object.keys(FORM_VALIDATIONS).forEach((key) => { - if (!formData.autoUpdateEnabled && key !== "targets") { + if (!formData.autoUpdateEnabled) { return; } const objKey = key as keyof typeof FORM_VALIDATIONS;