Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions packages/pages-components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

##### Chores

* prevent shell injection in github action (vuln-44056) ([#148](https://github.com/yext/js/pull/148)) ([62edc854](https://github.com/yext/js/commit/62edc8541bb93c17941dc8c58e7f4bddb1ebc758))
- prevent shell injection in github action (vuln-44056) ([#148](https://github.com/yext/js/pull/148)) ([62edc854](https://github.com/yext/js/commit/62edc8541bb93c17941dc8c58e7f4bddb1ebc758))

##### New Features

* **pages-components:** expose entity prop to the analytics track method ([#149](https://github.com/yext/js/pull/149)) ([5e5932ab](https://github.com/yext/js/commit/5e5932abd2346475be18091a2f99a3dd50665122))
- **pages-components:** expose entity prop to the analytics track method ([#149](https://github.com/yext/js/pull/149)) ([5e5932ab](https://github.com/yext/js/commit/5e5932abd2346475be18091a2f99a3dd50665122))

#### 2.1.1 (2026-04-21)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ describe("HoursStatus hydration", () => {
Settings.defaultZone = "America/New_York";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

vi.spyOn(globalThis, "setTimeout").mockImplementation((() => 0) as typeof setTimeout);
vi.spyOn(globalThis, "setTimeout").mockImplementation(
(() => 0) as unknown as typeof setTimeout
);
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});

const container = document.createElement("div");
Expand All @@ -45,7 +47,10 @@ describe("HoursStatus hydration", () => {
let root: ReturnType<typeof hydrateRoot> | undefined;

await act(async () => {
root = hydrateRoot(container, <HoursStatus hours={HoursData} timezone="America/New_York" />);
root = hydrateRoot(
container,
<HoursStatus hours={HoursData} timezone="America/New_York" comingSoon={false} />
);
await Promise.resolve();
});

Expand All @@ -59,4 +64,98 @@ describe("HoursStatus hydration", () => {
root?.unmount();
});
});

it("hydrates to the coming soon label without warnings", async () => {
const mockedNow = DateTime.fromObject(
{ year: 2025, month: 1, day: 7, hour: 10 },
{ zone: "America/New_York" }
);

Settings.now = () => mockedNow.toMillis();
Settings.defaultZone = "America/New_York";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

const setTimeoutSpy = vi
.spyOn(globalThis, "setTimeout")
.mockImplementation((() => 0) as unknown as typeof setTimeout);
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});

const container = document.createElement("div");
container.innerHTML = renderToString(
<div style={{ minHeight: "1.5em" }} className="HoursStatus" />
);
document.body.appendChild(container);

expect(container.textContent).toBe("");

let root: ReturnType<typeof hydrateRoot> | undefined;

await act(async () => {
root = hydrateRoot(
container,
<HoursStatus hours={HoursData} timezone="America/New_York" comingSoon />
);
await Promise.resolve();
});

expect(consoleErrorSpy).not.toHaveBeenCalled();
expect(setTimeoutSpy).not.toHaveBeenCalled();
expect(container.textContent).toContain("Coming Soon");
expect(container.textContent).not.toContain("Open Now");
expect(container.textContent).not.toContain("Closed");
expect(container.textContent).not.toContain("Closes at");
expect(container.textContent).not.toContain("Opens at");

await act(async () => {
root?.unmount();
});
});

it("hydrates to the coming soon template override without warnings", async () => {
const mockedNow = DateTime.fromObject(
{ year: 2025, month: 1, day: 7, hour: 10 },
{ zone: "America/New_York" }
);

Settings.now = () => mockedNow.toMillis();
Settings.defaultZone = "America/New_York";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

const setTimeoutSpy = vi
.spyOn(globalThis, "setTimeout")
.mockImplementation((() => 0) as unknown as typeof setTimeout);
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});

const container = document.createElement("div");
container.innerHTML = renderToString(
<div style={{ minHeight: "1.5em" }} className="HoursStatus" />
);
document.body.appendChild(container);

expect(container.textContent).toBe("");

let root: ReturnType<typeof hydrateRoot> | undefined;

await act(async () => {
root = hydrateRoot(
container,
<HoursStatus
hours={HoursData}
timezone="America/New_York"
comingSoon
comingSoonTemplate={() => "Pronto"}
/>
);
await Promise.resolve();
});

expect(consoleErrorSpy).not.toHaveBeenCalled();
expect(setTimeoutSpy).not.toHaveBeenCalled();
expect(container.textContent).toContain("Pronto");
expect(container.textContent).not.toContain("Coming Soon");

await act(async () => {
root?.unmount();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,31 @@ export const Open247: Story = {
},
};

export const ComingSoon: Story = {
args: {
hours: HoursData,
comingSoon: true,
},
parameters: {
mockedLuxonDateTime: DateTime.fromObject(
{ year: 2025, month: 1, day: 7, hour: 10 } // Tuesday 10 AM
),
},
};

export const ComingSoonTranslated: Story = {
args: {
hours: HoursData,
comingSoon: true,
comingSoonTemplate: () => "Pronto",
},
parameters: {
mockedLuxonDateTime: DateTime.fromObject(
{ year: 2025, month: 1, day: 7, hour: 10 } // Tuesday 10 AM
),
},
};

export const IndefinitelyClosedActive: Story = {
args: {
hours: HoursTemporarilyClosed,
Expand Down
58 changes: 40 additions & 18 deletions packages/pages-components/src/components/hours/hoursStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,22 @@ function isOpen24h(params: StatusParams): boolean {
return params?.currentInterval?.is24h?.() || false;
}

function isComingSoon(params: StatusParams): boolean {
return !!params.comingSoon;
}

function isIndefinitelyClosed(params: StatusParams): boolean {
return !params.futureInterval;
}

function defaultComingSoonTemplate(_: StatusParams): React.ReactNode {
return <span className="HoursStatus-current">Coming Soon</span>;
}

function defaultCurrentTemplate(params: StatusParams): React.ReactNode {
if (isComingSoon(params)) {
return defaultComingSoonTemplate(params);
}
if (isOpen24h(params)) {
return <span className="HoursStatus-current">Open 24 Hours</span>;
}
Expand All @@ -25,21 +36,21 @@ function defaultCurrentTemplate(params: StatusParams): React.ReactNode {
}

function defaultSeparatorTemplate(params: StatusParams): React.ReactNode {
if (isOpen24h(params) || isIndefinitelyClosed(params)) {
if (isComingSoon(params) || isOpen24h(params) || isIndefinitelyClosed(params)) {
return null;
}
return <span className="HoursStatus-separator"> • </span>;
}

function defaultFutureTemplate(params: StatusParams): React.ReactNode {
if (isOpen24h(params) || isIndefinitelyClosed(params)) {
if (isComingSoon(params) || isOpen24h(params) || isIndefinitelyClosed(params)) {
return null;
}
return <span className="HoursStatus-future">{params.isOpen ? "Closes at" : "Opens at"}</span>;
}

function defaultTimeTemplate(params: StatusParams): React.ReactNode {
if (isOpen24h(params) || isIndefinitelyClosed(params)) {
if (isComingSoon(params) || isOpen24h(params) || isIndefinitelyClosed(params)) {
return null;
}
let time = "";
Expand All @@ -54,7 +65,7 @@ function defaultTimeTemplate(params: StatusParams): React.ReactNode {
}

function defaultDayOfWeekTemplate(params: StatusParams): React.ReactNode {
if (isOpen24h(params) || isIndefinitelyClosed(params)) {
if (isComingSoon(params) || isOpen24h(params) || isIndefinitelyClosed(params)) {
return null;
}
const dayOptions: Intl.DateTimeFormatOptions = {
Expand All @@ -78,14 +89,15 @@ function defaultStatusTemplate(
props?: HoursStatusProps
): React.ReactNode {
const currentTemplate = params.currentTemplate || defaultCurrentTemplate;
const comingSoonTemplate = params.comingSoonTemplate || defaultComingSoonTemplate;
const separatorTemplate = params.separatorTemplate || defaultSeparatorTemplate;
const futureTemplate = params.futureTemplate || defaultFutureTemplate;
const timeTemplate = params.timeTemplate || defaultTimeTemplate;
const dayOfWeekTemplate = params.dayOfWeekTemplate || defaultDayOfWeekTemplate;

return (
<div className={c("HoursStatus", props?.className || "")}>
{currentTemplate(params)}
{params.comingSoon ? comingSoonTemplate(params) : currentTemplate(params)}
{separatorTemplate(params)}
{futureTemplate(params)}
{timeTemplate(params)}
Expand All @@ -101,6 +113,8 @@ const emptyStyle = { minHeight: `${1.5}em` };
* describing the current Open/Closed status of the entity
*
* @param {HoursType} hours data from Yext Streams
* @param {Boolean} comingSoon display a coming soon state instead of the normal status
* @param {Function} comingSoonTemplate override rendering for the "coming soon" part of this component "[[Coming Soon]]"
* @param {Intl.DateTimeFormatOptions} timeOptions
* @param {Intl.DateTimeFormatOptions} dayOptions
* @param {Function} statusTemplate completely override rendering for this component
Expand All @@ -121,26 +135,34 @@ const HoursStatus: React.FC<HoursStatusProps> = (props) => {
setIsClient(true);
}, []);

if (!props.hours) {
const statusTemplateFn = props.statusTemplate || defaultStatusTemplate;
const comingSoon = !!props.comingSoon;
let isOpen = false;
let currentInterval: StatusParams["currentInterval"] = null;
let futureInterval: StatusParams["futureInterval"] = null;

if (!props.hours && !comingSoon) {
return <></>;
}

const statusTemplateFn = props.statusTemplate || defaultStatusTemplate;
const h = new Hours(props.hours, props.timezone);
const isOpen = h.isOpenNow();
const currentInterval = h.getCurrentInterval();
const futureInterval = h.getNextInterval();

// When the current interval ends, or the next interval starts, trigger component rerender
const isOpenChangeTime = currentInterval?.end || futureInterval?.start;
if (isOpenChangeTime && !hasStatusTimeout) {
setHasStatusTimeout(true);
const delayMS = isOpenChangeTime.toMillis() - DateTime.now().toMillis();
setTimeout(() => setHasStatusTimeout(false), delayMS);
if (!comingSoon && props.hours) {
const h = new Hours(props.hours, props.timezone);
isOpen = h.isOpenNow();
currentInterval = h.getCurrentInterval();
futureInterval = h.getNextInterval();

// When the current interval ends, or the next interval starts, trigger component rerender
const isOpenChangeTime = currentInterval?.end || futureInterval?.start;
if (isOpenChangeTime && !hasStatusTimeout) {
setHasStatusTimeout(true);
const delayMS = isOpenChangeTime.toMillis() - DateTime.now().toMillis();
setTimeout(() => setHasStatusTimeout(false), delayMS);
}
}

const statusParams: StatusParams = {
isOpen,
comingSoon,
currentInterval,
futureInterval,
...props,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,50 @@ describe("HoursTable hydration", () => {
root?.unmount();
});
});

it("stays empty when coming soon is enabled", async () => {
const mockedNow = DateTime.fromObject(
{ year: 2025, month: 1, day: 9, hour: 12 },
{ zone: "America/New_York" }
);

Settings.now = () => mockedNow.toMillis();
Settings.defaultZone = "America/New_York";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockImplementation(
function (this: Intl.DateTimeFormat) {
return {
...originalResolvedOptions.call(this),
timeZone: "America/New_York",
};
}
);

const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const container = document.createElement("div");
container.innerHTML = renderToString(
<ServerSideHoursTable hours={HoursData} comingSoon startOfWeek="today" />
);
document.body.appendChild(container);

expect(container.innerHTML).toBe("");

let root: ReturnType<typeof hydrateRoot> | undefined;

await act(async () => {
root = hydrateRoot(
container,
<HoursTable hours={HoursData} comingSoon startOfWeek="today" />
);
await Promise.resolve();
});

expect(consoleErrorSpy).not.toHaveBeenCalled();
expect(container.innerHTML).toBe("");

await act(async () => {
root?.unmount();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,12 @@ describe("HoursTable SSR", () => {
expect(html).not.toContain("is-today");
expect(dayLabels[0]).toBe("Sunday");
});

it("renders nothing when coming soon is enabled", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const html = renderToString(<HoursTable hours={HoursData} comingSoon startOfWeek="today" />);

expect(consoleErrorSpy).not.toHaveBeenCalled();
expect(html).toBe("");
});
});
12 changes: 12 additions & 0 deletions packages/pages-components/src/components/hours/hoursTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ const HoursTable: React.FC<HoursTableProps> = (props) => {
setIsClient(true);
}, []);

if (props.comingSoon) {
return <></>;
}

if (!props.hours) {
return <></>;
}
Expand All @@ -156,6 +160,10 @@ const HoursTable: React.FC<HoursTableProps> = (props) => {
};

export const ClientSideHoursTable: React.FC<HoursTableProps> = (props) => {
if (props.comingSoon) {
return <></>;
}

const h = new Hours(props.hours, Intl.DateTimeFormat().resolvedOptions().timeZone);
const now = DateTime.now();

Expand All @@ -169,6 +177,10 @@ export const ClientSideHoursTable: React.FC<HoursTableProps> = (props) => {
};

export const ServerSideHoursTable: React.FC<HoursTableProps> = (props) => {
if (props.comingSoon) {
return <></>;
}

const { hours, dayOfWeekNames, intervalTranslations } = props;

const hoursTableData: HoursTableDayData[] = days.map((day) => {
Expand Down
Loading
Loading