← Back to team overview

sts-sponsors team mailing list archive

[Merge] ~petermakowski/maas-site-manager:add-hidden-columns-MAASENG-1391 into maas-site-manager:main

 

Peter Makowski has proposed merging ~petermakowski/maas-site-manager:add-hidden-columns-MAASENG-1391 into maas-site-manager:main.

Commit message:
add hidden columns MAASENG-1391 

Requested reviews:
  MAAS Committers (maas-committers)

For more details, see:
https://code.launchpad.net/~petermakowski/maas-site-manager/+git/site-manager/+merge/438107

- add Placeholder component
- add response delay for more realistic server response time when using the mock server 
- add e2e test for hiding sites table columns
- ignore stateless components in react/no-multi-comp eslint rule (all this was doing is maas-ui was encouraging writing functions instead of React components to bypass this rule without having to create lots of small files)
-- 
Your team MAAS Committers is requested to review the proposed merge of ~petermakowski/maas-site-manager:add-hidden-columns-MAASENG-1391 into maas-site-manager:main.
diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js
index 5d90fb2..242261d 100644
--- a/frontend/.eslintrc.js
+++ b/frontend/.eslintrc.js
@@ -119,7 +119,7 @@ module.exports = {
     {
       files: ["src/**/*.tsx"],
       rules: {
-        "react/no-multi-comp": ["error", { ignoreStateless: false }],
+        "react/no-multi-comp": ["error", { ignoreStateless: true }],
       },
     },
     {
diff --git a/frontend/package.json b/frontend/package.json
index c3c0f84..726b091 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -15,6 +15,7 @@
     "@canonical/react-components": "0.38.0",
     "@tanstack/react-table": "8.7.9",
     "axios": "1.3.4",
+    "classnames": "2.3.2",
     "date-fns": "2.29.3",
     "date-fns-tz": "2.0.0",
     "lodash": "4.17.21",
@@ -22,6 +23,7 @@
     "react-dom": "18.2.0",
     "react-query": "3.39.3",
     "react-router-dom": "6.8.1",
+    "use-local-storage-state": "18.1.2",
     "vanilla-framework": "3.11.0"
   },
   "devDependencies": {
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
index cf34b9f..d57e481 100644
--- a/frontend/playwright.config.ts
+++ b/frontend/playwright.config.ts
@@ -6,7 +6,7 @@ import { defineConfig, devices } from "@playwright/test";
  */
 import dotenv from "dotenv";
 
-dotenv.config();
+dotenv.config({ path: "../.env" });
 
 /**
  * See https://playwright.dev/docs/test-configuration.
@@ -37,7 +37,7 @@ export default defineConfig({
     /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
     actionTimeout: 0,
     /* Base URL to use in actions like `await page.goto('/')`. */
-    baseURL: `http://localhost:${process.env.UI_PORT}`,
+    baseURL: `http://localhost:${process.env.VITE_UI_PORT}`,
 
     /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
     trace: "on-first-retry",
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index c8495ac..5314e6f 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -18,9 +18,13 @@ export type Site = {
   };
 };
 
-export type Sites = {
-  items: Site[];
+export type PaginatedQueryResult = {
+  items: unknown[];
   total: number;
   page: number;
   size: number;
 };
+
+export type SitesQueryResult = PaginatedQueryResult & {
+  items: Site[];
+};
diff --git a/frontend/src/components/Placeholder/Placeholder.scss b/frontend/src/components/Placeholder/Placeholder.scss
new file mode 100644
index 0000000..cc9ed21
--- /dev/null
+++ b/frontend/src/components/Placeholder/Placeholder.scss
@@ -0,0 +1,25 @@
+.p-placeholder {
+  animation: shine 1.5s infinite ease-out;
+  background-color: $color-mid-x-light;
+  background-image: linear-gradient(
+    to right,
+    $color-mid-x-light calc(50% - 2rem),
+    $color-light 50%,
+    $color-mid-x-light calc(50% + 2rem)
+  );
+  background-size: 300% 100%;
+  color: transparent;
+  overflow: hidden;
+  pointer-events: none;
+  text-indent: -100%;
+}
+
+@keyframes shine {
+  0% {
+    background-position: right;
+  }
+
+  100% {
+    background-position: left;
+  }
+}
diff --git a/frontend/src/components/Placeholder/Placeholder.test.tsx b/frontend/src/components/Placeholder/Placeholder.test.tsx
new file mode 100644
index 0000000..e963ac9
--- /dev/null
+++ b/frontend/src/components/Placeholder/Placeholder.test.tsx
@@ -0,0 +1,26 @@
+import { render, screen } from "../../test-utils";
+
+import Placeholder from "./Placeholder";
+
+describe("Placeholder", () => {
+  it("always hides placeholder text passed as a text prop", () => {
+    const { rerender } = render(<Placeholder text="Placeholder text" />);
+    expect(screen.queryByText(/Placeholder text/)).not.toBeInTheDocument();
+    rerender(<Placeholder isLoading text="Placeholder text" />);
+    expect(screen.queryByText(/Placeholder text/)).toHaveAttribute("aria-hidden", "true");
+  });
+
+  it("hides the children when loading", () => {
+    const { rerender } = render(<Placeholder>Placeholder children</Placeholder>);
+    expect(screen.getByText(/Placeholder children/)).toBeInTheDocument();
+    rerender(<Placeholder isLoading>Placeholder children</Placeholder>);
+    expect(screen.queryByText(/Placeholder children/)).toHaveAttribute("aria-hidden", "true");
+  });
+
+  it("does not return placeholder styles when isLoading is false", () => {
+    const { rerender } = render(<Placeholder>Placeholder children</Placeholder>);
+    expect(screen.queryByTestId("placeholder")).not.toBeInTheDocument();
+    rerender(<Placeholder isLoading>Placeholder children</Placeholder>);
+    expect(screen.getByTestId("placeholder")).toBeInTheDocument();
+  });
+});
diff --git a/frontend/src/components/Placeholder/Placeholder.tsx b/frontend/src/components/Placeholder/Placeholder.tsx
new file mode 100644
index 0000000..cab700c
--- /dev/null
+++ b/frontend/src/components/Placeholder/Placeholder.tsx
@@ -0,0 +1,38 @@
+import type { ReactNode } from "react";
+
+import classNames from "classnames";
+
+import "./Placeholder.scss";
+
+type Props = {
+  className?: string;
+  isLoading?: boolean;
+} & (
+  | {
+      text?: never;
+      children: ReactNode;
+    }
+  | {
+      text: string;
+      children?: never;
+    }
+);
+
+const Placeholder = ({ text, children, className, isLoading = false }: Props) => {
+  const delay = Math.floor(Math.random() * 750);
+  if (isLoading) {
+    return (
+      <span
+        aria-hidden={true}
+        className={classNames("p-placeholder", className)}
+        data-testid="placeholder"
+        style={{ animationDelay: `${delay}ms` }}
+      >
+        {text || children}
+      </span>
+    );
+  }
+  return <>{children}</>;
+};
+
+export default Placeholder;
diff --git a/frontend/src/components/Placeholder/index.ts b/frontend/src/components/Placeholder/index.ts
new file mode 100644
index 0000000..72011f2
--- /dev/null
+++ b/frontend/src/components/Placeholder/index.ts
@@ -0,0 +1 @@
+export { default } from "./Placeholder";
diff --git a/frontend/src/components/SitesList/SitesList.test.tsx b/frontend/src/components/SitesList/SitesList.test.tsx
index 7b067dd..b944366 100644
--- a/frontend/src/components/SitesList/SitesList.test.tsx
+++ b/frontend/src/components/SitesList/SitesList.test.tsx
@@ -27,7 +27,7 @@ it("renders header", () => {
 it("displays loading text", () => {
   render(<SitesList />);
 
-  expect(screen.getByText(/loading/i)).toBeInTheDocument();
+  expect(within(screen.getByRole("table", { name: /sites/i })).getByText(/loading/i)).toBeInTheDocument();
 });
 
 it("displays populated sites table", async () => {
diff --git a/frontend/src/components/SitesList/SitesList.tsx b/frontend/src/components/SitesList/SitesList.tsx
index 5583a75..682c17a 100644
--- a/frontend/src/components/SitesList/SitesList.tsx
+++ b/frontend/src/components/SitesList/SitesList.tsx
@@ -3,13 +3,11 @@ import { useSitesQuery } from "../../hooks/api";
 import SitesTable from "./components/SitesTable";
 
 const SitesList = () => {
-  const query = useSitesQuery();
+  const { data, isLoading, isFetchedAfterMount } = useSitesQuery();
 
   return (
     <div>
-      <h2 className="p-heading--4">{query?.data?.items?.length || ""} MAAS Regions</h2>
-      {query.isLoading && !query.isFetchedAfterMount ? "Loading..." : null}
-      {query.isFetchedAfterMount && query.data ? <SitesTable data={query.data.items} /> : null}
+      <SitesTable data={data} isFetchedAfterMount={isFetchedAfterMount} isLoading={isLoading} />
     </div>
   );
 };
diff --git a/frontend/src/components/SitesList/components/ColumnsVisibilityControl.scss b/frontend/src/components/SitesList/components/ColumnsVisibilityControl.scss
new file mode 100644
index 0000000..8343345
--- /dev/null
+++ b/frontend/src/components/SitesList/components/ColumnsVisibilityControl.scss
@@ -0,0 +1,26 @@
+@include vf-p-icon-settings;
+
+.columns-visibility-checkbox {
+  &:first-of-type label {
+    padding-top: $sph--small;
+  }
+  label {
+    padding: 0 $sph--large $sph--x-small $sph--large;
+    text-transform: capitalize;
+    width: 100%;
+    text-indent: 0;
+  }
+}
+
+[class^="p-button"].has-icon.columns-visibility-toggle {
+  @extend %vf-input-elements;
+  padding-left: $spv--small;
+  text-align: left;
+  width: 100%;
+  text-transform: capitalize;
+
+  i[class*="p-icon"]:last-child {
+    margin-left: 0;
+    margin-right: $spv--x-small;
+  }
+}
diff --git a/frontend/src/components/SitesList/components/ColumnsVisibilityControl.tsx b/frontend/src/components/SitesList/components/ColumnsVisibilityControl.tsx
new file mode 100644
index 0000000..c799b88
--- /dev/null
+++ b/frontend/src/components/SitesList/components/ColumnsVisibilityControl.tsx
@@ -0,0 +1,44 @@
+import { ContextualMenu, Icon, CheckboxInput } from "@canonical/react-components";
+
+import "./ColumnsVisibilityControl.scss";
+import type { SitesColumn } from "./SitesTable";
+
+function ColumnsVisibilityControl({ columns }: { columns: SitesColumn[] }) {
+  return (
+    <ContextualMenu
+      className="filter-accordion"
+      constrainPanelWidth
+      dropdownProps={{ "aria-label": "columns menu" }}
+      position="left"
+      toggleClassName="columns-visibility-toggle has-icon"
+      toggleLabel={
+        <>
+          <Icon name="settings" /> Columns
+        </>
+      }
+      toggleLabelFirst={true}
+    >
+      <div className="columns-visibility-select-wrapper u-no-padding--top">
+        {columns
+          .filter((column) => column.id !== "select")
+          .map((column) => {
+            return (
+              <div className="columns-visibility-checkbox">
+                <CheckboxInput
+                  aria-label={column.id}
+                  key={column.id}
+                  label={column.id}
+                  {...{
+                    checked: column.getIsVisible(),
+                    onChange: column.getToggleVisibilityHandler(),
+                  }}
+                />
+              </div>
+            );
+          })}
+      </div>
+    </ContextualMenu>
+  );
+}
+
+export default ColumnsVisibilityControl;
diff --git a/frontend/src/components/SitesList/components/SitesTable.scss b/frontend/src/components/SitesList/components/SitesTable.scss
new file mode 100644
index 0000000..37b435f
--- /dev/null
+++ b/frontend/src/components/SitesList/components/SitesTable.scss
@@ -0,0 +1,5 @@
+.sites-table {
+  thead th:first-child {
+    width: 3rem;
+  }
+}
diff --git a/frontend/src/components/SitesList/components/SitesTable.test.tsx b/frontend/src/components/SitesList/components/SitesTable.test.tsx
index cb4ff72..38fce0d 100644
--- a/frontend/src/components/SitesList/components/SitesTable.test.tsx
+++ b/frontend/src/components/SitesList/components/SitesTable.test.tsx
@@ -18,14 +18,14 @@ afterEach(() => {
 });
 
 it("displays an empty sites table", () => {
-  render(<SitesTable data={[]} />);
+  render(<SitesTable data={{ items: [], total: 0, page: 1, size: 0 }} isFetchedAfterMount={true} isLoading={false} />);
 
   expect(screen.getByRole("table", { name: /sites/i })).toBeInTheDocument();
 });
 
 it("displays rows with details for each site", () => {
   const items = sites().items as Site[];
-  render(<SitesTable data={items} />);
+  render(<SitesTable data={{ items, total: 1, page: 1, size: 1 }} isFetchedAfterMount={true} isLoading={false} />);
 
   expect(screen.getByRole("table", { name: /sites/i })).toBeInTheDocument();
 
@@ -41,7 +41,9 @@ it("displays correct local time", () => {
   vi.setSystemTime(date);
 
   const item = site({ timezone: "CET" });
-  render(<SitesTable data={[item]} />);
+  render(
+    <SitesTable data={{ items: [item], total: 1, page: 1, size: 1 }} isFetchedAfterMount={true} isLoading={false} />,
+  );
 
   expect(screen.getByRole("table", { name: /sites/i })).toBeInTheDocument();
   expect(screen.getByText(/13:00 \(local time\)/i)).toBeInTheDocument();
diff --git a/frontend/src/components/SitesList/components/SitesTable.tsx b/frontend/src/components/SitesList/components/SitesTable.tsx
index 1aa8054..7791887 100644
--- a/frontend/src/components/SitesList/components/SitesTable.tsx
+++ b/frontend/src/components/SitesList/components/SitesTable.tsx
@@ -8,22 +8,37 @@ import {
   getFilteredRowModel,
   getPaginationRowModel,
 } from "@tanstack/react-table";
-import type { ColumnDef } from "@tanstack/react-table";
+import type { ColumnDef, Column } from "@tanstack/react-table";
 import { format } from "date-fns";
 import { utcToZonedTime } from "date-fns-tz";
 import pick from "lodash/fp/pick";
+import useLocalStorageState from "use-local-storage-state";
 
-import type { Site } from "../../../api/types";
+import type { SitesQueryResult } from "../../../api/types";
+import type { UseSitesQueryResult } from "../../../hooks/api";
+
+import "./SitesTable.scss";
+import SitesTableControls from "./SitesTableControls";
 
 const createAccessor =
   <T, K extends keyof T>(keys: K[] | K) =>
   (row: T) =>
     pick(keys, row);
 
-const SitesTable = ({ data }: { data: Site[] }) => {
-  const [columnVisibility, setColumnVisibility] = useState({});
+export type Site = SitesQueryResult["items"][number];
+export type SitesColumnDef = ColumnDef<Site, Partial<Site>>;
+export type SitesColumn = Column<Site, unknown>;
+
+const SitesTable = ({
+  data,
+  isFetchedAfterMount,
+  isLoading,
+}: Pick<UseSitesQueryResult, "data" | "isLoading" | "isFetchedAfterMount">) => {
+  const [columnVisibility, setColumnVisibility] = useLocalStorageState("sitesTableColumnVisibility", {
+    defaultValue: {},
+  });
 
-  const columns = useMemo<ColumnDef<Site, Partial<Site>>[]>(
+  const columns = useMemo<SitesColumnDef[]>(
     () => [
       {
         id: "select",
@@ -155,7 +170,7 @@ const SitesTable = ({ data }: { data: Site[] }) => {
   const [rowSelection, setRowSelection] = useState({});
 
   const table = useReactTable<Site>({
-    data: data || [],
+    data: data?.items || [],
     columns,
     state: {
       rowSelection,
@@ -175,39 +190,46 @@ const SitesTable = ({ data }: { data: Site[] }) => {
   });
 
   return (
-    <table aria-label="sites" className="u-table-layout--auto">
-      <thead>
-        {table.getHeaderGroups().map((headerGroup) => (
-          <tr key={headerGroup.id}>
-            {headerGroup.headers.map((header) => {
+    <>
+      <SitesTableControls allColumns={table.getAllLeafColumns()} data={data} isLoading={isLoading} />
+      <table aria-label="sites" className="sites-table">
+        <thead>
+          {table.getHeaderGroups().map((headerGroup) => (
+            <tr key={headerGroup.id}>
+              {headerGroup.headers.map((header) => {
+                return (
+                  <th colSpan={header.colSpan} key={header.id}>
+                    {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
+                    {header.column.getCanResize() && (
+                      <div
+                        className={`resizer ${header.column.getIsResizing() ? "isResizing" : ""}`}
+                        onMouseDown={header.getResizeHandler()}
+                        onTouchStart={header.getResizeHandler()}
+                      ></div>
+                    )}
+                  </th>
+                );
+              })}
+            </tr>
+          ))}
+        </thead>
+        {isLoading && !isFetchedAfterMount ? (
+          <caption>Loading...</caption>
+        ) : (
+          <tbody>
+            {table.getRowModel().rows.map((row) => {
               return (
-                <th colSpan={header.colSpan} key={header.id}>
-                  {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
-                  {header.column.getCanResize() && (
-                    <div
-                      className={`resizer ${header.column.getIsResizing() ? "isResizing" : ""}`}
-                      onMouseDown={header.getResizeHandler()}
-                      onTouchStart={header.getResizeHandler()}
-                    ></div>
-                  )}
-                </th>
+                <tr key={row.id}>
+                  {row.getVisibleCells().map((cell) => {
+                    return <td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>;
+                  })}
+                </tr>
               );
             })}
-          </tr>
-        ))}
-      </thead>
-      <tbody>
-        {table.getRowModel().rows.map((row) => {
-          return (
-            <tr key={row.id}>
-              {row.getVisibleCells().map((cell) => {
-                return <td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>;
-              })}
-            </tr>
-          );
-        })}
-      </tbody>
-    </table>
+          </tbody>
+        )}
+      </table>
+    </>
   );
 };
 
diff --git a/frontend/src/components/SitesList/components/SitesTableControls.tsx b/frontend/src/components/SitesList/components/SitesTableControls.tsx
new file mode 100644
index 0000000..076e506
--- /dev/null
+++ b/frontend/src/components/SitesList/components/SitesTableControls.tsx
@@ -0,0 +1,31 @@
+import { Row, Col } from "@canonical/react-components";
+
+import type { UseSitesQueryResult } from "../../../hooks/api";
+import Placeholder from "../../Placeholder";
+
+import ColumnsVisibilityControl from "./ColumnsVisibilityControl";
+import type { SitesColumn } from "./SitesTable";
+
+const SitesCount = ({ data, isLoading }: Pick<UseSitesQueryResult, "data" | "isLoading">) =>
+  isLoading ? <Placeholder isLoading={isLoading} text="xx" /> : <span>{`${data?.items?.length || ""}`}</span>;
+
+const SitesTableControls = ({
+  data,
+  isLoading,
+  allColumns,
+}: { allColumns: SitesColumn[] } & Pick<UseSitesQueryResult, "data" | "isLoading">) => {
+  return (
+    <Row>
+      <Col size={10}>
+        <h2 className="p-heading--4">
+          <SitesCount data={data} isLoading={isLoading} /> MAAS Regions
+        </h2>
+      </Col>
+      <Col size={2}>
+        <ColumnsVisibilityControl columns={allColumns} />
+      </Col>
+    </Row>
+  );
+};
+
+export default SitesTableControls;
diff --git a/frontend/src/hooks/api.ts b/frontend/src/hooks/api.ts
index 9ae36ae..71943b2 100644
--- a/frontend/src/hooks/api.ts
+++ b/frontend/src/hooks/api.ts
@@ -1,6 +1,7 @@
 import { useQuery } from "react-query";
 
 import { getSites } from "../api/handlers";
-import type { Sites } from "../api/types";
+import type { SitesQueryResult } from "../api/types";
 
-export const useSitesQuery = () => useQuery<Sites>("/api/sites", getSites);
+export const useSitesQuery = () => useQuery<SitesQueryResult>("/api/sites", getSites);
+export type UseSitesQueryResult = ReturnType<typeof useSitesQuery>;
diff --git a/frontend/src/mocks/browser.ts b/frontend/src/mocks/browser.ts
index a0feda2..cdc1e21 100644
--- a/frontend/src/mocks/browser.ts
+++ b/frontend/src/mocks/browser.ts
@@ -6,6 +6,6 @@ import { sites } from "./factories";
 
 export const worker = setupWorker(
   rest.get(urls.sites, (_req, res, ctx) => {
-    return res(ctx.json(sites()));
+    return res(ctx.delay(), ctx.json(sites()));
   }),
 );
diff --git a/frontend/tests/sites.spec.ts b/frontend/tests/sites.spec.ts
new file mode 100644
index 0000000..072e2fa
--- /dev/null
+++ b/frontend/tests/sites.spec.ts
@@ -0,0 +1,26 @@
+import { test, expect } from "@playwright/test";
+
+test.beforeEach(async ({ page }) => {
+  await page.goto("/sites");
+});
+
+test("can hide table columns", async ({ page }) => {
+  const columnsCount = 6;
+  const columnHeaders = await page.locator("th");
+  await expect(columnHeaders).toHaveCount(columnsCount);
+  await expect(columnHeaders).toHaveText(["", /name/i, /connection/i, /country/i, /local time/i, /machines/i]);
+  await page.getByRole("button", { name: "Columns" }).click();
+  await page.getByLabel("submenu").getByRole("checkbox", { name: "connection" }).click({ force: true });
+
+  const updatedColumnHeaders = await page.locator("th");
+  expect(updatedColumnHeaders).toHaveCount(columnsCount - 1);
+
+  await expect(columnHeaders).toHaveText(["", /name/i, /country/i, /local time/i, /machines/i]);
+
+  await page.reload();
+
+  // verify that the hidden column is still hidden after refresh
+  const refreshedColumnHeaders = await page.locator("th");
+  expect(refreshedColumnHeaders).toHaveCount(columnsCount - 1);
+  await expect(refreshedColumnHeaders).toHaveText(["", /name/i, /country/i, /local time/i, /machines/i]);
+});
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index cde0616..078a2de 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -8,4 +8,9 @@ dotenv.config({ path: "../.env" });
 export default defineConfig({
   plugins: [react()],
   server: { port: Number(process.env.VITE_UI_PORT) },
+  css: {
+    preprocessorOptions: {
+      scss: { additionalData: `@import "node_modules/vanilla-framework"; @include vanilla;` },
+    },
+  },
 });
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 3d2f493..8b0b656 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -2332,6 +2332,11 @@ classnames@2.3.1:
   resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e";
   integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==
 
+classnames@2.3.2:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924";
+  integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
+
 cli-cursor@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307";
@@ -5400,6 +5405,11 @@ uri-js@^4.2.2:
   dependencies:
     punycode "^2.1.0"
 
+use-local-storage-state@18.1.2:
+  version "18.1.2"
+  resolved "https://registry.yarnpkg.com/use-local-storage-state/-/use-local-storage-state-18.1.2.tgz#f131c0aa3803742ca261c547cdfd9d61e848581d";
+  integrity sha512-V+kYQNC5R0N/JDpsg6b4ED5UaItKJcSvbne68DwJDZWHxGMQBiF41ATktFIOyet3PIq30d2qtzVp/2aB6hQ8Bg==
+
 use-ssr@^1.0.22:
   version "1.0.24"
   resolved "https://registry.yarnpkg.com/use-ssr/-/use-ssr-1.0.24.tgz#213a3df58f5ab9268e6fe1a57ad0a9de91e514d1";

Follow ups