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
7 changes: 7 additions & 0 deletions .changeset/breezy-yaks-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@liam-hq/erd-core": patch
---

- ✨ Add "Show All Tables" and "Hide All Tables" table visibility shortcuts and options in CommandPalette
- Command Palette: new "Show All Tables" and "Hide All Tables" commands
- Subscribe shortcuts: ⇧A for "Show All Tables" and ⇧H for "Hide All Tables"
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { ToastProvider } from '@liam-hq/ui'
import { render, screen } from '@testing-library/react'
import { ReactFlowProvider } from '@xyflow/react'
import { type Node, ReactFlowProvider } from '@xyflow/react'
import { Command } from 'cmdk'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
import type { FC, PropsWithChildren } from 'react'
import { expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { UserEditingProvider } from '../../../../../../stores'
import { CommandPaletteProvider } from '../CommandPaletteProvider'
import { CommandPaletteCommandOptions } from './CommandOptions'

const mockDefaultNodes = vi.fn<() => Node[]>()
const onUrlUpdate = vi.fn<() => [UrlUpdateEvent]>()

const wrapper: FC<PropsWithChildren> = ({ children }) => (
<NuqsTestingAdapter>
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate}>
<UserEditingProvider>
<ReactFlowProvider>
<ReactFlowProvider defaultNodes={mockDefaultNodes()}>
<ToastProvider>
<CommandPaletteProvider>
<Command>{children}</Command>
Expand Down Expand Up @@ -45,3 +48,89 @@ it('renders options with descriptions', async () => {
screen.getByRole('option', { name: 'Show Key Only ⇧ 4' }),
).toBeInTheDocument()
})

describe('show/hide all tables options', () => {
it('shows "Show All Tables " option and hide "Hide All Tables" option when all tables are hidden', () => {
mockDefaultNodes.mockReturnValueOnce([
{
id: '1',
type: 'table',
data: {},
position: { x: 0, y: 0 },
hidden: true,
},
{
id: '2',
type: 'table',
data: {},
position: { x: 0, y: 0 },
hidden: true,
},
])

render(<CommandPaletteCommandOptions />, { wrapper })

expect(
screen.getByRole('option', { name: 'Show All Tables ⇧ A' }),
).toBeInTheDocument()
expect(
screen.queryByRole('option', { name: 'Hide All Tables ⇧ H' }),
).not.toBeInTheDocument()
})

it('shows "Hide All Tables" option and hide "Show All Tables" option when all tables are visible', () => {
mockDefaultNodes.mockReturnValueOnce([
{
id: '1',
type: 'table',
data: {},
position: { x: 0, y: 0 },
hidden: false,
},
{
id: '2',
type: 'table',
data: {},
position: { x: 0, y: 0 },
hidden: false,
},
])

render(<CommandPaletteCommandOptions />, { wrapper })

expect(
screen.queryByRole('option', { name: 'Show All Tables ⇧ A' }),
).not.toBeInTheDocument()
expect(
screen.getByRole('option', { name: 'Hide All Tables ⇧ H' }),
).toBeInTheDocument()
})

it('shows both "Show All Tables" and "Hide All Tables" options when some tables are visible and the others are hidden', () => {
mockDefaultNodes.mockReturnValueOnce([
{
id: '1',
type: 'table',
data: {},
position: { x: 0, y: 0 },
hidden: true,
},
{
id: '2',
type: 'table',
data: {},
position: { x: 0, y: 0 },
hidden: false,
},
])

render(<CommandPaletteCommandOptions />, { wrapper })

expect(
screen.getByRole('option', { name: 'Show All Tables ⇧ A' }),
).toBeInTheDocument()
expect(
screen.getByRole('option', { name: 'Hide All Tables ⇧ H' }),
).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
Copy,
Eye,
EyeOff,
KeyRound,
PanelTop,
RectangleHorizontal,
Expand All @@ -9,6 +11,7 @@ import {
import { Command } from 'cmdk'
import type { FC } from 'react'
import { useUserEditingOrThrow } from '../../../../../../stores'
import { useTableVisibility } from '../../hooks'
import { useCommandPaletteOrThrow } from '../CommandPaletteProvider'
import { useCopyLink } from '../hooks/useCopyLink'
import { useFitScreen } from '../hooks/useFitScreen'
Expand All @@ -19,6 +22,7 @@ export const CommandPaletteCommandOptions: FC = () => {
const { copyLink } = useCopyLink('command-palette')
const { zoomToFit, tidyUp } = useFitScreen()
const { setShowMode } = useUserEditingOrThrow()
const { visibilityStatus, showAllNodes, hideAllNodes } = useTableVisibility()

const { setOpen } = useCommandPaletteOrThrow()

Expand Down Expand Up @@ -103,6 +107,42 @@ export const CommandPaletteCommandOptions: FC = () => {
<span className={styles.keyIcon}>⇧</span>
<span className={styles.keyIcon}>4</span>
</Command.Item>
{visibilityStatus !== 'all-visible' && (
<Command.Item
className={styles.item}
value={getSuggestionText({
type: 'command',
name: 'Show All Tables',
})}
onSelect={() => {
showAllNodes()
setOpen(false)
}}
>
<Eye className={styles.itemIcon} />
<span className={styles.itemText}>Show All Tables</span>
<span className={styles.keyIcon}>⇧</span>
<span className={styles.keyIcon}>A</span>
</Command.Item>
)}
{visibilityStatus !== 'all-hidden' && (
<Command.Item
className={styles.item}
value={getSuggestionText({
type: 'command',
name: 'Hide All Tables',
})}
onSelect={() => {
hideAllNodes()
setOpen(false)
}}
>
<EyeOff className={styles.itemIcon} />
<span className={styles.itemText}>Hide All Tables</span>
<span className={styles.keyIcon}>⇧</span>
<span className={styles.keyIcon}>H</span>
</Command.Item>
)}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

I noticed that Show All and Show All Fields were confusing. In the Left Pane, there was a Show All button within the section listing tables, so it seemed natural to omit the word "Tables".
I've thought about it and would like to request that you change the labels to Show All Tables and Hide All Tables.

Copy link
Member Author

@tnyo43 tnyo43 Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, thank you for pointing that out. I fixed the literals in a435dd6

</Command.Group>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ type Props = {
commandName: string
}

// TODO: set gif or image for "Show All Table" and "Hide All Table" commands
const COMMAND_VIDEO_SOURCE: Record<string, string> = {
'copy link':
'https://assets.liambx.com/erd-core/2025-09-01/videos/copy-link.mp4',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useSubscribeCopyLinkCommand } from './useSubscribeCopyLinkCommand'
import { useSubscribeShowModeCommand } from './useSubscribeSwitchShowMode'
import { useSubscribeTableVisibility } from './useSubscribeTableVisibility'
import { useSubscribeTidyUpCommand } from './useSubscribeTidyUpCommand'
import { useSubscribeZoomToFitCommand } from './useSubscribeZoomToFitCommand'

Expand All @@ -8,4 +9,5 @@ export const useSubscribeCommands = () => {
useSubscribeShowModeCommand()
useSubscribeTidyUpCommand()
useSubscribeZoomToFitCommand()
useSubscribeTableVisibility()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { renderHook, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ReactFlowProvider } from '@xyflow/react'
import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
import type { ReactNode } from 'react'
import { afterEach, expect, it, vi } from 'vitest'
import { UserEditingProvider } from '../../../../../../../stores'
import { compressToEncodedUriComponent } from '../../../../../../../utils/compressToEncodedUriComponent'
import { useSubscribeTableVisibility } from './useSubscribeTableVisibility'

const onUrlUpdate = vi.fn<() => [UrlUpdateEvent]>()

const wrapper = ({ children }: { children: ReactNode }) => (
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate}>
<ReactFlowProvider
Comment on lines +11 to +15
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Fix onUrlUpdate mock typing and align comments with expectations

  • The mock is declared as vi.fn<() => [UrlUpdateEvent]>(), but you pass it to NuqsTestingAdapter and assert it’s called with a single UrlUpdateEvent object:

    expect(onUrlUpdate).toHaveBeenCalledWith(
      expect.objectContaining({ queryString: '...' }),
    )

    This strongly suggests the signature should be something like:

    const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()

    so TypeScript matches the adapter’s callback type and your usage.

  • In the second test (shows all table nodes by ⇧A), the query string is '?hidden=', but the comment still says “hidden query parameter should be added with all node ids”. Consider updating that comment to reflect that the hidden list is cleared instead.

Please double‑check the NuqsTestingAdapter onUrlUpdate type in its docs or .d.ts to confirm the callback signature matches the suggested typing.

Also applies to: 48-55, 64-71


I'll verify the review comment by examining the test file and understanding the NuqsTestingAdapter type requirements.


---

🏁 Script executed:

```shell
#!/bin/bash
# Inspect the test file to see current state
cat "frontend/packages/erd-core/src/features/erd/components/ERDRenderer/CommandPalette/hooks/useSubscribeCommands/useSubscribeTableVisibility.test.tsx"

Length of output: 2109


🏁 Script executed:

#!/bin/bash
# Search for NuqsTestingAdapter type definitions in the codebase
rg -n "NuqsTestingAdapter" --type=ts --type=tsx -A 5 -B 2

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Look for onUrlUpdate type definitions or imports
rg -n "onUrlUpdate" --type=ts --type=tsx -C 3

Length of output: 27


I can see the test file and the typing issue. Let me search for the NuqsTestingAdapter type definition and verify the expected callback signature:





🏁 Script executed:

#!/bin/bash
# Search for NuqsTestingAdapter and onUrlUpdate with corrected rg syntax
rg -n "NuqsTestingAdapter|onUrlUpdate" -A 5 -B 2

Length of output: 48526


🏁 Script executed:

#!/bin/bash
# Check if nuqs types are available in node_modules or package.json
find . -name "package.json" -type f | head -20 | xargs grep -l "nuqs"

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Look for type definitions or d.ts files related to nuqs
fd "nuqs" --type f -e d.ts -e ts | grep -E "(nuqs|testing)" | head -20

Length of output: 0


🌐 Web query:

nuqs NuqsTestingAdapter onUrlUpdate callback signature type definition

💡 Result:

Signature (in TypeScript):

type UrlUpdateEvent = {
searchParams: URLSearchParams;
queryString: string;
options: {
history?: 'push' | 'replace';
shallow?: boolean;
scroll?: boolean;
clearOnDefault?: boolean;
// other per-update options supported by nuqs (hook/adapter-level options)
};
};

type OnUrlUpdateFunction = (event: UrlUpdateEvent) => void;

Explanation: the testing adapter calls onUrlUpdate with an object containing the new URLSearchParams, the rendered query string, and the options used for the update (e.g. options.history is 'push' in examples). Sources: [1], [2].

Sources:
[1] nuqs — Testing docs (shows UrlUpdateEvent properties and example).
[2] nuqs — Options docs (shows per-update options such as history/shallow/scroll).


Fix onUrlUpdate mock callback signature and correct misleading comment

The mock is typed as vi.fn<() => [UrlUpdateEvent]>() (no parameters, returns array), but the actual callback signature expected by NuqsTestingAdapter is (event: UrlUpdateEvent) => void. Update line 11 and all occurrences:

const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()

Additionally, the comment at line 64 contradicts the test expectation. The second test shows the hidden list being cleared (queryString: '?hidden='), not nodes being added. Update the comment to reflect this behavior.

Same typing issue appears in:

  • CommandPaletteOptions/CommandOptions.test.tsx (line 13)
  • ERDRenderer/hooks/useTableVisibility/useTableVisibility.test.tsx (line 11)
🤖 Prompt for AI Agents
In
frontend/packages/erd-core/src/features/erd/components/ERDRenderer/CommandPalette/hooks/useSubscribeCommands/useSubscribeTableVisibility.test.tsx
around lines 11–15, the onUrlUpdate mock is incorrectly typed as a no-arg
function returning an array; change it to the actual signature vi.fn<(event:
UrlUpdateEvent) => void>() and update any other occurrences in this file; also
update the misleading comment at line 64 to state that the hidden list is
cleared (queryString: '?hidden=') instead of nodes being added; apply the same
typing fix (vi.fn<(event: UrlUpdateEvent) => void>()) to the other files noted:
CommandPaletteOptions/CommandOptions.test.tsx (line 13) and
ERDRenderer/hooks/useTableVisibility/useTableVisibility.test.tsx (line 11).

defaultNodes={[
{
id: '1',
type: 'table',
data: {},
position: { x: 0, y: 0 },
hidden: false,
},
{
id: '2',
type: 'table',
data: {},
position: { x: 0, y: 0 },
hidden: true,
},
]}
>
<UserEditingProvider>{children}</UserEditingProvider>
</ReactFlowProvider>
</NuqsTestingAdapter>
)

afterEach(() => {
vi.clearAllMocks()
})

it('hides all table nodes by ⇧H', async () => {
const user = userEvent.setup()
renderHook(() => useSubscribeTableVisibility(), { wrapper })

await user.keyboard('{Shift>}H{/Shift}')

// hidden query parameter should be added with all node ids
await waitFor(() => {
expect(onUrlUpdate).toHaveBeenCalledWith(
expect.objectContaining({
queryString: `?hidden=${compressToEncodedUriComponent('1,2')}`,
}),
)
})
})

it('shows all table nodes by ⇧A', async () => {
const user = userEvent.setup()
renderHook(() => useSubscribeTableVisibility(), { wrapper })

await user.keyboard('{Shift>}A{/Shift}')

// hidden query parameter should be added with all node ids
await waitFor(() => {
expect(onUrlUpdate).toHaveBeenCalledWith(
expect.objectContaining({
queryString: '?hidden=',
}),
)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useEffect } from 'react'
import { useTableVisibility } from '../../../hooks'

export const useSubscribeTableVisibility = () => {
const { showAllNodes, hideAllNodes } = useTableVisibility()

// Show all nodes when ⇧A is pressed
useEffect(() => {
const down = async (event: KeyboardEvent) => {
if (event.code === 'KeyA' && event.shiftKey) {
showAllNodes()
}
}

document.addEventListener('keydown', down)
return () => document.removeEventListener('keydown', down)
}, [showAllNodes])

// Hide all nodes when ⇧H is pressed
useEffect(() => {
const down = async (event: KeyboardEvent) => {
if (event.code === 'KeyH' && event.shiftKey) {
hideAllNodes()
}
}

document.addEventListener('keydown', down)
return () => document.removeEventListener('keydown', down)
}, [hideAllNodes])
}
Comment on lines +1 to +30
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard global hotkeys against editable targets and simplify handlers

The global keydown listeners correctly wire ⇧A / ⇧H to showAllNodes / hideAllNodes, but they currently fire for any Shift+A/H while the ERD page is mounted — including when the user is typing in an <input>, <textarea>, or content‑editable field. That can be pretty surprising UX (e.g., hiding all tables while entering uppercase “A” or “H”).

Consider:

  • Early‑returning when the event originates from an editable element:

    const isEditableTarget = (event: KeyboardEvent) => {
      const target = event.target as HTMLElement | null
      return (
        target?.closest('input, textarea, [contenteditable=""], [contenteditable="true"]') != null
      )
    }
    
    // inside the listener
    if (isEditableTarget(event)) return
  • Dropping the unnecessary async keyword on the listeners since nothing is awaited.

  • Optionally, combining the two effects into one keydown handler that switches on event.code to avoid registering two separate listeners on document.

Based on learnings, direct document access here is fine since the CommandPalette runs only on the client.

🤖 Prompt for AI Agents
In
frontend/packages/erd-core/src/features/erd/components/ERDRenderer/CommandPalette/hooks/useSubscribeCommands/useSubscribeTableVisibility.ts
lines 1-30, the two document keydown handlers fire while the user is typing in
inputs/textarea/contenteditable and both listeners are marked async
unnecessarily; update by replacing the two effects with a single keydown
listener that first guards editable targets (use event.target as HTMLElement and
return early when closest('input, textarea, [contenteditable=""],
[contenteditable="true"]') is non-null), drop the async keyword from the
handler, switch on event.code ('KeyA' and 'KeyH') and event.shiftKey to call
showAllNodes/hideAllNodes, and ensure a single
addEventListener/removeEventListener pair is used in the effect cleanup.

Loading