Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .changeset/tanstack-query-webapp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tumaet/webapp": patch
---

No visible change to the editor. Under the hood, the web app now loads and caches version history, version snapshots, and shared diagrams through one shared data layer instead of hand-written fetching, so the version panel refreshes and reconciles more consistently across tabs and collaborators.
59 changes: 59 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions standalone/webapp/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { DocsContainer } from "@storybook/addon-docs/blocks"
import { themes } from "storybook/theming"
import { addons } from "storybook/preview-api"
import { withTanStackRouter } from "../src/stories/_support/webapp"
import { QueryClientProvider } from "@tanstack/react-query"
import { storybookQueryClient } from "../src/stories/_support/queryClient"
import { VersionRepositoryProvider } from "../src/contexts/VersionRepositoryContext"

type DocsContainerCtx = ComponentProps<typeof DocsContainer>["context"]

Expand Down Expand Up @@ -213,7 +216,24 @@ const preview: Preview = {
},
},
tags: ["autodocs"],
// One clean query cache per story: stories that share a query key but inject
// different data would otherwise read each other's cached results.
beforeEach: () => {
storybookQueryClient.clear()
},
decorators: [
// TanStack Query context for components that read server state through
// the query hooks (versioning UI, share flow, legal pages). The shared
// client lives in _support/queryClient so beforeEach hooks can reset it.
// Query cache + the version backend the story's UI talks to (the editor
// routes supply the latter in production).
(Story) => (
<QueryClientProvider client={storybookQueryClient}>
<VersionRepositoryProvider kind="remote">
<Story />
</VersionRepositoryProvider>
</QueryClientProvider>
),
// TanStack router context so any component using <Link>/useNavigate/
// useLocation renders without crashing. Per-story routes and the active
// location are set via the `tanstackRouter` parameter.
Expand Down
4 changes: 4 additions & 0 deletions standalone/webapp/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import pluginJs from "@eslint/js"
import tseslint from "typescript-eslint"
import eslintReact from "@eslint-react/eslint-plugin"
import reactHooks from "eslint-plugin-react-hooks"
import pluginQuery from "@tanstack/eslint-plugin-query"

/** @type {import('eslint').Linter.Config[]} */
export default [
Expand All @@ -26,6 +27,9 @@ export default [
{ languageOptions: { globals: globals.browser } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
// TanStack Query correctness rules (exhaustive query keys, stable
// QueryClient, no misuse of mutation results).
...pluginQuery.configs["flat/recommended"],
// recommended-typescript disables the prop-types rules TypeScript already enforces.
eslintReact.configs["recommended-typescript"],
{
Expand Down
3 changes: 3 additions & 0 deletions standalone/webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@resvg/resvg-wasm": "catalog:",
"@tailwindcss/vite": "catalog:",
"@tanstack/history": "1.162.0",
"@tanstack/react-query": "5.101.2",
"@tanstack/react-router": "1.170.16",
"@tumaet/apollon": "workspace:*",
"@tumaet/ui": "workspace:*",
Expand Down Expand Up @@ -80,6 +81,8 @@
"@storybook/addon-themes": "catalog:",
"@storybook/addon-vitest": "catalog:",
"@storybook/react-vite": "catalog:",
"@tanstack/eslint-plugin-query": "5.100.4",
"@tanstack/react-query-devtools": "5.101.2",
"@tanstack/router-plugin": "1.168.18",
"@testing-library/react": "catalog:",
"@testing-library/user-event": "14.6.1",
Expand Down
14 changes: 11 additions & 3 deletions standalone/webapp/src/AppProviders.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import React, { ReactNode } from "react"
import { QueryClientProvider } from "@tanstack/react-query"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import { EditorProvider, ModalProvider } from "@/contexts"
import { queryClient } from "@/queryClient"

interface Props {
children: ReactNode
}

export const AppProviders: React.FC<Props> = ({ children }) => {
return (
<EditorProvider>
<ModalProvider>{children}</ModalProvider>
</EditorProvider>
<QueryClientProvider client={queryClient}>
<EditorProvider>
<ModalProvider>{children}</ModalProvider>
</EditorProvider>
{/* Self-excludes from production bundles: the package swaps in a no-op
export when NODE_ENV !== "development", so no env gate is needed. */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { fireEvent, screen, waitFor } from "@testing-library/react"
import { ShareDashboardModal } from "./ShareDashboardModal"
import { QueryClientProvider } from "@tanstack/react-query"
import { renderWithRouter } from "@/test/renderWithRouter"
import { createTestQueryClient } from "@/test/queryTestUtils"
import { usePersistenceModelStore } from "@/stores/usePersistenceModelStore"
import { DiagramView } from "@/types"

Expand Down Expand Up @@ -89,7 +91,14 @@ describe("ShareDashboardModal", () => {

const { router } = renderWithRouter(
<ShareDashboardModal modelId="diagram-1" />,
{ routePaths: ["/", "/shared/$diagramId"] }
{
routePaths: ["/", "/shared/$diagramId"],
wrapper: (children) => (
<QueryClientProvider client={createTestQueryClient()}>
{children}
</QueryClientProvider>
),
}
)

fireEvent.click(await screen.findByRole("button", { name: "Create" }))
Expand Down
7 changes: 4 additions & 3 deletions standalone/webapp/src/components/modals/ShareModal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { fireEvent, render, screen } from "@testing-library/react"
import { fireEvent, screen } from "@testing-library/react"
import { renderWithQuery } from "@/test/queryTestUtils"

const { sharedIdRef, createMock } = vi.hoisted(() => ({
sharedIdRef: { value: undefined as string | undefined },
Expand Down Expand Up @@ -46,7 +47,7 @@ beforeEach(() => {
describe("ShareModal", () => {
it("opens straight on the link for an already-shared diagram, with embed", () => {
sharedIdRef.value = "shared-xyz"
render(<ShareModal />)
renderWithQuery(<ShareModal />)

expect(screen.getByLabelText("Copy link")).toBeTruthy()
expect(screen.getByText("Embed")).toBeTruthy()
Expand All @@ -56,7 +57,7 @@ describe("ShareModal", () => {

it("creates the shared diagram exactly once, then shows the link", async () => {
createMock.mockResolvedValue({ id: "new-1" })
render(<ShareModal />)
renderWithQuery(<ShareModal />)

fireEvent.click(screen.getByRole("button", { name: "Create share link" }))
// After creation the link appears and the create button is gone.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react"
import { useMutation } from "@tanstack/react-query"
import { toast } from "react-toastify"
import type { UMLModel } from "@tumaet/apollon"
import { DiagramView } from "@/types"
Expand Down Expand Up @@ -40,6 +41,10 @@ export function useShareableDiagram(

const link = diagramId ? buildSharedDiagramUrl(diagramId, mode) : ""

const createDiagramMutation = useMutation({
mutationFn: (model: UMLModel) => DiagramApiClient.createDiagram(model),
})

const create = async (name: string) => {
if (!modelData) {
toast.error("This diagram can't be shared right now.")
Expand All @@ -52,7 +57,7 @@ export function useShareableDiagram(
trimmed && trimmed !== modelData.title
? { ...modelData, title: trimmed }
: modelData
const { id } = await DiagramApiClient.createDiagram(model)
const { id } = await createDiagramMutation.mutateAsync(model)
addSharedDiagramEntry(id)
setDiagramId(id)
setMode(DiagramView.COLLABORATE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ interface VersionHistoryButtonProps {
* Discoverable navbar entry point for the version-history sidebar.
*
* Renders on any route with an active diagram that has a version backend:
* `/shared/:id` (collab, RemoteVersionRepository) and `/local/:id`
* (standalone, LocalVersionRepository). Legacy `/:id` redirects to
* `/shared/:id` (collab, the collab backend) and `/local/:id`
* (standalone, the local backend). Legacy `/:id` redirects to
* `/shared/:id`, so it is also covered. The gallery (`/`) and the
* playground have no active diagram, so the button is hidden.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
AlertDialogFooter,
} from "@tumaet/ui/components/alert-dialog"
import { useModalContext } from "@/contexts"
import type { PendingVersion } from "@/stores/useVersionStore"
import type { PendingVersion } from "@/types"
import { log } from "@/logger"
import { versioningStrings as t } from "./strings"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,8 @@ import {
} from "lucide-react"
import type { CSSProperties, FC } from "react"
import { cn } from "@tumaet/ui/lib/utils"
import {
selectScopedPreview,
useVersionStore,
type PendingVersion,
} from "@/stores/useVersionStore"
import { selectScopedPreview, useVersionStore } from "@/stores/useVersionStore"
import type { PendingVersion } from "@/types"
import { useClosePreview } from "@/hooks/useVersionPreviewUrlSync"
import { relativeTime } from "./relativeTime"
import {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import { expect, fn, userEvent, within } from "storybook/test"
import { useVersionStore } from "@/stores/useVersionStore"
import { withModalFrame } from "../../stories/_support/webapp"
import {
SAMPLE_DIAGRAM_ID,
Expand Down Expand Up @@ -38,11 +37,13 @@ const meta = {
diagramId: SAMPLE_DIAGRAM_ID,
versionId: VERSION_ID,
version: target,
kind: "remote",
},
argTypes: {
diagramId: { control: false, table: { category: "Data" } },
versionId: { control: false, table: { category: "Data" } },
version: { control: false, table: { category: "Data" } },
kind: { control: false, table: { category: "Data" } },
},
beforeEach: () => {
resetVersionStore()
Expand Down Expand Up @@ -88,23 +89,22 @@ export const ShowsVersionLabel: Story = {
},
}

/** Clicking Delete invokes the store's `deleteVersion` for the target. */
// Repository-level spy: the modal's delete mutation goes through the bound
// `VersionRepository`, so stories assert at the same seam the app uses.
let deleteSpy = fn(async () => {})

/** Clicking Delete invokes the repository's `delete` for the target. */
export const ConfirmDeletes: Story = {
tags: ["test", "!autodocs", "!dev"],
beforeEach: () => {
resetVersionStore()
seedVersions([target])
// Swap in a spy so the play test can assert the delete fired without a backend.
useVersionStore.setState({ deleteVersion: fn(async () => {}) })
deleteSpy = fn(async () => {})
seedVersions([target], { overrides: { delete: deleteSpy } })
},
play: async () => {
const canvas = within(document.body)
const deleteVersion = useVersionStore.getState().deleteVersion
await userEvent.click(canvas.getByRole("button", { name: /^delete$/i }))
await expect(deleteVersion).toHaveBeenCalledWith(
SAMPLE_DIAGRAM_ID,
VERSION_ID
)
await expect(deleteSpy).toHaveBeenCalledWith(SAMPLE_DIAGRAM_ID, VERSION_ID)
},
}

Expand All @@ -113,13 +113,12 @@ export const CancelKeepsVersion: Story = {
tags: ["test", "!autodocs", "!dev"],
beforeEach: () => {
resetVersionStore()
seedVersions([target])
useVersionStore.setState({ deleteVersion: fn(async () => {}) })
deleteSpy = fn(async () => {})
seedVersions([target], { overrides: { delete: deleteSpy } })
},
play: async () => {
const canvas = within(document.body)
const deleteVersion = useVersionStore.getState().deleteVersion
await userEvent.click(canvas.getByRole("button", { name: /cancel/i }))
await expect(deleteVersion).not.toHaveBeenCalled()
await expect(deleteSpy).not.toHaveBeenCalled()
},
}
Loading
Loading