fix(temporary-notes): live sidebar clock marker + stacked mobile create buttons
Issue 1 — the sidebar tree's temporary-note clock marker did not appear/ disappear until a page reload when a note's temporary state changed. - Make/unmake permanent from the page header menu and the in-page banner went through syncTemporaryExpiresInCache(), which patched the page query cache but never touched treeDataAtom, so the sidebar node kept its stale temporaryExpiresAt. Patch the tree node there too (via jotai's default store), so the marker updates without a reload. - Creating a note as temporary showed no marker until reload: the create flow's cache write (invalidateOnCreatePage) omitted temporaryExpiresAt, so the tree rebuild (buildTree -> mergeRootTrees) overwrote the optimistic/socket node's marker with undefined. Carry temporaryExpiresAt in that cached entry. - Thread temporaryExpiresAt through the server addTreeNode broadcast (PAGE_CREATED snapshot -> TreeNodeSnapshot -> broadcastPageCreated) so OTHER clients watching the space also render the marker immediately, and harden handleCreate's idempotency guard to patch the deadline if the broadcast won the insert race. Issue 2 — the home and space-overview "New note" / "New temporary note" buttons sat side-by-side and the temporary label clipped on narrow mobile widths. Lay them out full-width, stacked vertically, and tint the temporary button orange (matching the clock marker + banner) while the regular one stays neutral gray. Tests: extend tree-socket-reducers.test.ts (addTreeNode carries temporaryExpiresAt). Verified live with Playwright: marker appears on create and toggles both ways with no reload; mobile buttons are stacked, full-width, unclipped, and differently colored. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Button, Group, Menu, Text } from "@mantine/core";
|
||||
import { Button, Menu, Stack, Text } from "@mantine/core";
|
||||
import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
||||
import { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -21,11 +21,15 @@ function CreateNoteButton({
|
||||
temporary,
|
||||
label,
|
||||
icon,
|
||||
color,
|
||||
}: {
|
||||
writableSpaces: ISpace[];
|
||||
temporary: boolean;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
// Mantine color token; lets the temporary action tint toward the warm
|
||||
// orange/amber used by the clock marker + banner while "New note" stays neutral.
|
||||
color: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -54,7 +58,8 @@ function CreateNoteButton({
|
||||
<Button
|
||||
size="md"
|
||||
variant="light"
|
||||
color="gray"
|
||||
color={color}
|
||||
fullWidth
|
||||
leftSection={icon}
|
||||
loading={isPending}
|
||||
onClick={() => createNote(writableSpaces[0])}
|
||||
@@ -71,7 +76,8 @@ function CreateNoteButton({
|
||||
<Button
|
||||
size="md"
|
||||
variant="light"
|
||||
color="gray"
|
||||
color={color}
|
||||
fullWidth
|
||||
leftSection={icon}
|
||||
loading={isPending}
|
||||
>
|
||||
@@ -109,8 +115,10 @@ function CreateNoteButton({
|
||||
// Prominent home-screen actions to create a new note (page). Because the home
|
||||
// screen has no active space, the target space is resolved from the user's
|
||||
// writable spaces: created directly when there is one, picked from a dropdown
|
||||
// when there are several. Renders two equal-width buttons: a regular note and a
|
||||
// temporary note (which auto-moves to Trash after the workspace lifetime).
|
||||
// when there are several. Renders two full-width, vertically stacked buttons: a
|
||||
// neutral regular note and an orange-tinted temporary note (which auto-moves to
|
||||
// Trash after the workspace lifetime). Stacking full-width keeps the longer
|
||||
// "New temporary note" label from clipping on narrow mobile widths.
|
||||
export default function NewNoteButton() {
|
||||
const { t } = useTranslation();
|
||||
const { data } = useGetSpacesQuery({ limit: 100 });
|
||||
@@ -121,19 +129,21 @@ export default function NewNoteButton() {
|
||||
if (writableSpaces.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Group grow gap="sm">
|
||||
<Stack gap="sm">
|
||||
<CreateNoteButton
|
||||
writableSpaces={writableSpaces}
|
||||
temporary={false}
|
||||
label={t("New note")}
|
||||
icon={<IconPlus size={18} />}
|
||||
color="gray"
|
||||
/>
|
||||
<CreateNoteButton
|
||||
writableSpaces={writableSpaces}
|
||||
temporary={true}
|
||||
label={t("New temporary note")}
|
||||
icon={<IconHourglass size={18} />}
|
||||
color="orange"
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { getDefaultStore } from "jotai";
|
||||
import {
|
||||
toggleTemplate,
|
||||
toggleTemporary,
|
||||
@@ -9,6 +10,9 @@ import type {
|
||||
ToggleTemporaryResponse,
|
||||
} from "@/features/page-embed/types/page-embed.types";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
/**
|
||||
* After toggling a note's temporary state, mirror the new deadline into the
|
||||
@@ -30,6 +34,19 @@ export function syncTemporaryExpiresInCache(
|
||||
});
|
||||
}
|
||||
}
|
||||
// Patch the in-memory sidebar tree node so its temporary clock marker
|
||||
// appears/disappears immediately — WITHOUT a reload. The page cache update
|
||||
// above only drives the in-page banner/menu; the sidebar reads
|
||||
// `temporaryExpiresAt` straight off the `treeDataAtom` node. The app uses
|
||||
// jotai's default store (no <Provider>), so `getDefaultStore()` is the same
|
||||
// store the sidebar's hooks read from. `treeModel.update` returns the same
|
||||
// reference (a no-op) when the page isn't in the currently loaded tree.
|
||||
const store = getDefaultStore();
|
||||
const prevTree = store.get(treeDataAtom);
|
||||
const nextTree = treeModel.update(prevTree, page.id, {
|
||||
temporaryExpiresAt,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
if (nextTree !== prevTree) store.set(treeDataAtom, nextTree);
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["sidebar-pages"].includes(item.queryKey[0] as string),
|
||||
|
||||
@@ -176,8 +176,8 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
pageId: page.id,
|
||||
temporary: next,
|
||||
});
|
||||
// Reflect the new deadline in the page cache so the menu label flips and
|
||||
// any banner updates. The sidebar icon refreshes via its own query.
|
||||
// Reflect the new deadline in the page cache (menu label + banner) AND in
|
||||
// the sidebar tree node so its clock marker updates immediately, no reload.
|
||||
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
|
||||
notifications.show({
|
||||
message: next
|
||||
|
||||
@@ -410,6 +410,11 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
slugId: data.slugId,
|
||||
spaceId: data.spaceId,
|
||||
title: data.title,
|
||||
// Carry the death-timer deadline so a note created as temporary keeps its
|
||||
// sidebar clock marker when the tree is rebuilt from this cached entry
|
||||
// (buildTree → mergeRootTrees). Omitting it overwrote the optimistic/socket
|
||||
// node's marker with `undefined`, hiding it until a reload.
|
||||
temporaryExpiresAt: data.temporaryExpiresAt,
|
||||
};
|
||||
|
||||
let queryKey: QueryKey = null;
|
||||
|
||||
@@ -173,7 +173,22 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
// optimistic node's id IS the real created page id (createdPage.id), so
|
||||
// the ids match exactly regardless of which path runs first.
|
||||
setData((prev) => {
|
||||
if (treeModel.find(prev, newNode.id)) return prev;
|
||||
const existing = treeModel.find(prev, newNode.id);
|
||||
if (existing) {
|
||||
// The server `addTreeNode` broadcast won the race and already inserted
|
||||
// this node. Older broadcasts could omit `temporaryExpiresAt`, leaving
|
||||
// a temporary note WITHOUT its clock marker until reload; patch it on
|
||||
// from the authoritative create response so the marker shows now.
|
||||
if (
|
||||
newNode.temporaryExpiresAt &&
|
||||
!(existing as SpaceTreeNode).temporaryExpiresAt
|
||||
) {
|
||||
return treeModel.update(prev, newNode.id, {
|
||||
temporaryExpiresAt: newNode.temporaryExpiresAt,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
return treeModel.insert(prev, parentId, newNode, lastIndex);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Group } from "@mantine/core";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -45,12 +45,16 @@ export default function SpaceCreateNoteButtons() {
|
||||
.finally(() => setPending(null));
|
||||
};
|
||||
|
||||
// Two full-width, vertically stacked buttons: a neutral regular note and an
|
||||
// orange-tinted temporary note. Stacking full-width keeps the longer "New
|
||||
// temporary note" label from clipping on narrow mobile widths.
|
||||
return (
|
||||
<Group grow gap="sm">
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
size="md"
|
||||
variant="light"
|
||||
color="gray"
|
||||
fullWidth
|
||||
leftSection={<IconPlus size={18} />}
|
||||
loading={pending === "regular"}
|
||||
disabled={pending !== null}
|
||||
@@ -61,7 +65,8 @@ export default function SpaceCreateNoteButtons() {
|
||||
<Button
|
||||
size="md"
|
||||
variant="light"
|
||||
color="gray"
|
||||
color="orange"
|
||||
fullWidth
|
||||
leftSection={<IconHourglass size={18} />}
|
||||
loading={pending === "temporary"}
|
||||
disabled={pending !== null}
|
||||
@@ -69,6 +74,6 @@ export default function SpaceCreateNoteButtons() {
|
||||
>
|
||||
{t("New temporary note")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -323,4 +323,18 @@ describe("applyAddTreeNode", () => {
|
||||
"child",
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries temporaryExpiresAt onto the inserted node so the clock marker shows on create (no reload)", () => {
|
||||
// A note created as temporary broadcasts addTreeNode with the death-timer
|
||||
// deadline in its payload; the receiver's inserted node must keep it so
|
||||
// space-tree-row renders the orange clock marker immediately.
|
||||
const tree = roots();
|
||||
const expiresAt = "2026-06-27T21:00:00.000Z";
|
||||
const next = applyAddTreeNode(tree, {
|
||||
parentId: null as unknown as string,
|
||||
index: 0,
|
||||
data: node("temp", { position: "a3", temporaryExpiresAt: expiresAt }),
|
||||
});
|
||||
expect(treeModel.find(next, "temp")?.temporaryExpiresAt).toBe(expiresAt);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user