Skip to content
Closed
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: 2 additions & 2 deletions apps/studio-desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ Linux build once (`e2e/packaged.e2e.test.ts`). It publishes nothing.

- **Opens a recipe folder.** `File > Open Folder…` (Ctrl/Cmd+O), `File > Open Recent`, or a folder on the
command line. With none given it opens the most recent folder that still exists.
- **Picks folders with the native dialog.** In the app the toolbar has "Open folder…" instead of a folder
box and "Open" (the page asks the main process through the preload; a browser tab keeps the box).
- **Picks folders with the native dialog.** In the app **Recipe ▾ → Open folder…** opens the native dialog directly
(the page asks the main process through the preload); a browser tab shows a folder box with Browse… instead.
- **Runs hooks.** `--hooks <file>` (or `--plugins`, or `OPENCRAW_PLUGINS`/`OPENCRAW_HOOKS`) loads a hooks or
plugins module on the launch's own authority, exactly like `opencraw studio --hooks`. A file that fails
to load stops the app with the reason. A second launch's `--hooks` is ignored: the running server keeps its own.
Expand Down
5 changes: 3 additions & 2 deletions apps/studio-desktop/e2e/launch.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@ describeBuilt('studio desktop: launch', () => {
expect(url.hostname).toBe('127.0.0.1')
expect(url.searchParams.get('folder')).toBe(folder)
await page.getByText('Run sample', { exact: true }).first().waitFor()
await page.locator('option', { hasText: 'books' }).waitFor({ state: 'attached' })
await page.getByRole('button', { name: 'Recipe: books ▾' }).waitFor()
})

it('offers the native folder dialog instead of a folder box', async () => {
await page.getByRole('button', { name: 'Open folder…' }).waitFor()
await page.getByRole('button', { name: /^Recipe/ }).click()
await page.getByRole('menuitem', { name: 'Open folder…' }).waitFor()

expect(await page.getByPlaceholder('recipe folder').count()).toBe(0)
expect(await page.evaluate('typeof window.opencrawDesktop.chooseFolder')).toBe('function')
Expand Down
2 changes: 1 addition & 1 deletion apps/studio-desktop/e2e/packaged.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describePackaged('studio desktop: packaged app', () => {
try {
const page = await app.firstWindow()
await page.getByText('Run sample', { exact: true }).first().waitFor()
await page.locator('option', { hasText: 'books' }).waitFor({ state: 'attached' })
await page.getByRole('button', { name: 'Recipe: books ▾' }).waitFor()

expect(new URL(page.url()).searchParams.get('folder')).toBe(folder)
} finally {
Expand Down
36 changes: 25 additions & 11 deletions apps/studio-ui/src/app/app.view.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { resetRunSessionStore, resetStudioUiStore } from '../studio-store'
import App from './app.view'

Expand Down Expand Up @@ -30,11 +30,11 @@ describe('App shell', () => {
it('renders the toolbar, the split layout and the preview strip', () => {
render(<App />)
expect(screen.getByText('OpenCraw Studio')).toBeTruthy()
expect(screen.getByPlaceholderText('recipe folder')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Open' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Recipe ▾' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Load ▾' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Run sample' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Stop' })).toBeTruthy()
expect(screen.getByText(/select a recipe/i)).toBeTruthy()
expect(screen.getByRole('status').textContent).toContain('All changes saved')
expect(screen.getByText(/no records yet/i)).toBeTruthy()
})

Expand All @@ -48,17 +48,31 @@ describe('App shell', () => {
expect(screen.getByText('Record')).toBeTruthy()
})

it('disables "+ New recipe" until a workspace folder is open (issue #110)', () => {
it('keeps "New recipe…" disabled until a workspace folder is open (issue #110)', () => {
render(<App />)
expect(screen.getByRole('button', { name: '+ New recipe' }).hasAttribute('disabled')).toBe(true)
fireEvent.click(screen.getByRole('button', { name: 'Recipe ▾' }))
expect(screen.getByRole('menuitem', { name: 'New recipe…' }).getAttribute('aria-disabled')).toBe('true')
})

it('offers "Open document…" and a drop zone over the whole shell, from the very first screen (issue #120)', () => {
it('offers Load ▾ Document… and Web page (URL)… and a drop zone over the whole shell, from the very first screen (issue #120)', async () => {
render(<App />)
expect(screen.getByRole('button', { name: 'Open document…' }).hasAttribute('disabled')).toBe(false)
expect(screen.getByLabelText('Open document').getAttribute('accept')).toContain('.pdf')
expect(screen.getByRole('button', { name: 'Load ▾' }).hasAttribute('disabled')).toBe(false)
expect(screen.getByLabelText('Load document').getAttribute('accept')).toContain('.pdf')
fireEvent.click(screen.getByRole('button', { name: 'Load ▾' }))
expect(await screen.findByRole('menuitem', { name: 'Document…' })).toBeTruthy()
expect(screen.getByRole('menuitem', { name: 'Web page (URL)…' })).toBeTruthy()
expect(screen.getByTestId('document-drop-zone')).toBeTruthy()
})

it('asks the server for its folder dialog from Recipe ▾ → Open folder…, and falls back to the folder box when there is none', async () => {
const fetchMock = jest.fn().mockResolvedValue({ ok: false, status: 500, json: async () => ({ error: 'there is no folder dialog on this machine: type the folder instead' }) })
Object.defineProperty(globalThis, 'fetch', { value: fetchMock, configurable: true })
history.replaceState({}, '', '/?token=abc')
render(<App />)
fireEvent.click(screen.getByRole('button', { name: 'Recipe ▾' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'Open folder…' }))
expect(await screen.findByPlaceholderText('recipe folder')).toBeTruthy()
})
})

describe('App shell inside the desktop app', () => {
Expand All @@ -69,9 +83,9 @@ describe('App shell inside the desktop app', () => {
Object.defineProperty(globalThis, 'opencrawDesktop', { value: { chooseFolder }, configurable: true })
render(<App />)

fireEvent.click(screen.getByRole('button', { name: 'Recipe ▾' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'Open folder…' }))
expect(screen.queryByPlaceholderText('recipe folder')).toBeNull()
expect(screen.queryByRole('button', { name: 'Open' })).toBeNull()
screen.getByRole('button', { name: 'Open folder…' }).click()
expect(chooseFolder).toHaveBeenCalledTimes(1)
})
})
17 changes: 11 additions & 6 deletions apps/studio-ui/src/app/app.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { RecordEditor, fieldToStepIdOf } from '../record-editor'
import type { MappingRuleJson } from '../record-editor'
import { HookNamesContext } from '../hook-names'
import { activeStubs, useHookStubsStore } from '../hook-stubs'
import { DocumentDropZone, useImportDocumentFlow, useLoadUrlFlow, useOpenPathFromLink } from '../recipe-start'
import { StepsOutline } from '../steps-outline'
import {
createStudioQueryClient,
Expand All @@ -24,12 +25,9 @@ import {
useWorkspaceQuery,
} from '../studio-client'
import { useRunSessionStore, useStudioUiStore } from '../studio-store'
import { DocumentDropZone } from './document-drop-zone.component'
import { desktopBridge } from './desktop-bridge.client'
import { HooksBar } from './hooks-bar.component'
import { useBrowseFolderFlow } from './browse-folder.hook'
import { useImportDocumentFlow } from './import-document.hook'
import { useOpenPathFromLink } from './open-path.hook'
import { Toolbar } from './toolbar.component'

/** The studio's shell: the toolbar, the resizable content/editor split, and the preview strip along the bottom. */
Expand Down Expand Up @@ -85,6 +83,7 @@ function AppShell () {
const saveOutline = useSaveOutlineMutation()
const explainWhy = useExplainWhyMutation()
const importFlow = useImportDocumentFlow()
const urlFlow = useLoadUrlFlow()
const browseFlow = useBrowseFolderFlow()
const linkError = useOpenPathFromLink()

Expand Down Expand Up @@ -130,7 +129,9 @@ function AppShell () {

const askWhy = (target: WhyTarget): void => { explainWhy.mutate(target) }

const workspaceError = linkError ?? importFlow.error ?? browseFlow.error ?? messageOf(workspace.error) ?? runError
const workspaceError = linkError ?? importFlow.error ?? urlFlow.error ?? browseFlow.error ?? messageOf(workspace.error) ?? runError

const loadNotice = importFlow.notice ?? urlFlow.notice

return (
<HookNamesContext.Provider value={workspace.data?.hooks?.names}>
Expand All @@ -140,20 +141,24 @@ function AppShell () {
onFolderChange={setFolder}
onOpen={() => { commitFolder(folder) }}
onChooseFolder={chooseFolder}
onBrowse={() => { void browseFlow.browse() }}
onBrowse={browseFlow.browse}
browsing={browseFlow.pending}
openFolder={openFolder}
inputs={inputs}
selectedRecipeId={selectedRecipeId}
onSelectRecipe={selectRecipe}
onRecipeCreated={selectRecipe}
onImportFile={file => { void importFlow.importFile(file) }}
importing={importFlow.pending}
onLoadUrl={url => { void urlFlow.loadUrl(url) }}
importing={importFlow.pending || urlFlow.pending}
running={running}
onRunSample={() => { if (selectedRecipeId !== undefined) runSample.mutate({ recipeId: selectedRecipeId, stubs: activeStubs(stubEntries) }) }}
onStop={() => { stopRun.mutate() }}
/>
<HooksBar hooks={workspace.data?.hooks} />
{loadNotice !== undefined && workspaceError === undefined && (
<Box role='status' px={4} py={1} bg='blue.subtle' color='blue.fg' fontSize='sm'>{loadNotice}</Box>
)}
{workspaceError !== undefined && (
<Box px={4} py={1} bg='red.subtle' color='red.fg' fontSize='sm'>{workspaceError}</Box>
)}
Expand Down
12 changes: 9 additions & 3 deletions apps/studio-ui/src/app/browse-folder.hook.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ describe('useBrowseFolderFlow', () => {
const fetchMock = answerWith(200, { folder: '/home/ana/recipes' })
const { result } = renderHook(() => useBrowseFolderFlow(), { wrapper })

await act(async () => { await result.current.browse() })
let outcome: string | undefined
await act(async () => { outcome = await result.current.browse() })

expect(outcome).toBe('picked')
expect(JSON.parse((fetchMock.mock.calls[0]?.[1] as RequestInit).body as string)).toEqual({ type: 'choose-folder' })
expect(useStudioUiStore.getState().openFolder).toBe('/home/ana/recipes')
expect(useStudioUiStore.getState().folder).toBe('/home/ana/recipes')
Expand All @@ -39,17 +41,21 @@ describe('useBrowseFolderFlow', () => {
act(() => { useStudioUiStore.getState().commitFolder('/r') })
const { result } = renderHook(() => useBrowseFolderFlow(), { wrapper })

await act(async () => { await result.current.browse() })
let outcome: string | undefined
await act(async () => { outcome = await result.current.browse() })

expect(outcome).toBe('cancelled')
expect(useStudioUiStore.getState().openFolder).toBe('/r')
})

it('says why when the server has no dialog', async () => {
answerWith(500, { error: 'there is no folder dialog on this machine: type the folder instead' })
const { result } = renderHook(() => useBrowseFolderFlow(), { wrapper })

await act(async () => { await result.current.browse() })
let outcome: string | undefined
await act(async () => { outcome = await result.current.browse() })

expect(outcome).toBe('unavailable')
expect(result.current.error).toContain('type the folder instead')
expect(result.current.pending).toBe(false)
})
Expand Down
21 changes: 15 additions & 6 deletions apps/studio-ui/src/app/browse-folder.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,42 @@ import { useState } from 'react'
import { useStudioClient } from '../studio-client'
import { useStudioUiStore } from '../studio-store'

/** How a browse ended: a folder was opened, the dialog was closed without one, or there is no dialog (the caller offers the folder box). */
export type BrowseOutcome = 'picked' | 'cancelled' | 'unavailable'

export interface BrowseFolderFlow {
/** Opens the server's folder dialog; once a folder is picked it becomes the open workspace, as if it had been typed and confirmed. */
browse: () => Promise<void>
browse: () => Promise<BrowseOutcome>
pending: boolean
/** Why no dialog opened (no display, no dialog program installed); the folder box keeps working. */
error?: string
}

/**
* "Browse…": a browser cannot open the folder picker, so the server does it
* on the machine it runs on, which is where the folder is. The answer goes
* through the same `commitFolder` the Open button uses.
* "Open folder…": a browser cannot open the folder picker, so the server does it
* on the machine it runs on, which is where the folder is: the operating
* system's dialog, or the editor's own when Studio runs in the VS Code
* extension. The answer goes through the same `commitFolder` the Open button uses.
*/
export function useBrowseFolderFlow (): BrowseFolderFlow {
const client = useStudioClient()
const commitFolder = useStudioUiStore(state => state.commitFolder)
const [pending, setPending] = useState(false)
const [error, setError] = useState<string>()

async function browse (): Promise<void> {
async function browse (): Promise<BrowseOutcome> {
setError(undefined)
setPending(true)
try {
const folder = await client.chooseFolder()
if (folder !== undefined) commitFolder(folder)
if (folder === undefined) return 'cancelled'
commitFolder(folder)

return 'picked'
} catch (browseError) {
setError(browseError instanceof Error ? browseError.message : String(browseError))

return 'unavailable'
} finally {
setPending(false)
}
Expand Down
42 changes: 42 additions & 0 deletions apps/studio-ui/src/app/dropdown-menu.component.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
import { DropdownMenu } from './dropdown-menu.component'

function renderMenu (onSelect = jest.fn(), onOther = jest.fn()) {
render(
<ChakraProvider value={defaultSystem}>
<DropdownMenu label='Load ▾' items={[{ label: 'Document…', onSelect }, { label: 'Web page…', onSelect: onOther, disabled: true, divider: true }]} />
</ChakraProvider>,
)

return { onSelect, onOther }
}

describe('DropdownMenu', () => {
it('lists its items only once opened, and runs the one chosen and closes', () => {
const { onSelect } = renderMenu()
expect(screen.queryByRole('menuitem')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Load ▾' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Document…' }))
expect(onSelect).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('menuitem')).toBeNull()
})

it('does nothing for a disabled item and stays open', () => {
const { onOther } = renderMenu()
fireEvent.click(screen.getByRole('button', { name: 'Load ▾' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Web page…' }))
expect(onOther).not.toHaveBeenCalled()
expect(screen.getAllByRole('menuitem')).toHaveLength(2)
})

it('closes on Escape and on a click elsewhere', () => {
renderMenu()
fireEvent.click(screen.getByRole('button', { name: 'Load ▾' }))
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menuitem')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Load ▾' }))
fireEvent.mouseDown(document.body)
expect(screen.queryByRole('menuitem')).toBeNull()
})
})
91 changes: 91 additions & 0 deletions apps/studio-ui/src/app/dropdown-menu.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from 'react'
import { Box, Button } from '@chakra-ui/react'

export interface DropdownItem {
/** What the item is called, and what it is found by. */
label: string
onSelect: () => void
disabled?: boolean
/** A heading drawn above this item, to group the list. */
heading?: string
/** A rule drawn above this item. */
divider?: boolean
}

export interface DropdownMenuProps {
/** The button's text, the arrow included. */
label: string
title?: string
disabled?: boolean
loading?: boolean
items: DropdownItem[]
}

/**
* A button that opens a short list under it: a plain popover of buttons rather
* than a menu library, which keeps the toolbar free of an extra dependency and
* behaves the same in the browser, the desktop app and the test DOM. Closes on
* a choice, Escape or a click anywhere else.
*/
export function DropdownMenu ({ label, title, disabled, loading, items }: DropdownMenuProps) {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)

useEffect(() => {
if (!open) return
const close = (event: MouseEvent | KeyboardEvent): void => {
if (event instanceof KeyboardEvent) {
if (event.key === 'Escape') setOpen(false)

return
}
if (rootRef.current !== null && !rootRef.current.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', close)
document.addEventListener('keydown', close)

return () => {
document.removeEventListener('mousedown', close)
document.removeEventListener('keydown', close)
}
}, [open])

return (
<Box ref={rootRef} position='relative' flexShrink={0}>
<Button size='sm' variant='outline' maxW='16em' title={title} disabled={disabled} loading={loading} aria-haspopup='menu' aria-expanded={open} onClick={() => { setOpen(value => !value) }}>
{label}
</Button>
{open && (
<Box role='menu' position='absolute' top='100%' left={0} mt={1} zIndex='dropdown' minW='14em' py={1} bg='bg' borderWidth='1px' borderRadius='md' boxShadow='md'>
{items.map(item => (
<Box key={item.label}>
{item.divider === true && <Box borderTopWidth='1px' my={1} />}
{item.heading !== undefined && <Box px={3} py={1} fontSize='xs' color='fg.muted'>{item.heading}</Box>}
<Box
as='button'
role='menuitem'
display='block'
w='full'
textAlign='left'
px={3}
py={1}
fontSize='sm'
cursor={item.disabled === true ? 'not-allowed' : 'pointer'}
opacity={item.disabled === true ? 0.5 : 1}
_hover={item.disabled === true ? undefined : { bg: 'bg.muted' }}
aria-disabled={item.disabled === true}
onClick={() => {
if (item.disabled === true) return
setOpen(false)
item.onSelect()
}}
>
{item.label}
</Box>
</Box>
))}
</Box>
)}
</Box>
)
}
Loading
Loading