Skip to content

Commit 4039274

Browse files
feat(updates): add passive release update indicator (#845)
1 parent 92fb0fc commit 4039274

11 files changed

Lines changed: 562 additions & 57 deletions

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

Lines changed: 39 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 mockUseUpdateIndicator = vi.fn();
1617

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

46+
vi.mock("@/protoFleet/features/updates/useUpdateIndicator", () => ({
47+
useUpdateIndicator: (options: { enabled?: boolean }) => mockUseUpdateIndicator(options),
48+
}));
49+
4550
vi.mock("@/shared/hooks/useWindowDimensions", () => ({
4651
useWindowDimensions: () => mockUseWindowDimensions(),
4752
}));
@@ -99,6 +104,7 @@ describe("AppLayout", () => {
99104
mockUseReactiveLocalStorage.mockReturnValue([false, vi.fn()]);
100105
mockUseCurtailmentPillData.mockReturnValue({ activeEvent: null });
101106
mockUseSchedulePillData.mockReturnValue(createSchedulePillData());
107+
mockUseUpdateIndicator.mockReturnValue(null);
102108
vi.mocked(useHasPermission).mockReturnValue(true);
103109
});
104110

@@ -223,6 +229,39 @@ describe("AppLayout", () => {
223229
expect(screen.getByText("Body content").parentElement).toHaveClass("phone:top-[calc(theme(spacing.1)*12+40px)]");
224230
});
225231

232+
it("uses the three-widget phone content offset when the update indicator makes four widgets visible", () => {
233+
mockUseReactiveLocalStorage.mockReturnValue([true, vi.fn()]);
234+
mockUseCurtailmentPillData.mockReturnValue({ activeEvent: activeCurtailmentEvent });
235+
mockUseSchedulePillData.mockReturnValue(
236+
createSchedulePillData({
237+
pillSchedule: createPillSchedule(),
238+
}),
239+
);
240+
mockUseUpdateIndicator.mockReturnValue({ version: "v1.3.0", onClick: vi.fn() });
241+
242+
render(
243+
<MemoryRouter>
244+
<AppLayout>
245+
<div>Body content</div>
246+
</AppLayout>
247+
</MemoryRouter>,
248+
);
249+
250+
expect(screen.getByText("Body content").parentElement).toHaveClass("phone:top-[calc(theme(spacing.1)*12+120px)]");
251+
});
252+
253+
it("disables update polling when the route hides the shell header", () => {
254+
render(
255+
<MemoryRouter>
256+
<AppLayout hideShellHeader>
257+
<div>Body content</div>
258+
</AppLayout>
259+
</MemoryRouter>,
260+
);
261+
262+
expect(mockUseUpdateIndicator).toHaveBeenCalledWith({ enabled: false });
263+
});
264+
226265
it("keeps the base phone content offset when the only curtailment widget fits inline", () => {
227266
mockUseCurtailmentPillData.mockReturnValue({ activeEvent: activeCurtailmentEvent });
228267

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ 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 { useUpdateIndicator } from "@/protoFleet/features/updates/useUpdateIndicator";
1920
import { usePageBackground } from "@/protoFleet/hooks/usePageBackground";
2021
import { useHasPermission } from "@/protoFleet/store";
2122
import { Menu } from "@/shared/assets/icons";
@@ -34,11 +35,16 @@ const AppLayoutContent = ({ children, hideShellHeader = false }: Props) => {
3435
const [dismissedSetup] = useReactiveLocalStorage<boolean>("completeSetupDismissed");
3536
const schedulePillData = useSchedulePillData();
3637
const { activeEvent: activeCurtailmentEvent } = useCurtailmentPillData();
38+
// Release discovery is an enhancement of the normal Fleet header. Focused
39+
// detail routes deliberately skip the poll along with the hidden header.
40+
const updatePill = useUpdateIndicator({ enabled: !hideShellHeader });
3741
const hasDismissedSetup = Boolean(dismissedSetup);
3842
const canReadCurtailment = useHasPermission("curtailment:read");
3943
const hasVisibleCurtailmentPill = activeCurtailmentEvent !== null && canReadCurtailment;
44+
const hasVisibleUpdatePill = updatePill !== null;
4045
const headerWidgetCount = getVisibleHeaderWidgetCount({
4146
hasDismissedSetup,
47+
hasVisibleUpdatePill,
4248
hasVisibleCurtailmentPill,
4349
hasVisibleSchedules: schedulePillData.hasVisibleSchedules,
4450
});
@@ -94,6 +100,7 @@ const AppLayoutContent = ({ children, hideShellHeader = false }: Props) => {
94100
isMenuOpen={isMenuOpen}
95101
openMenu={() => setIsMenuOpen(true)}
96102
schedulePillData={schedulePillData}
103+
updatePill={updatePill}
97104
/>
98105
</div>
99106
)}

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 settings") }}
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 settings 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 settings 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 {

0 commit comments

Comments
 (0)