47717 auld UI latest os version (#50571)
**Related issue:** Resolves #47717 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. _Front end only_ ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added Apple OS update targeting options for no enforcement, a custom minimum version, or the latest available version. * Added configurable whole-day deadlines for macOS, iOS, and iPadOS updates. * Added platform-specific target controls, validation, and automatic new-host updates for latest-version targeting. * Displayed minimum versions, pending status, and update deadlines in host details and activity feeds. * Clarified Windows deadlines as days after release. * **Style** * Improved layout and spacing for Apple and Windows update forms. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Jordan Montgomery <elijah.jordan.montgomery@gmail.com>
This commit is contained in:
co-authored by
Jordan Montgomery
parent
4d55e96f9f
commit
eb4acf4d1e
@@ -18,14 +18,17 @@ const DEFAULT_CONFIG_MDM_MOCK: IMdmConfig = {
|
||||
macos_updates: {
|
||||
minimum_version: "",
|
||||
deadline: "",
|
||||
deadline_days: null,
|
||||
},
|
||||
ios_updates: {
|
||||
minimum_version: "",
|
||||
deadline: "",
|
||||
deadline_days: null,
|
||||
},
|
||||
ipados_updates: {
|
||||
minimum_version: "",
|
||||
deadline: "",
|
||||
deadline_days: null,
|
||||
},
|
||||
apple_settings: {
|
||||
configuration_profiles: null,
|
||||
|
||||
@@ -40,8 +40,12 @@ interface ICustomSetting {
|
||||
}
|
||||
|
||||
export interface IAppleDeviceUpdates {
|
||||
/** The sentinel `"latest"` enforces the newest version available, with the
|
||||
* deadline derived from `deadline_days` instead of a fixed date. */
|
||||
minimum_version: string;
|
||||
deadline: string;
|
||||
/** Only set when `minimum_version` is `"latest"`; null otherwise. */
|
||||
deadline_days: number | null;
|
||||
update_new_hosts?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -458,6 +458,13 @@ export interface IHost {
|
||||
conditional_access_bypassed: boolean;
|
||||
mdm_enrollment_hardware_attested?: boolean;
|
||||
dep_assigned_to_fleet: boolean;
|
||||
/** The OS version this host is required to reach. Null when OS updates
|
||||
* aren't configured for the host's fleet, and "Pending" while Fleet is still
|
||||
* resolving the target for a "latest" requirement. */
|
||||
os_update_minimum_version?: string | null;
|
||||
/** The date by which os_update_minimum_version must be installed, in
|
||||
* YYYY-MM-DD. Null and "Pending" follow os_update_minimum_version. */
|
||||
os_update_deadline?: string | null;
|
||||
// iOS/iPadOS-only vitals collected via the DeviceInformation MDM command.
|
||||
// Omitted entirely (not just null) for every other platform.
|
||||
udid?: string;
|
||||
|
||||
+40
@@ -219,6 +219,46 @@ describe("Activity Feed", () => {
|
||||
expect(screen.getByText("was added to Fleet by SSO.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an edited_macos_min_version activity for a specific version", () => {
|
||||
const activity = createMockActivity({
|
||||
type: ActivityType.EditedMacosMinVersion,
|
||||
details: {
|
||||
team_id: 1,
|
||||
team_name: "Workstations",
|
||||
minimum_version: "14.6.1",
|
||||
deadline: "2026-09-01",
|
||||
},
|
||||
});
|
||||
render(<GlobalActivityItem activity={activity} isPremiumTier />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/updated the minimum macOS version/)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("14.6.1")).toBeInTheDocument();
|
||||
expect(screen.getByText(/deadline: 2026-09-01/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an edited_macos_min_version activity for the latest target", () => {
|
||||
// "latest" is a mode rather than a version, so the sentence must not call it
|
||||
// a minimum, and there's no deadline to report.
|
||||
const activity = createMockActivity({
|
||||
type: ActivityType.EditedMacosMinVersion,
|
||||
details: {
|
||||
team_id: 1,
|
||||
team_name: "Workstations",
|
||||
minimum_version: "latest",
|
||||
deadline: "",
|
||||
},
|
||||
});
|
||||
render(<GlobalActivityItem activity={activity} isPremiumTier />);
|
||||
|
||||
expect(screen.getByText(/updated macOS version to/)).toBeInTheDocument();
|
||||
expect(screen.getByText("latest")).toBeInTheDocument();
|
||||
expect(screen.getByText("Workstations")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/minimum/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/deadline/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an edited_agent_options type activity for a team", () => {
|
||||
const activity = createMockActivity({
|
||||
type: ActivityType.EditedAgentOptions,
|
||||
|
||||
+13
@@ -521,6 +521,19 @@ const TAGGED_TEMPLATES = {
|
||||
<>unassigned</>
|
||||
);
|
||||
|
||||
// "latest" isn't a minimum -- the target floats with what Apple publishes --
|
||||
// so the sentence drops "the minimum" rather than contradicting itself.
|
||||
// There's no deadline to report either: it's derived from deadline_days,
|
||||
// which the activity doesn't record.
|
||||
if (activity.details?.minimum_version === "latest") {
|
||||
return (
|
||||
<>
|
||||
{editedActivity} {applePlatform} version to <b>latest</b> on hosts
|
||||
assigned to {teamSection}.
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{editedActivity} the minimum {applePlatform} version {versionSection}{" "}
|
||||
|
||||
+616
@@ -41,6 +41,7 @@ describe("AppleOSTargetForm", () => {
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="11.0"
|
||||
defaultDeadline="2024-12-31"
|
||||
defaultDeadlineDays=""
|
||||
defaultUpdateNewHosts
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
@@ -69,6 +70,7 @@ describe("AppleOSTargetForm", () => {
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="11.0"
|
||||
defaultDeadline="2024-12-31"
|
||||
defaultDeadlineDays=""
|
||||
defaultUpdateNewHosts
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
@@ -100,6 +102,566 @@ describe("AppleOSTargetForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Every field is sent for every target: the config PATCH merges key by key,
|
||||
// so an omitted one would leave the stored value behind.
|
||||
it("sends the sentinel, no deadline and the days for 'Latest version'", async () => {
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="latest"
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays="7"
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestBody?.mdm?.macos_updates?.minimum_version).toBe("latest");
|
||||
expect(requestBody?.mdm?.macos_updates?.deadline).toBe("");
|
||||
expect(requestBody?.mdm?.macos_updates?.deadline_days).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears every field for 'No updates enforced'", async () => {
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="latest"
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays="7"
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByText("No updates enforced"));
|
||||
await user.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestBody?.mdm?.macos_updates?.minimum_version).toBe("");
|
||||
expect(requestBody?.mdm?.macos_updates?.deadline).toBe("");
|
||||
expect(requestBody?.mdm?.macos_updates?.deadline_days).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("nulls deadline_days when moving from 'Latest version' to 'Custom version'", async () => {
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="latest"
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays="7"
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByText("Custom version"));
|
||||
await user.type(screen.getByLabelText(/Minimum version/i), "15.7.8");
|
||||
await user.type(screen.getByLabelText(/^Deadline$/i), "2026-09-01");
|
||||
await user.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestBody?.mdm?.macos_updates?.minimum_version).toBe("15.7.8");
|
||||
expect(requestBody?.mdm?.macos_updates?.deadline).toBe("2026-09-01");
|
||||
// The stored 7 must not survive the switch out of latest mode.
|
||||
expect(requestBody?.mdm?.macos_updates?.deadline_days).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("sends update_new_hosts as true for 'Latest version'", async () => {
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="latest"
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays="7"
|
||||
// Stored as false, so a true in the request can only come from the
|
||||
// target rather than from the prop.
|
||||
defaultUpdateNewHosts={false}
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestBody?.mdm?.macos_updates?.update_new_hosts).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the checkbox to the persisted value when the target changes", async () => {
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="11.0"
|
||||
defaultDeadline="2024-12-31"
|
||||
defaultDeadlineDays=""
|
||||
defaultUpdateNewHosts={false}
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox", {
|
||||
name: /update_new_hosts/i,
|
||||
});
|
||||
|
||||
// Tick it without saving, so the on-screen value differs from what's stored.
|
||||
await user.click(checkbox);
|
||||
await waitFor(() => expect(checkbox).toBeChecked());
|
||||
|
||||
// Changing the target dismisses that unsaved input.
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByText("No updates enforced"));
|
||||
|
||||
expect(
|
||||
screen.getByRole("checkbox", { name: /update_new_hosts/i })
|
||||
).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("seeds the days field from the stored deadline_days", () => {
|
||||
render(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="latest"
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays="14"
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const daysInput = screen.getByLabelText(/Days after release/i);
|
||||
expect((daysInput as HTMLInputElement).value).toBe("14");
|
||||
});
|
||||
|
||||
it("keeps the 'latest' sentinel out of the minimum version input", async () => {
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="latest"
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays="7"
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// "latest" is the mode, not a version the user typed, so switching to a
|
||||
// custom version must start from an empty field rather than the sentinel.
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByText("Custom version"));
|
||||
|
||||
const minVersionInput = screen.getByLabelText(/Minimum version/i);
|
||||
expect((minVersionInput as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("renders the hardware help text when the stored version is 'latest'", () => {
|
||||
render(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="latest"
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays=""
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Based on host hardware\./i)).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: /Learn more/i })).toHaveAttribute(
|
||||
"href",
|
||||
"https://fleetdm.com/learn-more-about/apple-available-os-updates"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the hardware help text only once 'Latest version' is selected", async () => {
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="11.0"
|
||||
defaultDeadline="2024-12-31"
|
||||
defaultDeadlineDays=""
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/Based on host hardware\./i)).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByText("Latest version"));
|
||||
|
||||
expect(screen.getByText(/Based on host hardware\./i)).toBeVisible();
|
||||
});
|
||||
|
||||
it("does not render the hardware help text when no updates are enforced", () => {
|
||||
render(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion=""
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays=""
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/Based on host hardware\./i)).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the hardware help text when switching away from 'Latest version'", async () => {
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="latest"
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays=""
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Based on host hardware\./i)).toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByText("Custom version"));
|
||||
expect(screen.queryByText(/Based on host hardware\./i)).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByText("No updates enforced"));
|
||||
expect(screen.queryByText(/Based on host hardware\./i)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows only the days field when 'Latest version' is selected", () => {
|
||||
render(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="latest"
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays=""
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText(/Days after release/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Minimum version/i)).toBeNull();
|
||||
expect(screen.queryByLabelText(/^Deadline$/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no version fields when no updates are enforced", () => {
|
||||
render(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion=""
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays=""
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText(/Minimum version/i)).toBeNull();
|
||||
expect(screen.queryByLabelText(/^Deadline$/i)).toBeNull();
|
||||
expect(screen.queryByLabelText(/Days after release/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("swaps the fields when the target changes", async () => {
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="11.0"
|
||||
defaultDeadline="2024-12-31"
|
||||
defaultDeadlineDays=""
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText(/Minimum version/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Days after release/i)).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByText("Latest version"));
|
||||
|
||||
expect(screen.getByLabelText(/Days after release/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Minimum version/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("checks and disables 'update new hosts' for 'Latest version', leaving it visible", () => {
|
||||
render(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="latest"
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays=""
|
||||
defaultUpdateNewHosts={false}
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// The native input is hidden by the Checkbox component's styling, so the
|
||||
// label is what proves the control is still on screen.
|
||||
expect(screen.getByText(/Update new hosts to latest/i)).toBeVisible();
|
||||
|
||||
const checkbox = screen.getByLabelText(/Update new hosts to latest/i);
|
||||
expect(checkbox).toBeChecked();
|
||||
expect(checkbox).toBeDisabled();
|
||||
});
|
||||
|
||||
it("leaves 'update new hosts' editable for 'Custom version'", () => {
|
||||
render(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="11.0"
|
||||
defaultDeadline="2024-12-31"
|
||||
defaultDeadlineDays=""
|
||||
defaultUpdateNewHosts={false}
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByLabelText(/Update new hosts to latest/i);
|
||||
expect(checkbox).not.toBeChecked();
|
||||
expect(checkbox).toBeEnabled();
|
||||
});
|
||||
|
||||
it("shows the ADE tooltip for 'Latest version' on the checkbox", async () => {
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="latest"
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays=""
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.hover(screen.getByText(/Update new hosts to latest/i));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/all hosts will be updated to latest macOS version\./i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the minimum version tooltip on the checkbox for 'Custom version'", async () => {
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="11.0"
|
||||
defaultDeadline="2024-12-31"
|
||||
defaultDeadlineDays=""
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.hover(screen.getByText(/Update new hosts to latest/i));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/hosts below the minimum version are updated/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the days after release tooltip", async () => {
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="latest"
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays=""
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.hover(screen.getByText(/Days after release/i));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/number of days after Apple releases an update before hosts are required to install it\./i
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a value in days after release before saving", async () => {
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="latest"
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays=""
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/The days after release is required\./i)
|
||||
).toBeInTheDocument();
|
||||
expect(requestBody).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a days after release value below 1", async () => {
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="latest"
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays=""
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.type(screen.getByLabelText(/Days after release/i), "0");
|
||||
await user.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/must be a whole number of 1 or more\./i)
|
||||
).toBeInTheDocument();
|
||||
expect(requestBody).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a fractional days after release value", async () => {
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="latest"
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays=""
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.type(screen.getByLabelText(/Days after release/i), "1.5");
|
||||
await user.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/must be a whole number of 1 or more\./i)
|
||||
).toBeInTheDocument();
|
||||
expect(requestBody).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not validate the hidden version fields in 'Latest version'", async () => {
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="latest"
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays=""
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.type(screen.getByLabelText(/Days after release/i), "7");
|
||||
await user.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
// The minimum version and deadline are empty but not on screen, so they
|
||||
// must not block the save.
|
||||
expect(screen.queryByText(/The minimum version is required\./i)).toBeNull();
|
||||
expect(screen.queryByText(/The deadline is required\./i)).toBeNull();
|
||||
await waitFor(() => expect(requestBody).toBeDefined());
|
||||
});
|
||||
|
||||
it("clears a validation error when the target changes", async () => {
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="latest"
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays=""
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Save/i }));
|
||||
expect(
|
||||
await screen.findByText(/The days after release is required\./i)
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Away and back: the field unmounts either way, so only returning to it
|
||||
// proves the error state was cleared rather than merely hidden.
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByText("Custom version"));
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByText("Latest version"));
|
||||
|
||||
expect(screen.getByLabelText(/Days after release/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/The days after release is required\./i)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("requires both fields for 'Custom version' rather than clearing", async () => {
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion=""
|
||||
defaultDeadline=""
|
||||
defaultDeadlineDays=""
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByText("Custom version"));
|
||||
await user.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
// Saving an empty custom form used to clear the settings, which is now what
|
||||
// "No updates enforced" is for.
|
||||
expect(
|
||||
await screen.findByText(/The minimum version is required\./i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/The deadline is required\./i)).toBeInTheDocument();
|
||||
expect(requestBody).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders the correct form for iOS", () => {
|
||||
render(
|
||||
<AppleOSTargetForm
|
||||
@@ -107,6 +669,7 @@ describe("AppleOSTargetForm", () => {
|
||||
applePlatform="ios"
|
||||
defaultMinOsVersion="11.0"
|
||||
defaultDeadline="2024-12-31"
|
||||
defaultDeadlineDays=""
|
||||
defaultUpdateNewHosts
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
@@ -134,6 +697,7 @@ describe("AppleOSTargetForm", () => {
|
||||
applePlatform="ios"
|
||||
defaultMinOsVersion="12.0"
|
||||
defaultDeadline="2025-12-31"
|
||||
defaultDeadlineDays=""
|
||||
defaultUpdateNewHosts
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
@@ -157,6 +721,7 @@ describe("AppleOSTargetForm", () => {
|
||||
applePlatform="ipados"
|
||||
defaultMinOsVersion="11.0"
|
||||
defaultDeadline="2024-12-31"
|
||||
defaultDeadlineDays=""
|
||||
defaultUpdateNewHosts
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
@@ -184,6 +749,7 @@ describe("AppleOSTargetForm", () => {
|
||||
applePlatform="ipados"
|
||||
defaultMinOsVersion="13.0"
|
||||
defaultDeadline="2026-12-31"
|
||||
defaultDeadlineDays=""
|
||||
defaultUpdateNewHosts
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={jest.fn()}
|
||||
@@ -201,4 +767,54 @@ describe("AppleOSTargetForm", () => {
|
||||
expect(requestBody?.mdm?.ipados_updates?.deadline).toBe("2026-12-31");
|
||||
});
|
||||
});
|
||||
|
||||
// A rejected save must not refetch. The refetch feeds the stored config back
|
||||
// in as props and resets the form, which would discard the very input the user
|
||||
// needs to correct — e.g. a version Apple doesn't support.
|
||||
it("keeps the entered version when the server rejects the save", async () => {
|
||||
let rejected = false;
|
||||
mockServer.use(
|
||||
http.patch(baseUrl("/fleets/1"), () => {
|
||||
rejected = true;
|
||||
return HttpResponse.json(
|
||||
{
|
||||
message: "Validation Failed",
|
||||
errors: [
|
||||
{
|
||||
name: "macos_updates",
|
||||
reason: "The minimum version isn't supported by Apple.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const refetchTeamConfig = jest.fn();
|
||||
const { user } = renderWithBackend(
|
||||
<AppleOSTargetForm
|
||||
currentTeamId={1}
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion="11.0"
|
||||
defaultDeadline="2024-12-31"
|
||||
defaultDeadlineDays=""
|
||||
defaultUpdateNewHosts
|
||||
refetchAppConfig={jest.fn()}
|
||||
refetchTeamConfig={refetchTeamConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
const minVersionInput = screen.getByLabelText(/Minimum version/i);
|
||||
await user.clear(minVersionInput);
|
||||
await user.type(minVersionInput, "15.1");
|
||||
await user.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rejected).toBe(true);
|
||||
});
|
||||
|
||||
expect((minVersionInput as HTMLInputElement).value).toBe("15.1");
|
||||
expect(refetchTeamConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+205
-62
@@ -10,6 +10,8 @@ import teamsAPI from "services/entities/teams";
|
||||
import { ApplePlatform } from "interfaces/platform";
|
||||
|
||||
import InputField from "components/forms/fields/InputField";
|
||||
import DropdownWrapper from "components/forms/fields/DropdownWrapper";
|
||||
import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper";
|
||||
import Button from "components/buttons/Button";
|
||||
import Checkbox from "components/forms/fields/Checkbox";
|
||||
import validatePresence from "components/forms/validators/validate_presence";
|
||||
@@ -20,14 +22,38 @@ import { getErrorMessage } from "./helpers";
|
||||
|
||||
const baseClass = "apple-os-target-form";
|
||||
|
||||
/** The sentinel stored in minimum_version meaning "enforce the newest version
|
||||
* available", with the deadline derived from deadline_days rather than a date. */
|
||||
export const LATEST_VERSION = "latest";
|
||||
|
||||
/** Which set of fields the form collects. Not stored separately: minimum_version
|
||||
* carries the mode, so the target is derived from it. */
|
||||
export type AppleOSTarget = "none" | "custom" | "latest";
|
||||
|
||||
const TARGET_OPTIONS: CustomOptionType[] = [
|
||||
{ label: "No updates enforced", value: "none" },
|
||||
{ label: "Custom version", value: "custom" },
|
||||
{ label: "Latest version", value: "latest" },
|
||||
];
|
||||
|
||||
export const getTargetFromMinOsVersion = (
|
||||
minOsVersion: string
|
||||
): AppleOSTarget => {
|
||||
if (minOsVersion === LATEST_VERSION) return "latest";
|
||||
return minOsVersion ? "custom" : "none";
|
||||
};
|
||||
|
||||
interface IAppleOSTargetFormData {
|
||||
target: AppleOSTarget;
|
||||
minOsVersion: string;
|
||||
deadline: string;
|
||||
deadlineDays: string;
|
||||
}
|
||||
|
||||
interface IAppleOSTargetFormErrors {
|
||||
minOsVersion?: string;
|
||||
deadline?: string;
|
||||
deadlineDays?: string;
|
||||
}
|
||||
|
||||
const validateMinVersion = (value: string) => {
|
||||
@@ -38,17 +64,36 @@ const validateDeadline = (value: string) => {
|
||||
return /^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$/.test(value);
|
||||
};
|
||||
|
||||
/** Whole days only, and there's no upper bound. A deadline of zero days would
|
||||
* leave no time to install the update, so 1 is the lowest meaningful value. */
|
||||
const validateDeadlineDays = (value: string) => {
|
||||
return /^[1-9]\d*$/.test(value);
|
||||
};
|
||||
|
||||
const validateForm = (formData: IAppleOSTargetFormData) => {
|
||||
const errors: IAppleOSTargetFormErrors = {};
|
||||
|
||||
// Both fields may be cleared out and saved
|
||||
if (
|
||||
!validatePresence(formData.minOsVersion) &&
|
||||
!validatePresence(formData.deadline)
|
||||
) {
|
||||
// Nothing to validate: saving clears the version and the deadline.
|
||||
if (formData.target === "none") {
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Only the days field is shown in "latest" mode, so it's the only one that
|
||||
// can be at fault — validating the hidden fields would block the save on an
|
||||
// error the user can't see.
|
||||
if (formData.target === "latest") {
|
||||
if (!validatePresence(formData.deadlineDays)) {
|
||||
errors.deadlineDays = "The days after release is required.";
|
||||
} else if (!validateDeadlineDays(formData.deadlineDays)) {
|
||||
errors.deadlineDays =
|
||||
"Days after release must be a whole number of 1 or more.";
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Both fields are required for a custom version: "No updates enforced" is
|
||||
// how enforcement is turned off, so an empty form here isn't a way to clear
|
||||
// the settings.
|
||||
if (!validatePresence(formData.minOsVersion)) {
|
||||
errors.minOsVersion = "The minimum version is required.";
|
||||
} else if (!validateMinVersion(formData.minOsVersion)) {
|
||||
@@ -70,34 +115,55 @@ const APPLE_PLATFORMS_TO_CONFIG_FIELDS = {
|
||||
ipados: "ipados_updates",
|
||||
};
|
||||
|
||||
interface IAppleOSUpdatesFields {
|
||||
minimum_version: string;
|
||||
deadline: string;
|
||||
deadline_days: number | null;
|
||||
update_new_hosts?: boolean;
|
||||
}
|
||||
|
||||
interface IAppleUpdatesMdmConfigData {
|
||||
mdm: {
|
||||
macos_updates?: {
|
||||
minimum_version: string;
|
||||
deadline: string;
|
||||
};
|
||||
ipados_updates?: {
|
||||
minimum_version: string;
|
||||
deadline: string;
|
||||
};
|
||||
ios_updates?: {
|
||||
minimum_version: string;
|
||||
deadline: string;
|
||||
};
|
||||
macos_updates?: IAppleOSUpdatesFields;
|
||||
ipados_updates?: IAppleOSUpdatesFields;
|
||||
ios_updates?: IAppleOSUpdatesFields;
|
||||
};
|
||||
}
|
||||
|
||||
/** Every field is sent for every target, including nulls: the config PATCH
|
||||
* merges field by field, so an omitted key leaves the stored value in place. */
|
||||
const createAppleOSUpdatesData = (
|
||||
applePlatform: ApplePlatform,
|
||||
minOsVersion: string,
|
||||
deadline: string,
|
||||
updateNewHosts?: boolean
|
||||
formData: IAppleOSTargetFormData,
|
||||
updateNewHosts: boolean
|
||||
): IAppleUpdatesMdmConfigData => {
|
||||
const { target, minOsVersion, deadline, deadlineDays } = formData;
|
||||
|
||||
let fields: IAppleOSUpdatesFields;
|
||||
switch (target) {
|
||||
case "latest":
|
||||
fields = {
|
||||
minimum_version: LATEST_VERSION,
|
||||
// A deadline can't coexist with "latest"; deadline_days replaces it.
|
||||
deadline: "",
|
||||
deadline_days: parseInt(deadlineDays, 10),
|
||||
};
|
||||
break;
|
||||
case "custom":
|
||||
fields = {
|
||||
minimum_version: minOsVersion,
|
||||
deadline,
|
||||
deadline_days: null,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
fields = { minimum_version: "", deadline: "", deadline_days: null };
|
||||
}
|
||||
|
||||
return {
|
||||
mdm: {
|
||||
[APPLE_PLATFORMS_TO_CONFIG_FIELDS[applePlatform]]: {
|
||||
minimum_version: minOsVersion,
|
||||
deadline,
|
||||
...fields,
|
||||
// Add update_new_hosts only for macOS right now.
|
||||
...(applePlatform === "darwin"
|
||||
? { update_new_hosts: updateNewHosts }
|
||||
@@ -112,6 +178,7 @@ interface IAppleOSTargetFormProps {
|
||||
applePlatform: ApplePlatform;
|
||||
defaultMinOsVersion: string;
|
||||
defaultDeadline: string;
|
||||
defaultDeadlineDays: string;
|
||||
defaultUpdateNewHosts?: boolean;
|
||||
refetchAppConfig: () => void;
|
||||
refetchTeamConfig: () => void;
|
||||
@@ -122,6 +189,7 @@ const AppleOSTargetForm = ({
|
||||
applePlatform,
|
||||
defaultMinOsVersion,
|
||||
defaultDeadline,
|
||||
defaultDeadlineDays,
|
||||
defaultUpdateNewHosts,
|
||||
refetchAppConfig,
|
||||
refetchTeamConfig,
|
||||
@@ -130,8 +198,15 @@ const AppleOSTargetForm = ({
|
||||
.gitops_mode_enabled;
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [minOsVersion, setMinOsVersion] = useState(defaultMinOsVersion);
|
||||
const [target, setTarget] = useState<AppleOSTarget>(
|
||||
getTargetFromMinOsVersion(defaultMinOsVersion)
|
||||
);
|
||||
const [minOsVersion, setMinOsVersion] = useState(
|
||||
// The sentinel is a mode, not a version to show in the input.
|
||||
defaultMinOsVersion === LATEST_VERSION ? "" : defaultMinOsVersion
|
||||
);
|
||||
const [deadline, setDeadline] = useState(defaultDeadline);
|
||||
const [deadlineDays, setDeadlineDays] = useState(defaultDeadlineDays);
|
||||
const [minOsVersionError, setMinOsVersionError] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
@@ -139,45 +214,70 @@ const AppleOSTargetForm = ({
|
||||
defaultUpdateNewHosts || false
|
||||
);
|
||||
const [deadlineError, setDeadlineError] = useState<string | undefined>();
|
||||
const [deadlineDaysError, setDeadlineDaysError] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
|
||||
// "Latest version" always updates new hosts; the other targets leave it to
|
||||
// the user. Derived rather than forced into state, and shared with the payload
|
||||
// so what's saved matches what the checkbox shows.
|
||||
const effectiveUpdateNewHosts = target === "latest" ? true : updateNewHosts;
|
||||
|
||||
// FIXME: This behaves unexpectedly when a user switches tabs or changes the teams dropdown while the form is
|
||||
// submitting because this component is unmounted.
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const errors = validateForm({
|
||||
minOsVersion,
|
||||
deadline,
|
||||
});
|
||||
const formData = { target, minOsVersion, deadline, deadlineDays };
|
||||
const errors = validateForm(formData);
|
||||
|
||||
setMinOsVersionError(errors.minOsVersion);
|
||||
setDeadlineError(errors.deadline);
|
||||
setDeadlineDaysError(errors.deadlineDays);
|
||||
|
||||
if (isEmpty(errors)) {
|
||||
setIsSaving(true);
|
||||
const updateData = createAppleOSUpdatesData(
|
||||
applePlatform,
|
||||
minOsVersion,
|
||||
deadline,
|
||||
updateNewHosts
|
||||
formData,
|
||||
effectiveUpdateNewHosts
|
||||
);
|
||||
try {
|
||||
currentTeamId === APP_CONTEXT_NO_TEAM_ID
|
||||
? await configAPI.update(updateData)
|
||||
: await teamsAPI.update(updateData, currentTeamId);
|
||||
notify.success("Successfully updated.");
|
||||
// Only refetch on success: the refetch flips isFetching, which unmounts
|
||||
// this form behind TargetSection's spinner and remounts it against the
|
||||
// stored config -- that's what resets it. After a rejected save nothing
|
||||
// changed on the server, so resetting would just discard what the user
|
||||
// needs to fix.
|
||||
currentTeamId === APP_CONTEXT_NO_TEAM_ID
|
||||
? refetchAppConfig()
|
||||
: refetchTeamConfig();
|
||||
} catch (err) {
|
||||
notify.error(getErrorMessage(err as AxiosResponse<IApiError>), {
|
||||
response: err,
|
||||
});
|
||||
} finally {
|
||||
currentTeamId === APP_CONTEXT_NO_TEAM_ID
|
||||
? refetchAppConfig()
|
||||
: refetchTeamConfig();
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTargetChange = (option: CustomOptionType | null) => {
|
||||
if (!option) return;
|
||||
setTarget(option.value as AppleOSTarget);
|
||||
// "Latest version" forces the checkbox on, which isn't a choice the user
|
||||
// made, so send it back to the persisted value rather than carrying the
|
||||
// previous selection over. The text inputs keep whatever was typed.
|
||||
setUpdateNewHosts(defaultUpdateNewHosts || false);
|
||||
// The fields these belong to are about to unmount, and a stale message
|
||||
// would reappear if the user came back to this target.
|
||||
setMinOsVersionError(undefined);
|
||||
setDeadlineError(undefined);
|
||||
setDeadlineDaysError(undefined);
|
||||
};
|
||||
|
||||
const handleMinVersionChange = (val: string) => {
|
||||
setMinOsVersion(val);
|
||||
};
|
||||
@@ -192,44 +292,87 @@ const AppleOSTargetForm = ({
|
||||
|
||||
return (
|
||||
<form className={baseClass} onSubmit={handleSubmit}>
|
||||
<InputField
|
||||
label="Minimum version"
|
||||
name="minimum_version"
|
||||
disabled={gitOpsModeEnabled}
|
||||
tooltip={getMinimumVersionTooltip()}
|
||||
<DropdownWrapper
|
||||
label="Target"
|
||||
name="target"
|
||||
options={TARGET_OPTIONS}
|
||||
value={target}
|
||||
isDisabled={gitOpsModeEnabled}
|
||||
onChange={handleTargetChange}
|
||||
helpText={
|
||||
<>
|
||||
Use only versions{" "}
|
||||
<CustomLink
|
||||
text="available from Apple"
|
||||
newTab
|
||||
url="https://fleetdm.com/learn-more-about/apple-available-os-updates"
|
||||
/>
|
||||
</>
|
||||
target === "latest" ? (
|
||||
<>
|
||||
Based on host hardware.{" "}
|
||||
<CustomLink
|
||||
text="Learn more"
|
||||
newTab
|
||||
url="https://fleetdm.com/learn-more-about/apple-available-os-updates"
|
||||
/>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
value={minOsVersion}
|
||||
error={minOsVersionError}
|
||||
onChange={handleMinVersionChange}
|
||||
/>
|
||||
<InputField
|
||||
disabled={gitOpsModeEnabled}
|
||||
name="deadline"
|
||||
label="Deadline"
|
||||
tooltip="The end user can't dismiss the OS update once they reach this deadline. Deadline is 12:00 (Noon), the host's local time."
|
||||
helpText="YYYY-MM-DD format only (e.g., “2024-07-01”)."
|
||||
value={deadline}
|
||||
error={deadlineError}
|
||||
onChange={handleDeadlineChange}
|
||||
/>
|
||||
{target === "custom" && (
|
||||
<>
|
||||
<InputField
|
||||
label="Minimum version"
|
||||
name="minimum_version"
|
||||
disabled={gitOpsModeEnabled}
|
||||
tooltip={getMinimumVersionTooltip()}
|
||||
helpText={
|
||||
<>
|
||||
Use only versions{" "}
|
||||
<CustomLink
|
||||
text="available from Apple."
|
||||
newTab
|
||||
url="https://fleetdm.com/learn-more-about/apple-available-os-updates"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
value={minOsVersion}
|
||||
error={minOsVersionError}
|
||||
onChange={handleMinVersionChange}
|
||||
/>
|
||||
<InputField
|
||||
disabled={gitOpsModeEnabled}
|
||||
name="deadline"
|
||||
label="Deadline"
|
||||
tooltip="The end user can't dismiss the OS update once they reach this deadline. Deadline is 12:00 (Noon), the host's local time."
|
||||
helpText="YYYY-MM-DD format only (e.g., “2024-07-01”)."
|
||||
value={deadline}
|
||||
error={deadlineError}
|
||||
onChange={handleDeadlineChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{target === "latest" && (
|
||||
<InputField
|
||||
disabled={gitOpsModeEnabled}
|
||||
name="deadline_days"
|
||||
label="Days after release"
|
||||
// Deliberately a text input: a number input's native min/step would
|
||||
// block submission before validateForm runs, so the user would get a
|
||||
// browser tooltip instead of the form's own error styling.
|
||||
helpText="Whole number of days, 1 or more."
|
||||
tooltip="The number of days after Apple releases an update before hosts are required to install it."
|
||||
value={deadlineDays}
|
||||
error={deadlineDaysError}
|
||||
onChange={setDeadlineDays}
|
||||
/>
|
||||
)}
|
||||
{applePlatform === "darwin" && (
|
||||
<Checkbox
|
||||
name="update_new_hosts"
|
||||
disabled={gitOpsModeEnabled}
|
||||
// "Latest version" always updates new hosts, so the choice is made
|
||||
// for the user rather than hidden from them.
|
||||
disabled={gitOpsModeEnabled || target === "latest"}
|
||||
onChange={setUpdateNewHosts}
|
||||
value={updateNewHosts}
|
||||
value={effectiveUpdateNewHosts}
|
||||
className={`${baseClass}__checkbox`}
|
||||
labelTooltipContent={
|
||||
"During automated enrollment (ADE), hosts below the minimum version are updated to the latest version. If a minimum version isn't set, all hosts are updated to the latest version."
|
||||
target === "latest"
|
||||
? "During automated enrollment (ADE), all hosts will be updated to latest macOS version."
|
||||
: "During automated enrollment (ADE), hosts below the minimum version are updated to the latest version. If a minimum version isn't set, all hosts are updated to the latest version."
|
||||
}
|
||||
>
|
||||
Update new hosts to latest
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.apple-os-target-form {
|
||||
// The target dropdown, the fields it reveals, the checkbox and the save
|
||||
// button are stacked siblings, so spacing belongs to the form rather than
|
||||
// to each field.
|
||||
@include vertical-form-layout;
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { noop } from "lodash";
|
||||
|
||||
import { createCustomRenderer } from "test/test-utils";
|
||||
|
||||
import PlatformTabs from "./PlatformTabs";
|
||||
|
||||
const render = createCustomRenderer({ withBackendMock: true });
|
||||
|
||||
const defaultProps = {
|
||||
currentTeamId: 1,
|
||||
defaultMacOSVersion: "11.0",
|
||||
defaultMacOSDeadline: "2024-12-31",
|
||||
defaultMacOSDeadlineDays: "",
|
||||
defaultMacOSUpdateNewHosts: true,
|
||||
defaultIOSVersion: "17.5",
|
||||
defaultIOSDeadline: "2024-12-31",
|
||||
defaultIOSDeadlineDays: "",
|
||||
defaultIPadOSVersion: "18.5",
|
||||
defaultIPadOSDeadline: "2024-12-31",
|
||||
defaultIPadOSDeadlineDays: "",
|
||||
defaultWindowsDeadlineDays: "5",
|
||||
defaultWindowsGracePeriodDays: "2",
|
||||
onSelectPlatform: noop,
|
||||
refetchAppConfig: noop,
|
||||
refetchTeamConfig: noop,
|
||||
isWindowsMdmEnabled: true,
|
||||
isAndroidMdmEnabled: true,
|
||||
};
|
||||
|
||||
describe("PlatformTabs", () => {
|
||||
// Only the Apple forms offer a target to choose; Windows is always deadline
|
||||
// driven and Android isn't supported yet. The tabs decide which form each
|
||||
// platform gets, so the dropdown must not leak into the other two.
|
||||
it("renders the target dropdown on the macOS tab", () => {
|
||||
render(<PlatformTabs {...defaultProps} selectedPlatform="darwin" />);
|
||||
|
||||
expect(screen.getByLabelText(/Target/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Minimum version/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the target dropdown on the iOS tab", () => {
|
||||
render(<PlatformTabs {...defaultProps} selectedPlatform="ios" />);
|
||||
|
||||
expect(screen.getByLabelText(/Target/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the target dropdown on the iPadOS tab", () => {
|
||||
render(<PlatformTabs {...defaultProps} selectedPlatform="ipados" />);
|
||||
|
||||
expect(screen.getByLabelText(/Target/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the target dropdown on the Windows tab", () => {
|
||||
render(<PlatformTabs {...defaultProps} selectedPlatform="windows" />);
|
||||
|
||||
expect(screen.queryByLabelText(/Target/i)).not.toBeInTheDocument();
|
||||
// Windows fields don't associate their labels with the input, so match the
|
||||
// label text rather than the control.
|
||||
expect(screen.getByText(/Grace period/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the target dropdown on the Android tab", () => {
|
||||
render(<PlatformTabs {...defaultProps} selectedPlatform="android" />);
|
||||
|
||||
expect(screen.queryByLabelText(/Target/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Android updates are coming soon/i)).toBeVisible();
|
||||
});
|
||||
|
||||
it("hides the Windows and Android tabs when their MDM isn't enabled", () => {
|
||||
render(
|
||||
<PlatformTabs
|
||||
{...defaultProps}
|
||||
selectedPlatform="darwin"
|
||||
isWindowsMdmEnabled={false}
|
||||
isAndroidMdmEnabled={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("tab", { name: /macOS/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("tab", { name: /Windows/i })
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("tab", { name: /Android/i })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -19,11 +19,14 @@ interface IPlatformTabsProps {
|
||||
currentTeamId: number;
|
||||
defaultMacOSVersion: string;
|
||||
defaultMacOSDeadline: string;
|
||||
defaultMacOSDeadlineDays: string;
|
||||
defaultMacOSUpdateNewHosts: boolean;
|
||||
defaultIOSVersion: string;
|
||||
defaultIOSDeadline: string;
|
||||
defaultIOSDeadlineDays: string;
|
||||
defaultIPadOSVersion: string;
|
||||
defaultIPadOSDeadline: string;
|
||||
defaultIPadOSDeadlineDays: string;
|
||||
defaultWindowsDeadlineDays: string;
|
||||
defaultWindowsGracePeriodDays: string;
|
||||
selectedPlatform: OSUpdatesTargetPlatform;
|
||||
@@ -37,11 +40,14 @@ interface IPlatformTabsProps {
|
||||
const PlatformTabs = ({
|
||||
currentTeamId,
|
||||
defaultMacOSDeadline,
|
||||
defaultMacOSDeadlineDays,
|
||||
defaultMacOSVersion,
|
||||
defaultMacOSUpdateNewHosts,
|
||||
defaultIOSDeadline,
|
||||
defaultIOSDeadlineDays,
|
||||
defaultIOSVersion,
|
||||
defaultIPadOSDeadline,
|
||||
defaultIPadOSDeadlineDays,
|
||||
defaultIPadOSVersion,
|
||||
defaultWindowsDeadlineDays,
|
||||
defaultWindowsGracePeriodDays,
|
||||
@@ -108,6 +114,7 @@ const PlatformTabs = ({
|
||||
applePlatform="darwin"
|
||||
defaultMinOsVersion={defaultMacOSVersion}
|
||||
defaultDeadline={defaultMacOSDeadline}
|
||||
defaultDeadlineDays={defaultMacOSDeadlineDays}
|
||||
defaultUpdateNewHosts={defaultMacOSUpdateNewHosts}
|
||||
key={currentTeamId}
|
||||
refetchAppConfig={refetchAppConfig}
|
||||
@@ -142,6 +149,7 @@ const PlatformTabs = ({
|
||||
applePlatform="ios"
|
||||
defaultMinOsVersion={defaultIOSVersion}
|
||||
defaultDeadline={defaultIOSDeadline}
|
||||
defaultDeadlineDays={defaultIOSDeadlineDays}
|
||||
key={currentTeamId}
|
||||
refetchAppConfig={refetchAppConfig}
|
||||
refetchTeamConfig={refetchTeamConfig}
|
||||
@@ -158,6 +166,7 @@ const PlatformTabs = ({
|
||||
applePlatform="ipados"
|
||||
defaultMinOsVersion={defaultIPadOSVersion}
|
||||
defaultDeadline={defaultIPadOSDeadline}
|
||||
defaultDeadlineDays={defaultIPadOSDeadlineDays}
|
||||
key={currentTeamId}
|
||||
refetchAppConfig={refetchAppConfig}
|
||||
refetchTeamConfig={refetchTeamConfig}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.platform-tabs {
|
||||
&__tab-panel {
|
||||
display: grid;
|
||||
gap: $pad-medium;
|
||||
gap: $pad-large; // match figma
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-areas: "target nudge-preview";
|
||||
|
||||
@@ -27,9 +27,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.apple-os-target-form,
|
||||
.windows-target-form {
|
||||
padding-right: $pad-xlarge; // match figma
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -40,6 +40,29 @@ const getDefaultUpdateNewHosts = ({
|
||||
}
|
||||
};
|
||||
|
||||
/** deadline_days is only set in "latest" mode; an empty string means unset,
|
||||
* matching how the version and deadline defaults are handled. */
|
||||
const getDefaultAppleDeadlineDays = ({
|
||||
osType,
|
||||
currentTeamId,
|
||||
appConfig,
|
||||
teamConfig,
|
||||
}: GetDefaultFnParams) => {
|
||||
const mdmData =
|
||||
currentTeamId === API_NO_TEAM_ID ? appConfig?.mdm : teamConfig?.mdm;
|
||||
|
||||
switch (osType) {
|
||||
case "darwin":
|
||||
return mdmData?.macos_updates.deadline_days?.toString() ?? "";
|
||||
case "ios":
|
||||
return mdmData?.ios_updates.deadline_days?.toString() ?? "";
|
||||
case "ipados":
|
||||
return mdmData?.ipados_updates.deadline_days?.toString() ?? "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const getDefaultOSVersion = ({
|
||||
osType,
|
||||
currentTeamId,
|
||||
@@ -170,6 +193,24 @@ const TargetSection = ({
|
||||
appConfig,
|
||||
teamConfig,
|
||||
});
|
||||
const defaultMacOSDeadlineDays = getDefaultAppleDeadlineDays({
|
||||
osType: "darwin",
|
||||
currentTeamId,
|
||||
appConfig,
|
||||
teamConfig,
|
||||
});
|
||||
const defaultIOSDeadlineDays = getDefaultAppleDeadlineDays({
|
||||
osType: "ios",
|
||||
currentTeamId,
|
||||
appConfig,
|
||||
teamConfig,
|
||||
});
|
||||
const defaultIPadOSDeadlineDays = getDefaultAppleDeadlineDays({
|
||||
osType: "ipados",
|
||||
currentTeamId,
|
||||
appConfig,
|
||||
teamConfig,
|
||||
});
|
||||
const defaultMacOSUpdateNewHosts = getDefaultUpdateNewHosts({
|
||||
osType: "darwin",
|
||||
currentTeamId,
|
||||
@@ -205,10 +246,13 @@ const TargetSection = ({
|
||||
currentTeamId={currentTeamId}
|
||||
defaultMacOSVersion={defaultMacOSVersion}
|
||||
defaultMacOSDeadline={defaultMacOSDeadline}
|
||||
defaultMacOSDeadlineDays={defaultMacOSDeadlineDays}
|
||||
defaultIOSVersion={defaultIOSVersion}
|
||||
defaultIOSDeadline={defaultIOSDeadline}
|
||||
defaultIOSDeadlineDays={defaultIOSDeadlineDays}
|
||||
defaultIPadOSVersion={defaultIPadOSOSVersion}
|
||||
defaultIPadOSDeadline={defaultIPadOSDeadline}
|
||||
defaultIPadOSDeadlineDays={defaultIPadOSDeadlineDays}
|
||||
defaultWindowsDeadlineDays={defaultWindowsDeadlineDays}
|
||||
defaultWindowsGracePeriodDays={defaultWindowsGracePeriodDays}
|
||||
defaultMacOSUpdateNewHosts={defaultMacOSUpdateNewHosts}
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ const WindowsTargetForm = ({
|
||||
<form className={baseClass} onSubmit={handleSubmit}>
|
||||
<InputField
|
||||
disabled={gitOpsModeEnabled}
|
||||
label="Deadline"
|
||||
label="Days after release"
|
||||
tooltip="Number of days the end user has before updates are installed and the host is forced to restart."
|
||||
helpText="Number of days from 0 to 30."
|
||||
value={formData.deadlineDays}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.windows-target-form {
|
||||
// Matches the Apple target form so the tabs are consistent: the fields and
|
||||
// the save button are stacked siblings, spaced by the form.
|
||||
@include vertical-form-layout;
|
||||
}
|
||||
@@ -654,19 +654,6 @@ const HostDetailsPage = ({
|
||||
? teams?.find((t) => t.id === host.team_id)?.features
|
||||
: config?.features;
|
||||
|
||||
const getOSVersionRequirementFromMDMConfig = (hostPlatform: string) => {
|
||||
switch (hostPlatform) {
|
||||
case "darwin":
|
||||
return mdmConfig?.macos_updates;
|
||||
case "ipados":
|
||||
return mdmConfig?.ipados_updates;
|
||||
case "ios":
|
||||
return mdmConfig?.ios_updates;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setUsersState(() => {
|
||||
return (
|
||||
@@ -1540,9 +1527,8 @@ const HostDetailsPage = ({
|
||||
vitalsData={vitalsData}
|
||||
munki={macadmins?.munki}
|
||||
mdm={host?.mdm}
|
||||
osVersionRequirement={getOSVersionRequirementFromMDMConfig(
|
||||
host.platform
|
||||
)}
|
||||
osUpdateMinimumVersion={host.os_update_minimum_version}
|
||||
osUpdateDeadline={host.os_update_deadline}
|
||||
toggleLocationModal={toggleLocationModal}
|
||||
toggleMDMStatusModal={toggleMDMStatusModal}
|
||||
toggleVitalsModal={toggleVitalsModal}
|
||||
@@ -2087,9 +2073,8 @@ const HostDetailsPage = ({
|
||||
vitalsData={vitalsData}
|
||||
munki={macadmins?.munki}
|
||||
mdm={host?.mdm}
|
||||
osVersionRequirement={getOSVersionRequirementFromMDMConfig(
|
||||
host.platform
|
||||
)}
|
||||
osUpdateMinimumVersion={host.os_update_minimum_version}
|
||||
osUpdateDeadline={host.os_update_deadline}
|
||||
toggleLocationModal={toggleLocationModal}
|
||||
toggleMDMStatusModal={toggleMDMStatusModal}
|
||||
customHostVitals={host.custom_host_vitals}
|
||||
|
||||
@@ -996,4 +996,72 @@ describe("Custom host vitals", () => {
|
||||
|
||||
expect(onEditCustomHostVital).toHaveBeenCalledWith(customHostVitals[0]);
|
||||
});
|
||||
|
||||
describe("Operating system OS update requirement", () => {
|
||||
const renderWithRequirement = createCustomRenderer({});
|
||||
|
||||
it("shows the required version and deadline", async () => {
|
||||
const mockHost = createMockHost({
|
||||
platform: "darwin",
|
||||
os_version: "macOS 26.5",
|
||||
});
|
||||
|
||||
const { user } = renderWithRequirement(
|
||||
<Vitals
|
||||
vitalsData={mockHost}
|
||||
osUpdateMinimumVersion="26.6"
|
||||
osUpdateDeadline="2026-07-30"
|
||||
/>
|
||||
);
|
||||
|
||||
await user.hover(screen.getByText("macOS 26.5"));
|
||||
|
||||
await waitFor(() => {
|
||||
const tooltip = screen.getByText(/Minimum version required:/i);
|
||||
expect(tooltip).toBeVisible();
|
||||
expect(tooltip).toHaveTextContent("Minimum version required: 26.6");
|
||||
expect(tooltip).toHaveTextContent("Deadline: 2026-07-30");
|
||||
});
|
||||
|
||||
// The values are bolded, the labels aren't.
|
||||
expect(screen.getByText("26.6").tagName).toBe("B");
|
||||
expect(screen.getByText("2026-07-30").tagName).toBe("B");
|
||||
});
|
||||
|
||||
it("shows Pending while the target is still being resolved", async () => {
|
||||
const mockHost = createMockHost({
|
||||
platform: "darwin",
|
||||
os_version: "macOS 26.5",
|
||||
});
|
||||
|
||||
const { user } = renderWithRequirement(
|
||||
<Vitals
|
||||
vitalsData={mockHost}
|
||||
osUpdateMinimumVersion="Pending"
|
||||
osUpdateDeadline="Pending"
|
||||
/>
|
||||
);
|
||||
|
||||
await user.hover(screen.getByText("macOS 26.5"));
|
||||
|
||||
await waitFor(() => {
|
||||
const tooltip = screen.getByText(/Minimum version required:/i);
|
||||
expect(tooltip).toBeVisible();
|
||||
expect(tooltip).toHaveTextContent("Minimum version required: Pending");
|
||||
expect(tooltip).toHaveTextContent("Deadline: Pending");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders no tooltip when there's no requirement", () => {
|
||||
const mockHost = createMockHost({
|
||||
platform: "darwin",
|
||||
os_version: "macOS 26.5",
|
||||
});
|
||||
|
||||
renderWithRequirement(<Vitals vitalsData={mockHost} />);
|
||||
|
||||
expect(screen.getByText("macOS 26.5")).toBeVisible();
|
||||
expect(screen.queryByText(/Minimum version required/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import classnames from "classnames";
|
||||
|
||||
import { IAppleDeviceUpdates } from "interfaces/config";
|
||||
import { IHostCustomVital } from "interfaces/custom_host_vitals";
|
||||
import { IHostMdmData, IMunkiData } from "interfaces/host";
|
||||
import {
|
||||
@@ -45,7 +44,12 @@ export interface IHostVitalsSources {
|
||||
vitalsData: { [key: string]: any };
|
||||
munki?: IMunkiData | null;
|
||||
mdm?: IHostMdmData;
|
||||
osVersionRequirement?: IAppleDeviceUpdates;
|
||||
/** The OS version the host is required to reach, resolved per host by the
|
||||
* server. "Pending" while Fleet is still working it out for a "latest"
|
||||
* requirement, and undefined when OS updates aren't configured. */
|
||||
osUpdateMinimumVersion?: string | null;
|
||||
/** The deadline for osUpdateMinimumVersion, following the same states. */
|
||||
osUpdateDeadline?: string | null;
|
||||
/**
|
||||
* Opens the Location modal. Presence of this handler also makes the
|
||||
* Location row interactive — omit it for read-only contexts (e.g., the
|
||||
@@ -99,6 +103,10 @@ const getGridColumnCount = (grid: HTMLElement | null) => {
|
||||
|
||||
const baseClass = "vitals-card";
|
||||
|
||||
/** What the API sends for a host's OS update target while Fleet is still
|
||||
* resolving a "latest" requirement. Shown to the user as-is, per the design. */
|
||||
const OS_UPDATE_REQUIREMENT_PENDING = "Pending";
|
||||
|
||||
const DISK_ENCRYPTION_MESSAGES = {
|
||||
darwin: {
|
||||
enabled: (
|
||||
@@ -166,7 +174,8 @@ export const buildHostVitals = ({
|
||||
vitalsData,
|
||||
munki,
|
||||
mdm,
|
||||
osVersionRequirement,
|
||||
osUpdateMinimumVersion,
|
||||
osUpdateDeadline,
|
||||
toggleLocationModal,
|
||||
toggleMDMStatusModal,
|
||||
customHostVitals,
|
||||
@@ -544,8 +553,8 @@ export const buildHostVitals = ({
|
||||
}
|
||||
|
||||
// Operating system
|
||||
// No tooltip if minimum version is not set, including all Windows, Linux, ChromeOS, Android operating systems
|
||||
if (!osVersionRequirement?.minimum_version) {
|
||||
// No tooltip if there's no requirement, including all Windows, Linux, ChromeOS, Android operating systems
|
||||
if (!osUpdateMinimumVersion) {
|
||||
const version = vitalsData.os_version;
|
||||
const versionForRender = ROLLING_ARCH_LINUX_VERSIONS.includes(version) ? (
|
||||
<>
|
||||
@@ -567,11 +576,16 @@ export const buildHostVitals = ({
|
||||
),
|
||||
});
|
||||
} else {
|
||||
const osVersionWithoutPrefix = removeOSPrefix(vitalsData.os_version);
|
||||
// A "latest" requirement is resolved per host by the server, which sends
|
||||
// "Pending" until it has worked the target out. There's nothing to
|
||||
// compare against until then, so no compliance icon is shown.
|
||||
const isPendingRequirement =
|
||||
osUpdateMinimumVersion === OS_UPDATE_REQUIREMENT_PENDING;
|
||||
const osVersionRequirementMet =
|
||||
isPendingRequirement ||
|
||||
compareVersions(
|
||||
osVersionWithoutPrefix,
|
||||
osVersionRequirement.minimum_version
|
||||
removeOSPrefix(vitalsData.os_version),
|
||||
osUpdateMinimumVersion
|
||||
) >= 0;
|
||||
|
||||
vitals.push({
|
||||
@@ -588,21 +602,11 @@ export const buildHostVitals = ({
|
||||
<TooltipWrapper
|
||||
className={`${baseClass}__os-version-tooltip`}
|
||||
tipContent={
|
||||
osVersionRequirementMet ? (
|
||||
<>
|
||||
{vitalsData.os_version}
|
||||
<br />
|
||||
Meets minimum version requirement.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{vitalsData.os_version}
|
||||
<br />
|
||||
Does not meet minimum version requirement.
|
||||
<br />
|
||||
Deadline to update: {osVersionRequirement.deadline}
|
||||
</>
|
||||
)
|
||||
<>
|
||||
Minimum version required: <b>{osUpdateMinimumVersion}</b>
|
||||
<br />
|
||||
Deadline: <b>{osUpdateDeadline}</b>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<span className={`${baseClass}__os-version-text`}>
|
||||
@@ -729,7 +733,8 @@ const Vitals = ({
|
||||
vitalsData,
|
||||
munki,
|
||||
mdm,
|
||||
osVersionRequirement,
|
||||
osUpdateMinimumVersion,
|
||||
osUpdateDeadline,
|
||||
className,
|
||||
toggleLocationModal,
|
||||
toggleMDMStatusModal,
|
||||
@@ -760,7 +765,8 @@ const Vitals = ({
|
||||
vitalsData,
|
||||
munki,
|
||||
mdm,
|
||||
osVersionRequirement,
|
||||
osUpdateMinimumVersion,
|
||||
osUpdateDeadline,
|
||||
toggleLocationModal,
|
||||
toggleMDMStatusModal,
|
||||
customHostVitals,
|
||||
|
||||
@@ -78,6 +78,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// TooltipWrapper pulls its dashed underline into the element's last pixel
|
||||
// with a negative margin, which DataSet's `dd` then clips away. Keep the
|
||||
// border inside the box so the hover affordance survives on every vital.
|
||||
.component__tooltip-wrapper__underline {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&__os-version-tooltip {
|
||||
min-width: 0;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user