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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,7 @@ dist
vite.config.ts.timestamp*

turingdb.out

# Playwright
test-results
playwright-report
64 changes: 64 additions & 0 deletions package-lock.json

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

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"build:dev": "vite build --mode development",
"build-and-start": "vite build && vite preview --host 0.0.0.0",
"start": "vite preview --host 0.0.0.0",
"prod": "vite build && node server.js"
"prod": "vite build && node server.js",
"test": "npx playwright test",
"test:ui": "npx playwright test --ui"
},
"name": "turingapp",
"dependencies": {
Expand All @@ -19,6 +21,7 @@
"@blueprintjs/core": "^5.16.2",
"@blueprintjs/icons": "^5.16.0",
"@blueprintjs/select": "^5.2.5",
"@playwright/test": "^1.59.1",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.69.0",
"@types/d3": "^7.4.3",
Expand Down
17 changes: 17 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { defineConfig } from '@playwright/test'

export default defineConfig({
testDir: './tests',
timeout: 30_000,
retries: 0,
use: {
baseURL: 'http://localhost:8080',
headless: true,
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:8080',
reuseExistingServer: !process.env.CI,
timeout: 15_000,
},
})
8 changes: 4 additions & 4 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ export function App() {

return (
<QueryClientProvider client={queryClient}>
<TuringLayout>
<TuringContextProvider>
<TuringContextProvider>
<TuringLayout>
<BlueprintProvider portalClassName={theme === 'dark' ? 'bp5-dark' : 'bp5-light'}>
<TuringFrame />
</BlueprintProvider>
</TuringContextProvider>
</TuringLayout>
</TuringLayout>
</TuringContextProvider>
</QueryClientProvider>
)
}
2 changes: 1 addition & 1 deletion src/hooks/use-cypher-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useAppStore, useCanvasStore, useVisStore } from '@/stores'
import { prepareQuery, ColumnMappingType, type ColumnMapping } from '@/utils/cypher-query-modifier'

// Get the number of rows in a chunk (max length of any column)
function getRowCount(chunk: unknown[][]): number {
function getRowCount(chunk: unknown[]): number {
return Math.max(0, ...chunk.map((col) => (Array.isArray(col) ? col.length : 0)))
}

Expand Down
7 changes: 5 additions & 2 deletions src/hooks/use-graph-entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ export const useGraphEntities = () => {

const { nodeIDs, edgeIDs } = useGraphEntityIDs()

// Stable key: use counts to avoid re-fetches when array references change
const entityKey = `${nodeIDs.length}:${edgeIDs.length}`

return useQuery({
queryKey: ['graph-entities', graph.info, graph.info?.name, nodeIDs, edgeIDs],
queryKey: ['graph-entities', graph.info?.name, entityKey],
queryFn: async () => {
if (!graph.info) return

Expand All @@ -42,7 +45,7 @@ export const useGraphEntities = () => {
const missingEdgesFromCache = cacheEdges.filter((edge) => edge.entry === undefined)

const newCacheNodes =
missingEdgesFromCache.length > 0
missingNodesFromCache.length > 0
? await getNodes({
graph: graph.info.name,
nodeIDs: [...missingNodesFromCache.map((node) => node.id)],
Expand Down
5 changes: 1 addition & 4 deletions src/pages/viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,12 @@ const GraphCanvas: FC<GraphCanvasProps> = (props) => {
const { setContextMenuInfo } = props
const inspectNode = useVisStore((state) => state.inspectNode)

const turing = useTuringContext()
const init = useCanvasStore((state) => state.init)
const closeInspectNodePanel = useVisStore((state) => state.closeInspectNodePanel)
const { newNeighbours, add: addNeighbour } = useVisStore((state) => state.neighbourhood)

useEffect(() => {
init(turing.instance)
closeInspectNodePanel()
}, [init, turing.instance, closeInspectNodePanel])
}, [closeInspectNodePanel])

const events = useRef<Partial<TuringUserEvents>>({
canvassingleclick: (e) => {
Expand Down
29 changes: 23 additions & 6 deletions src/turingcanvas/src/canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React, { useContext, useEffect } from 'react'
import { useStore } from 'zustand'
import { TuringInstance } from './instance'
import { type CanvasStore, type CanvasStoreApi, createCanvasStore } from './store'
import type { PartialTuringUserEvents } from './types'

export type TuringContextProviderProps = {
Expand All @@ -8,22 +10,35 @@ export type TuringContextProviderProps = {

export type TuringContextType = {
instance: TuringInstance
store: CanvasStoreApi
}

export const TuringContext = React.createContext<TuringContextType>({
instance: new TuringInstance(),
})
export const TuringContext = React.createContext<TuringContextType | null>(null)

export const TuringContextProvider: React.FC<TuringContextProviderProps> = (props) => {
const [turing] = React.useState<TuringContextType>({
instance: new TuringInstance(),
const [turing] = React.useState<TuringContextType>(() => {
const instance = new TuringInstance()
const store = createCanvasStore(instance)
return { instance, store }
})

return <TuringContext.Provider value={turing}>{props.children}</TuringContext.Provider>
}

export const useTuringContext = () => {
return React.useContext(TuringContext)
const ctx = React.useContext(TuringContext)
if (!ctx) {
throw new Error('useTuringContext must be used within a TuringContextProvider')
}
return ctx
}

export function useCanvasStore<T>(selector: (state: CanvasStore) => T): T {
const ctx = React.useContext(TuringContext)
if (!ctx) {
throw new Error('useCanvasStore must be used within a TuringContextProvider')
}
return useStore(ctx.store, selector)
}

export interface TuringCanvasProps {
Expand All @@ -38,6 +53,8 @@ export const TuringCanvas: React.FC<TuringCanvasProps> = (props) => {
const turing = useContext(TuringContext)

useEffect(() => {
if (!turing) return

const canvas = document.getElementById(props.id)

if (!(canvas instanceof HTMLCanvasElement)) {
Expand Down
33 changes: 14 additions & 19 deletions src/turingcanvas/src/store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type MutableRefObject, createRef } from 'react'
import type { MutableRefObject } from 'react'
import type {
ActiveCenterForceArgs as ActivateCenterForceArgs,
AddEdgesArgs,
Expand Down Expand Up @@ -26,7 +26,6 @@ type TuringInstanceRef = MutableRefObject<TuringInstance | undefined>
export type CanvasStore = {
instance: TuringInstanceRef

init: (instance: TuringInstance) => void
nodes: () => TuringNode[]
selectedNodes: () => Map<number, TuringNode>
nodeMap: () => NodeMap
Expand Down Expand Up @@ -54,15 +53,13 @@ export type CanvasStore = {
resetStates: (...args: TrackedState[]) => void
}

export const useCanvasStore = create<CanvasStore>((set, get) => {
const instanceRef = createRef<TuringInstance | undefined>() as TuringInstanceRef
export type CanvasStoreApi = ReturnType<typeof createCanvasStore>

return {
instance: instanceRef,
export const createCanvasStore = (instance: TuringInstance) => {
const instanceRef: TuringInstanceRef = { current: instance }

init: (instance: TuringInstance) => {
instanceRef.current = instance
},
return create<CanvasStore>((set, get) => ({
instance: instanceRef,

nodes: () => instanceRef.current?.nodes || [],
selectedNodes: () => instanceRef.current?.selectedNodes || new Map(),
Expand Down Expand Up @@ -150,30 +147,28 @@ export const useCanvasStore = create<CanvasStore>((set, get) => {
},

resetStates: (...states: TrackedState[]) => {
const instance = get().instance

// Prepare a partial state update object
const updatedState = states.reduce(
(acc, stateKey) => {
switch (stateKey) {
case 'nodes':
acc.nodes = () => instance.current?.nodes || []
acc.nodes = () => instanceRef.current?.nodes || []
break
case 'nodeMap':
acc.nodeMap = () => instance.current?.nodeMap || new Map<number, TuringNode>()
acc.nodeMap = () => instanceRef.current?.nodeMap || new Map<number, TuringNode>()
break
case 'edges':
acc.edges = () => instance.current?.edges || []
acc.edges = () => instanceRef.current?.edges || []
break
case 'edgeMap':
acc.edgeMap = () => instance.current?.edgeMap || new Map<number, TuringEdge>()
acc.edgeMap = () => instanceRef.current?.edgeMap || new Map<number, TuringEdge>()
break
case 'selectedNodes':
acc.selectedNodes = () => instance.current?.selectedNodes || new Map()
acc.selectedNodes = () => instanceRef.current?.selectedNodes || new Map()
break
case 'centerForce':
acc.centerForce = () =>
instance.current ? instance.current.simulation.centerForce : true
instanceRef.current ? instanceRef.current.simulation.centerForce : true
break
default:
break
Expand All @@ -186,5 +181,5 @@ export const useCanvasStore = create<CanvasStore>((set, get) => {
// Update the store only with the changed states
set(updatedState)
},
}
})
}))
}
4 changes: 2 additions & 2 deletions src/utils/cypher-query-modifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ function splitReturnClause(clause: string): string[] {
}

// Modify the query to add missing variables before their property projections
function modifyQuery(query: string, parsed: ParsedQuery, missing: Set<string>): string {
function modifyQuery(query: string, missing: Set<string>): string {
if (missing.size === 0) {
return query
}
Expand Down Expand Up @@ -172,7 +172,7 @@ function buildColumnMapping(parsed: ParsedQuery, missing: Set<string>): ColumnMa
export function prepareQuery(query: string): ModifiedQuery {
const parsed = parseCypherQuery(query)
const missing = findMissingVariables(parsed)
const modifiedQuery = modifyQuery(query, parsed, missing)
const modifiedQuery = modifyQuery(query, missing)
const columnMapping = buildColumnMapping(parsed, missing)

return { query: modifiedQuery, columnMapping }
Expand Down
40 changes: 40 additions & 0 deletions tests/app-shell.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { test, expect } from '@playwright/test'
import { mockApi, setupLoadedGraph } from './helpers'

test.describe('App shell', () => {
test('renders sidebar with logo', async ({ page }) => {
await mockApi(page)
await page.goto('/')

await expect(page.locator('[aria-label="turing-logo"]')).toBeVisible()
// Sidebar has a graph icon button
await expect(page.locator('[data-icon="graph"]')).toBeVisible()
})

test('shows top bar with graph selector', async ({ page }) => {
await mockApi(page)
await page.goto('/')

await expect(page.getByText('Viewing:')).toBeVisible()
await expect(page.getByText('No graph selected')).toBeVisible()
})

test('shows "Select a database" when no graph is chosen', async ({ page }) => {
await mockApi(page)
await page.goto('/')

await expect(page.getByText('Select a database to start')).toBeVisible()
})

test('graph selector populates from API', async ({ page }) => {
await mockApi(page, { graphs: ['alpha', 'beta', 'gamma'] })
await page.goto('/')

// Open the graph selector — use role to avoid ambiguity with "No graph selected"
await page.getByRole('button', { name: 'Graph' }).click()

await expect(page.getByText('alpha')).toBeVisible()
await expect(page.getByText('beta')).toBeVisible()
await expect(page.getByText('gamma')).toBeVisible()
})
})
Loading
Loading