Skip to content

Commit 67ae89e

Browse files
feat(updates): add release update notification
1 parent 370f44e commit 67ae89e

21 files changed

Lines changed: 652 additions & 16 deletions

File tree

client/src/protoFleet/components/AppLayout/AppLayout.test.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const mockUseWindowDimensions = vi.fn();
1313
const mockUseReactiveLocalStorage = vi.fn();
1414
const mockUseCurtailmentPillData = vi.fn();
1515
const mockUseSchedulePillData = vi.fn();
16+
const mockUseUpdateNotification = vi.fn();
1617

1718
vi.mock("@/protoFleet/api/ScheduleApiProvider", () => ({
1819
ScheduleApiProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
@@ -42,6 +43,15 @@ vi.mock("@/protoFleet/components/PageHeader/useCurtailmentPillData", () => ({
4243
useCurtailmentPillData: () => mockUseCurtailmentPillData(),
4344
}));
4445

46+
vi.mock("@/protoFleet/features/updates/useUpdateNotification", () => ({
47+
useUpdateNotification: () => mockUseUpdateNotification(),
48+
}));
49+
50+
vi.mock("@/protoFleet/features/updates/components/UpdateNotificationModal", () => ({
51+
__esModule: true,
52+
default: ({ open }: { open: boolean }) => (open ? <div>Update modal</div> : null),
53+
}));
54+
4555
vi.mock("@/shared/hooks/useWindowDimensions", () => ({
4656
useWindowDimensions: () => mockUseWindowDimensions(),
4757
}));
@@ -99,6 +109,13 @@ describe("AppLayout", () => {
99109
mockUseReactiveLocalStorage.mockReturnValue([false, vi.fn()]);
100110
mockUseCurtailmentPillData.mockReturnValue({ activeEvent: null });
101111
mockUseSchedulePillData.mockReturnValue(createSchedulePillData());
112+
mockUseUpdateNotification.mockReturnValue({
113+
closeModal: vi.fn(),
114+
installCommand: "",
115+
modalOpen: false,
116+
release: undefined,
117+
updatePill: null,
118+
});
102119
vi.mocked(useHasPermission).mockReturnValue(true);
103120
});
104121

@@ -223,6 +240,32 @@ describe("AppLayout", () => {
223240
expect(screen.getByText("Body content").parentElement).toHaveClass("phone:top-[calc(theme(spacing.1)*12+40px)]");
224241
});
225242

243+
it("includes the dismissed update pill in phone content offset calculations", () => {
244+
mockUseReactiveLocalStorage.mockReturnValue([true, vi.fn()]);
245+
mockUseSchedulePillData.mockReturnValue(
246+
createSchedulePillData({
247+
pillSchedule: createPillSchedule(),
248+
}),
249+
);
250+
mockUseUpdateNotification.mockReturnValue({
251+
closeModal: vi.fn(),
252+
installCommand: "install",
253+
modalOpen: false,
254+
release: { version: "v1.3.0" },
255+
updatePill: { version: "v1.3.0", onClick: vi.fn() },
256+
});
257+
258+
render(
259+
<MemoryRouter>
260+
<AppLayout>
261+
<div>Body content</div>
262+
</AppLayout>
263+
</MemoryRouter>,
264+
);
265+
266+
expect(screen.getByText("Body content").parentElement).toHaveClass("phone:top-[calc(theme(spacing.1)*12+80px)]");
267+
});
268+
226269
it("keeps the base phone content offset when the only curtailment widget fits inline", () => {
227270
mockUseCurtailmentPillData.mockReturnValue({ activeEvent: activeCurtailmentEvent });
228271

client/src/protoFleet/components/AppLayout/AppLayout.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
import { useCurtailmentPillData } from "@/protoFleet/components/PageHeader/useCurtailmentPillData";
1717
import { useSchedulePillData } from "@/protoFleet/components/PageHeader/useSchedulePillData";
1818
import { primaryNavItems } from "@/protoFleet/config/navItems";
19+
import UpdateNotificationModal from "@/protoFleet/features/updates/components/UpdateNotificationModal";
20+
import { useUpdateNotification } from "@/protoFleet/features/updates/useUpdateNotification";
1921
import { usePageBackground } from "@/protoFleet/hooks/usePageBackground";
2022
import { useHasPermission } from "@/protoFleet/store";
2123
import { Menu } from "@/shared/assets/icons";
@@ -34,11 +36,14 @@ const AppLayoutContent = ({ children, hideShellHeader = false }: Props) => {
3436
const [dismissedSetup] = useReactiveLocalStorage<boolean>("completeSetupDismissed");
3537
const schedulePillData = useSchedulePillData();
3638
const { activeEvent: activeCurtailmentEvent } = useCurtailmentPillData();
39+
const updateNotification = useUpdateNotification();
3740
const hasDismissedSetup = Boolean(dismissedSetup);
3841
const canReadCurtailment = useHasPermission("curtailment:read");
3942
const hasVisibleCurtailmentPill = activeCurtailmentEvent !== null && canReadCurtailment;
43+
const hasVisibleUpdatePill = updateNotification.updatePill !== null;
4044
const headerWidgetCount = getVisibleHeaderWidgetCount({
4145
hasDismissedSetup,
46+
hasVisibleUpdatePill,
4247
hasVisibleCurtailmentPill,
4348
hasVisibleSchedules: schedulePillData.hasVisibleSchedules,
4449
});
@@ -94,10 +99,18 @@ const AppLayoutContent = ({ children, hideShellHeader = false }: Props) => {
9499
isMenuOpen={isMenuOpen}
95100
openMenu={() => setIsMenuOpen(true)}
96101
schedulePillData={schedulePillData}
102+
updatePill={updateNotification.updatePill}
97103
/>
98104
</div>
99105
)}
100106

107+
<UpdateNotificationModal
108+
open={updateNotification.modalOpen}
109+
release={updateNotification.release}
110+
installCommand={updateNotification.installCommand}
111+
onDismiss={updateNotification.closeModal}
112+
/>
113+
101114
<div
102115
ref={scrollRef}
103116
className={clsx(

client/src/protoFleet/components/NavigationMenu/NavigationMenu.stories.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,50 @@
11
import { ElementType } from "react";
22
import { MemoryRouter } from "react-router-dom";
33

4+
import type { StoryFn } from "@storybook/react";
45
import { action } from "storybook/actions";
56
import NavigationMenuComponent from ".";
67
import { primaryNavItems } from "@/protoFleet/config/navItems";
8+
import { useFleetStore } from "@/protoFleet/store";
79

8-
export const NavigationMenu = () => {
10+
export const NavigationMenu: StoryFn = () => {
911
return <NavigationMenuComponent items={primaryNavItems} isVisible={true} closeMenu={action("close menu")} />;
1012
};
1113

14+
// Nav entries are permission-gated; seed the full set so the story shows the
15+
// complete menu. site:read additionally makes scopable links site-scoped, as
16+
// they are for a typical operator.
17+
const navPermissions = primaryNavItems.flatMap((item) => [
18+
...(item.requiredPermission ? [item.requiredPermission] : []),
19+
...(item.requiredAnyPermission ?? []).flatMap((requirement) =>
20+
Array.isArray(requirement) ? requirement : [requirement],
21+
),
22+
]);
23+
24+
const seedNavPermissions = () => {
25+
const previousPermissions = useFleetStore.getState().auth.permissions;
26+
useFleetStore.setState((state) => ({
27+
auth: { ...state.auth, permissions: [...new Set([...previousPermissions, ...navPermissions, "site:read"])] },
28+
}));
29+
return () => useFleetStore.setState((state) => ({ auth: { ...state.auth, permissions: [...previousPermissions] } }));
30+
};
31+
NavigationMenu.beforeEach = seedNavPermissions;
32+
1233
export default {
1334
title: "Proto Fleet/NavigationMenu",
35+
// Item visibility is permission-driven, so pin every story to the
36+
// unauthenticated baseline instead of whatever permissions an interrupted
37+
// story left persisted in the iframe's localStorage; seeded stories layer
38+
// on top of this in their own beforeEach (story hooks run after meta's).
39+
beforeEach: () => {
40+
const previousPermissions = useFleetStore.getState().auth.permissions;
41+
useFleetStore.setState((state) => ({
42+
auth: { ...state.auth, permissions: state.auth.isAuthenticated ? state.auth.permissions : [] },
43+
}));
44+
return () => {
45+
useFleetStore.setState((state) => ({ auth: { ...state.auth, permissions: [...previousPermissions] } }));
46+
};
47+
},
1448
parameters: {
1549
withRouter: false,
1650
},

client/src/protoFleet/components/PageHeader/PageHeader.stories.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,36 @@ export const PageHeader = () => {
236236
);
237237
};
238238

239+
export const UpdatePillWithSetup = () => {
240+
return (
241+
<SitesContext.Provider value={storySitesContext}>
242+
<div className="bg-surface-base" style={{ minWidth: "960px" }}>
243+
<PageHeaderComponent
244+
schedulePillData={emptySchedulePillData}
245+
updatePill={{ version: "v1.3.0", onClick: action("open update modal") }}
246+
/>
247+
</div>
248+
</SitesContext.Provider>
249+
);
250+
};
251+
UpdatePillWithSetup.beforeEach = () => {
252+
const previousValue = localStorage.getItem("completeSetupDismissed");
253+
localStorage.setItem("completeSetupDismissed", "true");
254+
window.dispatchEvent(
255+
new CustomEvent("localStorageChange", {
256+
detail: { key: "completeSetupDismissed", value: true },
257+
}),
258+
);
259+
260+
return () => {
261+
if (previousValue === null) {
262+
localStorage.removeItem("completeSetupDismissed");
263+
} else {
264+
localStorage.setItem("completeSetupDismissed", previousValue);
265+
}
266+
};
267+
};
268+
239269
export const SchedulePill = () => {
240270
return (
241271
<StoryFrame>

client/src/protoFleet/components/PageHeader/PageHeader.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,28 @@ describe("PageHeader", () => {
206206
expect(screen.queryByTestId("phone-header-widget-row")).not.toBeInTheDocument();
207207
});
208208

209+
it("places the update pill to the left of Continue setup on desktop", () => {
210+
mockUseWindowDimensions.mockReturnValue({
211+
isPhone: false,
212+
isTablet: false,
213+
});
214+
mockUseReactiveLocalStorage.mockReturnValue([true, vi.fn()]);
215+
216+
render(
217+
<MemoryRouter>
218+
<PageHeader schedulePillData={createSchedulePillData()} updatePill={{ version: "v1.3.0", onClick: vi.fn() }} />
219+
</MemoryRouter>,
220+
);
221+
222+
const widgets = screen.getByTestId("page-header-desktop-widgets");
223+
const updateButton = within(widgets).getByRole("button", { name: "Open update details for v1.3.0" });
224+
const setupButton = within(widgets).getByRole("button", { name: "Continue setup" });
225+
226+
expect(updateButton).toHaveTextContent("Update available");
227+
expect(updateButton.querySelector(".bg-intent-info-fill")).not.toBeNull();
228+
expect(updateButton.compareDocumentPosition(setupButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
229+
});
230+
209231
it("keeps the phone widget row hidden when neither setup nor schedules need space", () => {
210232
render(
211233
<MemoryRouter>

client/src/protoFleet/components/PageHeader/PageHeader.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ interface PageHeaderProps {
2929
isMenuOpen?: boolean;
3030
openMenu?: () => void;
3131
schedulePillData: UseSchedulePillDataResult;
32+
updatePill?: UpdatePillData | null;
33+
}
34+
35+
export interface UpdatePillData {
36+
onClick: () => void;
37+
version: string;
3238
}
3339

3440
interface HeaderWidgetsProps {
@@ -41,11 +47,12 @@ interface HeaderWidgetsProps {
4147
schedulePillData: UseSchedulePillDataResult;
4248
stacked?: boolean;
4349
testId?: string;
50+
updatePill?: UpdatePillData | null;
4451
widgets: HeaderWidgetKind[];
4552
}
4653

4754
const headerWidgetEnabled = true;
48-
type HeaderWidgetKind = "curtailment" | "schedule" | "setup";
55+
type HeaderWidgetKind = "curtailment" | "schedule" | "update" | "setup";
4956

5057
function HeaderWidgets({
5158
activeCurtailmentEvent,
@@ -57,6 +64,7 @@ function HeaderWidgets({
5764
schedulePillData,
5865
stacked = false,
5966
testId,
67+
updatePill,
6068
widgets,
6169
}: HeaderWidgetsProps): ReactElement {
6270
const { pillSchedule, sections, pendingScheduleId, onToggleScheduleStatus } = schedulePillData;
@@ -92,6 +100,21 @@ function HeaderWidgets({
92100
onToggleScheduleStatus={onToggleScheduleStatus}
93101
/>
94102
) : null;
103+
case "update":
104+
return updatePill ? (
105+
<Button
106+
key={widget}
107+
ariaLabel={`Open update details for ${updatePill.version}`}
108+
className="max-w-full min-w-0 overflow-hidden"
109+
prefixIcon={<span className="h-2.5 w-2.5 rounded-full bg-intent-info-fill" />}
110+
variant={variants.secondary}
111+
size={sizes.compact}
112+
onClick={updatePill.onClick}
113+
testId="update-available-pill"
114+
>
115+
<span className="block min-w-0 truncate">Update available</span>
116+
</Button>
117+
) : null;
95118
case "setup":
96119
return dismissedSetup ? (
97120
<Button
@@ -115,6 +138,7 @@ function PageHeader({
115138
isMenuOpen,
116139
openMenu,
117140
schedulePillData,
141+
updatePill = null,
118142
}: PageHeaderProps): ReactElement {
119143
const { isPhone, isTablet } = useWindowDimensions();
120144
const { bgClass } = usePageBackground();
@@ -146,15 +170,19 @@ function PageHeader({
146170
dismissedSetup: hasDismissedSetup,
147171
onContinueSetup: handleCompleteSetup,
148172
schedulePillData,
173+
updatePill,
149174
};
150175
const hasVisibleCurtailmentPill = activeCurtailmentEvent !== null && canReadCurtailment;
176+
const hasVisibleUpdatePill = updatePill !== null;
151177
const headerWidgetKinds: HeaderWidgetKind[] = [
152178
...(hasVisibleCurtailmentPill ? (["curtailment"] as const) : []),
153179
...(schedulePillData.hasVisibleSchedules ? (["schedule"] as const) : []),
180+
...(hasVisibleUpdatePill ? (["update"] as const) : []),
154181
...(hasDismissedSetup ? (["setup"] as const) : []),
155182
];
156183
const headerWidgetCount = getVisibleHeaderWidgetCount({
157184
hasDismissedSetup,
185+
hasVisibleUpdatePill,
158186
hasVisibleCurtailmentPill,
159187
hasVisibleSchedules: schedulePillData.hasVisibleSchedules,
160188
});

client/src/protoFleet/components/PageHeader/headerWidgetLayout.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
interface HeaderWidgetVisibility {
22
hasDismissedSetup: boolean;
3+
hasVisibleUpdatePill?: boolean;
34
hasVisibleCurtailmentPill: boolean;
45
hasVisibleSchedules: boolean;
56
}
@@ -14,10 +15,16 @@ export const PHONE_HEADER_WIDGET_STACK_THREE_HEIGHT_CLASS = "h-[120px]";
1415

1516
export function getVisibleHeaderWidgetCount({
1617
hasDismissedSetup,
18+
hasVisibleUpdatePill = false,
1719
hasVisibleCurtailmentPill,
1820
hasVisibleSchedules,
1921
}: HeaderWidgetVisibility): number {
20-
return Number(hasVisibleCurtailmentPill) + Number(hasVisibleSchedules) + Number(hasDismissedSetup);
22+
return (
23+
Number(hasVisibleCurtailmentPill) +
24+
Number(hasVisibleSchedules) +
25+
Number(hasVisibleUpdatePill) +
26+
Number(hasDismissedSetup)
27+
);
2128
}
2229

2330
export function shouldStackPhoneHeaderWidgets(widgetCount: number): boolean {

client/src/protoFleet/features/settings/components/Updates.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Updates from "./Updates";
44
import { updatesClient } from "@/protoFleet/api/clients";
55
import type { GetUpdateStatusResponse } from "@/protoFleet/api/generated/updates/v1/updates_pb";
66
import { ReleaseChannel } from "@/protoFleet/api/generated/updates/v1/updates_pb";
7+
import { DISMISSED_UPDATE_TAG_KEY } from "@/protoFleet/features/updates/constants";
78
import { useHasPermission } from "@/protoFleet/store";
89
import { pushToast } from "@/shared/features/toaster";
910
import { copyToClipboard } from "@/shared/utils/utility";
@@ -45,7 +46,6 @@ vi.mock("@/shared/features/toaster", () => ({
4546

4647
const INSTALL_COMMAND = "curl -fsSL https://fleet.example.com/install.sh | sh -s -- v1.3.0";
4748
const RELEASE_NOTES_URL = "https://github.com/block/proto-fleet/releases/tag/v1.3.0";
48-
const DISMISSED_UPDATE_TAG_KEY = "dismissedUpdateTag";
4949

5050
const buildStatus = (overrides?: Partial<GetUpdateStatusResponse>): GetUpdateStatusResponse =>
5151
({

0 commit comments

Comments
 (0)