From 8e177b7a7a224e19287651cadbe922d6b6a4c43d Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Sun, 26 Sep 2021 12:08:43 -0400 Subject: [PATCH 01/42] FIx passage renaming Avoids an error dialog in Electron due to multiple saves going on simultaneously; also avoids trying to create a passage spuriously while updating links --- package-lock.json | 2 +- src/__mocks__/fs-extra.ts | 1 + .../passage-connections.tsx | 2 - src/dialogs/passage-edit/passage-edit.tsx | 14 +++++- .../main-process/__tests__/ipc.test.ts | 32 ++++++++++++- .../main-process/__tests__/story-file.test.ts | 23 +++++++-- src/electron/main-process/ipc.ts | 48 ++++++++++++++++--- src/electron/main-process/story-file.ts | 19 ++++++-- .../__tests__/update-passage.test.ts | 33 ++++++++++++- .../stories/action-creators/update-passage.ts | 10 ++-- 10 files changed, 160 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed294a12a..157cb7b07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "Twine", - "version": "2.4.0-alpha1", + "version": "2.4.0-beta1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/src/__mocks__/fs-extra.ts b/src/__mocks__/fs-extra.ts index cd008ee3b..8c81c9413 100644 --- a/src/__mocks__/fs-extra.ts +++ b/src/__mocks__/fs-extra.ts @@ -1,4 +1,5 @@ export const copy = jest.fn(); +export const mkdtemp = jest.fn(); export const mkdirp = jest.fn(); export const move = jest.fn(); export const readFile = jest.fn().mockResolvedValue(''); diff --git a/src/components/passage/passage-connections/passage-connections.tsx b/src/components/passage/passage-connections/passage-connections.tsx index c1260b9f7..ef1c104f4 100644 --- a/src/components/passage/passage-connections/passage-connections.tsx +++ b/src/components/passage/passage-connections/passage-connections.tsx @@ -32,8 +32,6 @@ export const PassageConnections: React.FC = props => { referenceParser ]); - console.log(draggableReferences, fixedReferences); - const startPassage = React.useMemo( () => passages.find(passage => passage.id === startPassageId), [passages, startPassageId] diff --git a/src/dialogs/passage-edit/passage-edit.tsx b/src/dialogs/passage-edit/passage-edit.tsx index b42eaac29..8665c72df 100644 --- a/src/dialogs/passage-edit/passage-edit.tsx +++ b/src/dialogs/passage-edit/passage-edit.tsx @@ -73,7 +73,19 @@ export const PassageEditDialog: React.FC = props => { } function handleRename(name: string) { - dispatch(updatePassage(story, passage, {name})); + // Don't create newly linked passages here because the update action will + // try to recreate the passage as it's been renamed--it sees new links in + // existing passages, updates them, but does not see that the passage name + // has been updated since that hasn't happened yet. + + dispatch( + updatePassage( + story, + passage, + {name}, + {dontCreateNewlyLinkedPassages: true} + ) + ); } function handlePassageTextChange(text: string) { diff --git a/src/electron/main-process/__tests__/ipc.test.ts b/src/electron/main-process/__tests__/ipc.test.ts index 9c5b2a620..3265a2763 100644 --- a/src/electron/main-process/__tests__/ipc.test.ts +++ b/src/electron/main-process/__tests__/ipc.test.ts @@ -18,7 +18,10 @@ describe('initIpc()', () => { const saveJsonFileMock = saveJsonFile as jest.Mock; const saveStoryHtmlMock = saveStoryHtml as jest.Mock; - beforeEach(initIpc); + beforeEach(() => { + saveStoryHtmlMock.mockResolvedValue(undefined); + initIpc(); + }); describe('the listener it adds for delete-story events', () => { let listener: any[]; @@ -97,16 +100,41 @@ describe('initIpc()', () => { let story: Story; beforeEach(() => { + jest.spyOn(console, 'log').mockReturnValue(); listener = onMock.mock.calls.find(call => call[0] === 'save-story-html'); story = fakeStory(); }); it('calls saveStoryHtml()', async () => { expect(listener).not.toBeUndefined(); - listener[1]({sender: {send: jest.fn()}}, story, 'test-story-html'); + await listener[1]({sender: {send: jest.fn()}}, story, 'test-story-html'); expect(saveStoryHtmlMock).toBeCalledWith(story, 'test-story-html'); }); + it('queues calls to saveStoryHtml() for the same story ID', async () => { + saveStoryHtmlMock.mockImplementation(() => new Promise(() => {})); + listener[1]({sender: {send: jest.fn()}}, story, 'test-story-html'); + listener[1]({sender: {send: jest.fn()}}, story, 'test-story-html'); + await Promise.resolve(); + await Promise.resolve(); + expect(saveStoryHtmlMock).toBeCalledTimes(1); + }); + + it("doesn't queue calls to saveStoryHtml() for the different story IDs", async () => { + const story1 = fakeStory(); + const story2 = fakeStory(); + + story1.id = 'mock-id-1'; + story2.id = 'mock-id-2'; + + saveStoryHtmlMock.mockImplementation(() => new Promise(() => {})); + listener[1]({sender: {send: jest.fn()}}, story1, 'test-story-html'); + listener[1]({sender: {send: jest.fn()}}, story2, 'test-story-html'); + await Promise.resolve(); + await Promise.resolve(); + expect(saveStoryHtmlMock).toBeCalledTimes(2); + }); + it('sends back a story-html-saved event', async () => { const send = jest.fn(); diff --git a/src/electron/main-process/__tests__/story-file.test.ts b/src/electron/main-process/__tests__/story-file.test.ts index 28e21de14..644a9fa21 100644 --- a/src/electron/main-process/__tests__/story-file.test.ts +++ b/src/electron/main-process/__tests__/story-file.test.ts @@ -1,5 +1,13 @@ import {app, dialog, shell} from 'electron'; -import {move, readdir, readFile, rename, stat, writeFile} from 'fs-extra'; +import { + mkdtemp, + move, + readdir, + readFile, + rename, + stat, + writeFile +} from 'fs-extra'; import { deleteStory, loadStories, @@ -15,7 +23,6 @@ import {fakeStory} from '../../../test-util/fakes'; import {Story} from '../../../store/stories'; import {storyFileName} from '../../shared/story-filename'; -jest.mock('fs-extra'); jest.mock('../track-file-changes'); describe('deleteStory', () => { @@ -268,6 +275,7 @@ describe('renameStory', () => { describe('saveStoryHtml()', () => { const fileWasTouchedMock = fileWasTouched as jest.Mock; + const mkdtempMock = mkdtemp as jest.Mock; const moveMock = move as jest.Mock; const quitMock = app.quit as jest.Mock; const relaunchMock = app.relaunch as jest.Mock; @@ -279,6 +287,9 @@ describe('saveStoryHtml()', () => { beforeEach(() => { jest.spyOn(console, 'log').mockReturnValue(); jest.spyOn(console, 'error').mockReturnValue(); + mkdtempMock.mockImplementation( + async (prefix: string) => `mkdtemp-mock-${prefix}` + ); story = fakeStory(); }); @@ -286,14 +297,18 @@ describe('saveStoryHtml()', () => { await saveStoryHtml(story, 'story html'); expect(writeFileMock.mock.calls).toEqual([ [ - `mock-electron-app-path-temp/${storyFileName(story)}`, + `mkdtemp-mock-mock-electron-app-path-temp/twine-${ + story.id + }/${storyFileName(story)}`, 'story html', 'utf8' ] ]); expect(moveMock.mock.calls).toEqual([ [ - `mock-electron-app-path-temp/${storyFileName(story)}`, + `mkdtemp-mock-mock-electron-app-path-temp/twine-${ + story.id + }/${storyFileName(story)}`, `mock-electron-app-path-documents/common.appName/electron.storiesDirectoryName/${storyFileName( story )}`, diff --git a/src/electron/main-process/ipc.ts b/src/electron/main-process/ipc.ts index f35eaa2f1..0c370399d 100644 --- a/src/electron/main-process/ipc.ts +++ b/src/electron/main-process/ipc.ts @@ -4,13 +4,25 @@ import {saveJsonFile} from './json-file'; import {openWithTempFile} from './open-with-temp-file'; import {deleteStory, renameStory, saveStoryHtml} from './story-file'; +// It's possible for a second `save-story-html` message to be sent while one is +// in-progress. This race condition can cause saving to fail, so saves on a +// single story must be queued up. This uses the ID of the story object as key +// so that multiple stories can be saved independently; because of this, IDs +// must not change during an application session (but it's OK if they vary +// between sessions). + +const saveStoryQueue: Record> = {}; + export function initIpc() { ipcMain.on('delete-story', async (event, story) => { try { await deleteStory(story); event.sender.send('story-deleted', story); } catch (error) { - dialog.showErrorBox(i18n.t('electron.errors.storyDelete'), error.message); + dialog.showErrorBox( + i18n.t('electron.errors.storyDelete'), + (error as Error).message + ); throw error; } }); @@ -24,7 +36,10 @@ export function initIpc() { await renameStory(oldStory, newStory); event.sender.send('story-renamed', oldStory, newStory); } catch (error) { - dialog.showErrorBox(i18n.t('electron.errors.storyRename'), error.message); + dialog.showErrorBox( + i18n.t('electron.errors.storyRename'), + (error as Error).message + ); throw error; } }); @@ -33,7 +48,10 @@ export function initIpc() { try { await saveJsonFile(filename, data); } catch (error) { - dialog.showErrorBox(i18n.t('electron.errors.jsonSave'), error.message); + dialog.showErrorBox( + i18n.t('electron.errors.jsonSave'), + (error as Error).message + ); throw error; } }); @@ -48,10 +66,28 @@ export function initIpc() { throw new Error('Asked to save empty string as story HTML'); } - await saveStoryHtml(story, storyHtml); - event.sender.send('story-html-saved', story); + const savePromise = () => + saveStoryHtml(story, storyHtml) + .then(() => event.sender.send('story-html-saved', story)) + .catch(error => + dialog.showErrorBox( + i18n.t('electron.errors.storySave'), + (error as Error).message + ) + ); + + console.log(`Queuing save for story ID ${story.id}`); + + if (!saveStoryQueue[story.id]) { + saveStoryQueue[story.id] = savePromise(); + } else { + saveStoryQueue[story.id].then(savePromise); + } } catch (error) { - dialog.showErrorBox(i18n.t('electron.errors.storySave'), error.message); + dialog.showErrorBox( + i18n.t('electron.errors.storySave'), + (error as Error).message + ); throw error; } }); diff --git a/src/electron/main-process/story-file.ts b/src/electron/main-process/story-file.ts index 88e63a7cf..d555a7b2e 100644 --- a/src/electron/main-process/story-file.ts +++ b/src/electron/main-process/story-file.ts @@ -1,5 +1,13 @@ import {app, dialog, shell} from 'electron'; -import {move, readdir, readFile, rename, stat, writeFile} from 'fs-extra'; +import { + mkdtemp, + move, + readdir, + readFile, + rename, + stat, + writeFile +} from 'fs-extra'; import {basename, join} from 'path'; import {i18n} from './locales'; import {storyDirectoryPath} from './story-directory'; @@ -54,11 +62,15 @@ export async function saveStoryHtml(story: Story, storyHtml: string) { // so that if any step fails, the original file is left intact. const savedFilePath = join(storyDirectoryPath(), storyFileName(story)); - const tempFilePath = join(app.getPath('temp'), storyFileName(story)); console.log(`Saving ${savedFilePath}`); try { + const tempFileDirectory = await mkdtemp( + join(app.getPath('temp'), `twine-${story.id}`) + ); + const tempFilePath = join(tempFileDirectory, storyFileName(story)); + if (await wasFileChangedExternally(savedFilePath)) { const {response} = await dialog.showMessageBox({ buttons: [ @@ -84,8 +96,9 @@ export async function saveStoryHtml(story: Story, storyHtml: string) { overwrite: true }); fileWasTouched(savedFilePath); + console.log(`Successfully saved ${savedFilePath}`); } catch (e) { - console.error(`Error while saving story: ${e}`); + console.error(`Error while saving ${savedFilePath}: ${e}`); throw e; } } diff --git a/src/store/stories/action-creators/__tests__/update-passage.test.ts b/src/store/stories/action-creators/__tests__/update-passage.test.ts index 157a8240d..73186d9a7 100644 --- a/src/store/stories/action-creators/__tests__/update-passage.test.ts +++ b/src/store/stories/action-creators/__tests__/update-passage.test.ts @@ -20,7 +20,7 @@ describe('updatePassage action creator', () => { }); describe('The thunk it returns', () => { - it('calls dispatch with an updateStory action type', () => { + it('calls dispatch with an updatePassage action type', () => { updatePassage(story, story.passages[0], {name: 'test name'})( dispatch, getState @@ -37,6 +37,37 @@ describe('updatePassage action creator', () => { ]); }); + it('dispatches update actions to update links to the passage if its name changes', () => { + story = fakeStory(3); + story.passages[0].name = 'a'; + story.passages[1].text = '[[a]]'; + story.passages[2].text = 'unlinked'; + updatePassage( + story, + story.passages[0], + {name: 'test name'}, + {dontCreateNewlyLinkedPassages: true} + )(dispatch, getState); + expect(dispatchMock.mock.calls).toEqual([ + [ + { + passageId: story.passages[0].id, + props: {name: 'test name'}, + storyId: story.id, + type: 'updatePassage' + } + ], + [ + { + passageId: story.passages[1].id, + props: {text: '[[test name]]'}, + storyId: story.id, + type: 'updatePassage' + } + ] + ]); + }); + it("throws an error if the passage doesn't belong to the story", () => expect(() => updatePassage( diff --git a/src/store/stories/action-creators/update-passage.ts b/src/store/stories/action-creators/update-passage.ts index 5aa466f1a..76dfe5997 100644 --- a/src/store/stories/action-creators/update-passage.ts +++ b/src/store/stories/action-creators/update-passage.ts @@ -85,10 +85,12 @@ export function updatePassage( '[[' + newNameEscaped + '$1$2]]' ); - updatePassage(story, relinkedPassage, {text: newText})( - dispatch, - getState - ); + updatePassage( + story, + relinkedPassage, + {text: newText}, + options + )(dispatch, getState); } }); } From 1cddaaeeb4a49aeb0470fb90cd3f9dcdabf84e9f Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Sun, 26 Sep 2021 21:12:55 -0400 Subject: [PATCH 02/42] Add debouncing to passage editing --- src/dialogs/passage-edit/passage-edit.tsx | 11 +++--- src/dialogs/passage-edit/passage-text.tsx | 42 +++++++++++++++++++++-- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/dialogs/passage-edit/passage-edit.tsx b/src/dialogs/passage-edit/passage-edit.tsx index 8665c72df..059a9e768 100644 --- a/src/dialogs/passage-edit/passage-edit.tsx +++ b/src/dialogs/passage-edit/passage-edit.tsx @@ -50,6 +50,13 @@ export const PassageEditDialog: React.FC = props => { ); const {t} = useTranslation(); + const handlePassageTextChange = React.useCallback( + (text: string) => { + dispatch(updatePassage(story, passage, {text})); + }, + [dispatch, passage, story] + ); + // TODO: make tag changes undoable function handleAddTag(name: string, color?: Color) { @@ -88,10 +95,6 @@ export const PassageEditDialog: React.FC = props => { ); } - function handlePassageTextChange(text: string) { - dispatch(updatePassage(story, passage, {text})); - } - function handleSetAsStart() { dispatch(updateStory(stories, story, {startPassage: passageId})); } diff --git a/src/dialogs/passage-edit/passage-text.tsx b/src/dialogs/passage-edit/passage-text.tsx index f16c65bfe..718e72ab7 100644 --- a/src/dialogs/passage-edit/passage-text.tsx +++ b/src/dialogs/passage-edit/passage-text.tsx @@ -1,3 +1,4 @@ +import {debounce} from 'lodash'; import * as React from 'react'; import {DialogEditor} from '../../components/container/dialog-card'; import {CodeArea} from '../../components/control/code-area'; @@ -16,11 +17,47 @@ export interface PassageTextProps { export const PassageText: React.FC = props => { const {onChange, onEditorChange, passage, storyFormat} = props; + const [changePending, setChangePending] = React.useState(false); + const [localText, setLocalText] = React.useState(passage.text); const [editor, setEditor] = React.useState(); const {prefs} = usePrefsContext(); const mode = useFormatCodeMirrorMode(storyFormat.name, storyFormat.version) ?? 'text'; + // Effects to handle debouncing updates upward. The idea here is that the + // component maintains a local state so that the CodeMirror instance always is + // up-to-date with what the user has typed, but the global context may not be. + // This is because updating global context causes re-rendering in the story + // map, which can be time-intensive. + + React.useEffect(() => { + // A change to passage text has occurred externally, e.g. through a find and + // replace. We ignore this if a change is pending so that users don't see + // things they've typed in disappear or be replaced. + + if (!changePending && localText !== passage.text) { + setLocalText(passage.text); + } + }, [changePending, localText, passage.text]); + + // The code below handles user changes in the text field. 750ms is an + // eyeballed number. + + const debouncedOnChange = React.useMemo( + () => + debounce((value: string) => { + onChange(value); + setChangePending(false); + }, 750), + [onChange] + ); + + React.useEffect(() => { + if (changePending && localText !== passage.text) { + debouncedOnChange(localText); + } + }, [changePending, debouncedOnChange, localText, passage.text]); + function handleMount(editor: CodeMirror.Editor) { setEditor(editor); onEditorChange(editor); @@ -41,7 +78,8 @@ export const PassageText: React.FC = props => { text: string ) { onEditorChange(editor); - onChange(text); + setChangePending(true); + setLocalText(text); } return ( @@ -55,7 +93,7 @@ export const PassageText: React.FC = props => { onBeforeChange={handleChange} onChange={onEditorChange} options={{mode, lineWrapping: true}} - value={passage.text} + value={localText} /> From 76bfc626a5eadc1a48c05a5f10c531bc9921a404 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Mon, 27 Sep 2021 21:40:45 -0400 Subject: [PATCH 03/42] Make extra space in story map proportional to window size --- src/components/passage/passage-map/passage-map.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/passage/passage-map/passage-map.tsx b/src/components/passage/passage-map/passage-map.tsx index 04aeb6ec0..9e1d8ee73 100644 --- a/src/components/passage/passage-map/passage-map.tsx +++ b/src/components/passage/passage-map/passage-map.tsx @@ -65,8 +65,6 @@ function dragReducer(state: DragState, action: DragAction) { } } -const extraSpace = 500; - export const PassageMap: React.FC = props => { const { formatName, @@ -93,8 +91,12 @@ export const PassageMap: React.FC = props => { const scaledWindowWidth = window.innerWidth / zoom; return { - height: Math.max(passageBounds.height, scaledWindowHeight) + extraSpace, - width: Math.max(passageBounds.width, scaledWindowWidth) + extraSpace + height: + Math.max(passageBounds.height, scaledWindowHeight) + + window.innerHeight * 0.75, + width: + Math.max(passageBounds.width, scaledWindowWidth) + + window.innerWidth * 0.75 }; }, [passages, zoom]); From 32215f499bb2734a6ee74e60637d199c9acd73a7 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Thu, 30 Sep 2021 17:20:34 -0400 Subject: [PATCH 04/42] Add unit tests for basic UI components --- .../control/__tests__/file-input.test.tsx | 35 +++++++++ .../control/__tests__/icon-button.test.tsx | 12 +++- .../control/__tests__/icon-link.test.tsx | 47 ++++++++++++ .../control/__tests__/text-input.test.tsx | 72 +++++++++++++++++++ .../control/__tests__/text-select.test.tsx | 71 ++++++++++++++++++ src/components/control/file-input.css | 24 +++++++ src/components/control/file-input.tsx | 19 ++++- src/components/control/text-select.tsx | 1 + src/routes/story-import/upload-file.tsx | 11 ++- 9 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 src/components/control/__tests__/file-input.test.tsx create mode 100644 src/components/control/__tests__/icon-link.test.tsx create mode 100644 src/components/control/__tests__/text-input.test.tsx create mode 100644 src/components/control/__tests__/text-select.test.tsx create mode 100644 src/components/control/file-input.css diff --git a/src/components/control/__tests__/file-input.test.tsx b/src/components/control/__tests__/file-input.test.tsx new file mode 100644 index 000000000..e995f1ca2 --- /dev/null +++ b/src/components/control/__tests__/file-input.test.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import {axe} from 'jest-axe'; +import {FileInput, FileInputProps} from '../file-input'; +import {render, screen} from '@testing-library/react'; + +describe('', () => { + function renderComponent(props?: Partial) { + return render( + + mock-label + + ); + } + + it('renders a file input with the file types specified by the prop', () => { + renderComponent({accept: 'text/plain'}); + + const input = screen.getByLabelText('mock-label'); + + expect(input).toBeInTheDocument(); + }); + + // These will require mocking of the FileReader class. + + it.todo('calls the onChange prop when the input is changed'); + it.todo( + 'calls the onError prop when the input is changed and the file fails to be read' + ); + + it('is accessible', async () => { + const {container} = renderComponent(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/components/control/__tests__/icon-button.test.tsx b/src/components/control/__tests__/icon-button.test.tsx index 90804c882..8ab27cc00 100644 --- a/src/components/control/__tests__/icon-button.test.tsx +++ b/src/components/control/__tests__/icon-button.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import {axe} from 'jest-axe'; -import {fireEvent, render, screen} from '@testing-library/react'; +import {createEvent, fireEvent, render, screen} from '@testing-library/react'; import {IconButton, IconButtonProps} from '../icon-button'; describe('', () => { @@ -42,6 +42,16 @@ describe('', () => { expect(onClick).toHaveBeenCalledTimes(1); }); + it('prevents the default action from taking place if the preventDefault prop is true', () => { + renderComponent({preventDefault: true}); + + const button = screen.getByRole('button'); + const preventedEvent = createEvent.click(button); + + fireEvent(button, preventedEvent); + expect(preventedEvent.defaultPrevented).toBe(true); + }); + it('is accessible', async () => { const {container} = render( } label="mock-label" /> diff --git a/src/components/control/__tests__/icon-link.test.tsx b/src/components/control/__tests__/icon-link.test.tsx new file mode 100644 index 000000000..bec15bb93 --- /dev/null +++ b/src/components/control/__tests__/icon-link.test.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import {axe} from 'jest-axe'; +import {render, screen} from '@testing-library/react'; +import {IconLink, IconLinkProps} from '../icon-link'; + +describe('', () => { + function renderComponent(props?: Partial) { + return render( + } + label="mock-label" + {...props} + /> + ); + } + + it('renders a link to the href specified', () => { + renderComponent({href: 'test-href'}); + + const link = screen.getByRole('link'); + + expect(link).toBeInTheDocument(); + expect(link.getAttribute('href')).toBe('test-href'); + }); + + it('renders a link with the target specified', () => { + renderComponent({target: '__blank'}); + expect(screen.getByRole('link').getAttribute('target')).toBe('__blank'); + }); + + it('renders the icon prop', () => { + renderComponent(); + expect(screen.getByTestId('mock-icon')).toBeInTheDocument(); + }); + + it('renders the label prop', () => { + renderComponent(); + expect(screen.getByText('mock-label')).toBeInTheDocument(); + }); + + it('is accessible', async () => { + const {container} = renderComponent(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/components/control/__tests__/text-input.test.tsx b/src/components/control/__tests__/text-input.test.tsx new file mode 100644 index 000000000..80add3cfd --- /dev/null +++ b/src/components/control/__tests__/text-input.test.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import {axe} from 'jest-axe'; +import {fireEvent, render, screen} from '@testing-library/react'; +import {TextInput, TextInputProps} from '../text-input'; + +describe('', () => { + function renderComponent(props?: Partial) { + return render( + + ); + } + + it('renders a text input with the value set', () => { + renderComponent({value: 'test-value'}); + + const field = screen.getByRole('textbox'); + + expect(field).toBeInTheDocument(); + expect(field.getAttribute('value')).toBe('test-value'); + }); + + it('renders a text input with the placeholder attribute', () => { + renderComponent({placeholder: 'test-placeholder'}); + + const field = screen.getByRole('textbox'); + + expect(field).toBeInTheDocument(); + expect(field.getAttribute('placeholder')).toBe('test-placeholder'); + }); + + it('allows its type to be changed via prop', () => { + renderComponent({type: 'search'}); + + const field = screen.getByRole('searchbox'); + + expect(field).toBeInTheDocument(); + expect(field.getAttribute('type')).toBe('search'); + }); + + it('defaults to a text type input', () => { + renderComponent({placeholder: 'test-placeholder'}); + + const field = screen.getByRole('textbox'); + + expect(field).toBeInTheDocument(); + expect(field.getAttribute('type')).toBe('text'); + }); + + it('calls the onChange prop when the input receives a change event', () => { + const onChange = jest.fn(); + + renderComponent({onChange}); + expect(onChange).toHaveBeenCalledTimes(0); + fireEvent.change(screen.getByRole('textbox'), {target: {value: ''}}); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('calls the onInput prop when the input receives an input event', () => { + const onInput = jest.fn(); + + renderComponent({onInput}); + expect(onInput).toHaveBeenCalledTimes(0); + fireEvent.input(screen.getByRole('textbox')); + expect(onInput).toHaveBeenCalledTimes(1); + }); + + it('is accessible', async () => { + const {container} = renderComponent(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/components/control/__tests__/text-select.test.tsx b/src/components/control/__tests__/text-select.test.tsx new file mode 100644 index 000000000..edcc1abd3 --- /dev/null +++ b/src/components/control/__tests__/text-select.test.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import {axe} from 'jest-axe'; +import {fireEvent, render, screen} from '@testing-library/react'; +import {TextSelect, TextSelectProps} from '../text-select'; + +describe('', () => { + function renderComponent(props?: Partial) { + return render( + + mock-label + + ); + } + + it('renders a select with options as determined by the options prop', () => { + renderComponent({ + options: [ + {label: 'test-label-1', value: 'test-value-1'}, + {disabled: true, label: 'test-label-2', value: 'test-value-2'} + ] + }); + + const select = screen.getByRole('combobox'); + + expect(select).toBeInTheDocument(); + + const options = select.querySelectorAll('option'); + + expect(options.length).toBe(2); + expect(options[0].innerHTML).toBe('test-label-1'); + expect(options[0].getAttribute('disabled')).toBe(null); + expect(options[0].getAttribute('value')).toBe('test-value-1'); + expect(options[1].innerHTML).toBe('test-label-2'); + expect(options[1].getAttribute('disabled')).not.toBe(null); + expect(options[1].getAttribute('value')).toBe('test-value-2'); + }); + + it('sets the value of the select based on the value prop', () => { + renderComponent({ + options: [ + {label: 'test-label-1', value: 'test-value-1'}, + {label: 'test-label-2', value: 'test-value-2'} + ], + value: 'test-value-2' + }); + + expect(screen.getByRole('combobox')).toHaveValue('test-value-2'); + }); + + it('calls the onChange prop when the select changes', () => { + const onChange = jest.fn(); + + renderComponent({onChange}); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.change(screen.getByRole('combobox'), { + target: {value: 'test-value-1'} + }); + expect(onChange).toBeCalledTimes(1); + }); + + it('is accessible', async () => { + const {container} = renderComponent(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/components/control/file-input.css b/src/components/control/file-input.css new file mode 100644 index 000000000..373c031b3 --- /dev/null +++ b/src/components/control/file-input.css @@ -0,0 +1,24 @@ +@import '../../styles/colors.css'; +@import '../../styles/depth.css'; +@import '../../styles/metrics.css'; +@import '../../styles/typography.css'; + +.file-input > label { + align-items: center; + display: inline-flex; + position: relative; +} + +.file-input-control { + position: relative; +} + +.file-input.orientation-vertical > label { + align-items: flex-start; + flex-direction: column; +} + +.file-input.orientation-vertical > label input { + margin-left: 0; + margin-top: var(--grid-size); +} diff --git a/src/components/control/file-input.tsx b/src/components/control/file-input.tsx index b6a6c2def..355d3ee45 100644 --- a/src/components/control/file-input.tsx +++ b/src/components/control/file-input.tsx @@ -1,15 +1,23 @@ +import classNames from 'classnames'; import * as React from 'react'; +import './file-input.css'; // See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file export interface FileInputProps { accept?: string; + children: React.ReactNode; onChange: (file: File, data: string) => void; onError?: (error: Error) => void; + orientation?: 'horizontal' | 'vertical'; } export const FileInput: React.FC = props => { - const {children, onChange, onError, ...otherProps} = props; + const {children, onChange, onError, orientation, ...otherProps} = props; + const className = classNames( + 'file-input', + `orientation-${orientation ?? 'horizontal'}` + ); function handleChange(changeEvent: React.ChangeEvent) { if (!changeEvent.target.files) { @@ -35,8 +43,13 @@ export const FileInput: React.FC = props => { } return ( - - + + ); }; diff --git a/src/components/control/text-select.tsx b/src/components/control/text-select.tsx index ea656b667..3bd1911c0 100644 --- a/src/components/control/text-select.tsx +++ b/src/components/control/text-select.tsx @@ -9,6 +9,7 @@ export interface SelectOption { } export interface TextSelectProps { + children: React.ReactNode; onChange?: React.ChangeEventHandler; options: SelectOption[]; orientation?: 'horizontal' | 'vertical'; diff --git a/src/routes/story-import/upload-file.tsx b/src/routes/story-import/upload-file.tsx index 13fe8d293..012a5034b 100644 --- a/src/routes/story-import/upload-file.tsx +++ b/src/routes/story-import/upload-file.tsx @@ -18,8 +18,15 @@ export const UploadFile: React.FC = props => { return (
-

{t('routes.storyImport.uploadPrompt')}

- +

+ + {t('routes.storyImport.uploadPrompt')} + +

); }; From 49edf972aa227905c708c5a12f14160364cf709d Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Thu, 30 Sep 2021 21:17:05 -0400 Subject: [PATCH 05/42] Add text ellipsis to long dialog titles --- src/components/container/dialog-card/dialog-card.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/container/dialog-card/dialog-card.css b/src/components/container/dialog-card/dialog-card.css index da327b35d..d0381da2e 100644 --- a/src/components/container/dialog-card/dialog-card.css +++ b/src/components/container/dialog-card/dialog-card.css @@ -16,11 +16,18 @@ } .dialog-card h2 .dialog-card-header { + flex-shrink: 1; flex-grow: 1; + min-width: 0; } .dialog-card h2 .dialog-card-header .icon-button { + display: block; font-weight: bold; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; width: 100%; } From 86404eb123472fd9432b855ef768be3d3069c044 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Sat, 2 Oct 2021 13:03:19 -0400 Subject: [PATCH 06/42] Add test --- .../__tests__/checkbox-button.test.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/components/control/__tests__/checkbox-button.test.tsx diff --git a/src/components/control/__tests__/checkbox-button.test.tsx b/src/components/control/__tests__/checkbox-button.test.tsx new file mode 100644 index 000000000..1ec187325 --- /dev/null +++ b/src/components/control/__tests__/checkbox-button.test.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import {axe} from 'jest-axe'; +import {cleanup, fireEvent, render, screen} from '@testing-library/react'; +import {CheckboxButton, CheckboxButtonProps} from '../checkbox-button'; + +describe('', () => { + function renderComponent(props?: Partial) { + return render( + + ); + } + + it('renders a control with checkbox ARIA role', () => { + renderComponent(); + expect(screen.getByRole('checkbox')).toBeInTheDocument(); + }); + + it('sets the checked state based on the value prop', () => { + renderComponent({value: false}); + expect(screen.getByRole('checkbox')).not.toBeChecked(); + cleanup(); + renderComponent({value: true}); + expect(screen.getByRole('checkbox')).toBeChecked(); + }); + + it('sets the icon based on the value prop', () => { + renderComponent({ + checkedIcon: 'mock-checked-icon', + uncheckedIcon: 'mock-unchecked-icon', + value: false + }); + expect(screen.getByText('mock-unchecked-icon')).toBeInTheDocument(); + cleanup(); + renderComponent({ + checkedIcon: 'mock-checked-icon', + uncheckedIcon: 'mock-unchecked-icon', + value: true + }); + expect(screen.getByText('mock-checked-icon')).toBeInTheDocument(); + }); + + it('calls onChange when the value of the checkbox changes', () => { + const onChange = jest.fn(); + + renderComponent({onChange}); + expect(onChange).toHaveBeenCalledTimes(0); + fireEvent.click(screen.getByRole('checkbox').querySelector('button')!); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('is accessible', async () => { + const {container} = renderComponent(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); From ae78bb4006161156dd018fdcf6cd96c6cbae5853 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Mon, 4 Oct 2021 19:44:40 -0400 Subject: [PATCH 07/42] Fix dialog header button alignment, add unit tests --- .../__tests__/dialog-card.test.tsx | 56 +++++++++++++++++++ .../__tests__/dialog-editor.test.tsx | 17 ++++++ .../container/dialog-card/dialog-card.css | 1 + 3 files changed, 74 insertions(+) create mode 100644 src/components/container/dialog-card/__tests__/dialog-card.test.tsx create mode 100644 src/components/container/dialog-card/__tests__/dialog-editor.test.tsx diff --git a/src/components/container/dialog-card/__tests__/dialog-card.test.tsx b/src/components/container/dialog-card/__tests__/dialog-card.test.tsx new file mode 100644 index 000000000..8c1193568 --- /dev/null +++ b/src/components/container/dialog-card/__tests__/dialog-card.test.tsx @@ -0,0 +1,56 @@ +import {cleanup, fireEvent, render, screen} from '@testing-library/react'; +import {axe} from 'jest-axe'; +import * as React from 'react'; +import {DialogCard, DialogCardProps} from '../dialog-card'; + +describe('', () => { + function renderComponent(props?: Partial) { + return render( + +
+ + ); + } + + it('calls the onChangeCollapsed prop when the header button is clicked', () => { + const onChangeCollapsed = jest.fn(); + + renderComponent({onChangeCollapsed, headerLabel: 'test-label'}); + expect(onChangeCollapsed).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText('test-label')); + expect(onChangeCollapsed).toHaveBeenCalledTimes(1); + }); + + it('calls the onClose prop when the close button is clicked', () => { + const onClose = jest.fn(); + + renderComponent({onClose}); + expect(onClose).not.toHaveBeenCalled(); + fireEvent.click(screen.getByLabelText('common.close')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('displays its children when expanded', () => { + renderComponent({collapsed: false}); + expect(screen.getByTestId('dialog-card-children')).toBeInTheDocument(); + }); + + it('does not display children when collapsed', () => { + renderComponent({collapsed: true}); + expect( + screen.queryByTestId('dialog-card-children') + ).not.toBeInTheDocument(); + }); + + it('is accessible', async () => { + const {container} = renderComponent(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/components/container/dialog-card/__tests__/dialog-editor.test.tsx b/src/components/container/dialog-card/__tests__/dialog-editor.test.tsx new file mode 100644 index 000000000..3741f1676 --- /dev/null +++ b/src/components/container/dialog-card/__tests__/dialog-editor.test.tsx @@ -0,0 +1,17 @@ +import {render, screen} from '@testing-library/react'; +import {axe} from 'jest-axe'; +import * as React from 'react'; +import {DialogEditor} from '../dialog-editor'; + +describe('', () => { + it('renders its children', () => { + render(children); + expect(screen.getByText('children')).toBeInTheDocument(); + }); + + it('is accessible', async () => { + const {container} = render(children); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/components/container/dialog-card/dialog-card.css b/src/components/container/dialog-card/dialog-card.css index d0381da2e..4d8c27bb8 100644 --- a/src/components/container/dialog-card/dialog-card.css +++ b/src/components/container/dialog-card/dialog-card.css @@ -25,6 +25,7 @@ display: block; font-weight: bold; overflow: hidden; + text-align: left; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; From 836de9608b11fbe745422ca6675c5ebf9a90f4aa Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Mon, 4 Oct 2021 19:59:17 -0400 Subject: [PATCH 08/42] Add ARIA dialog roles --- .../container/dialog-card/__tests__/dialog-card.test.tsx | 1 + src/components/container/dialog-card/dialog-card.tsx | 2 +- src/dialogs/story-stylesheet.tsx | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/container/dialog-card/__tests__/dialog-card.test.tsx b/src/components/container/dialog-card/__tests__/dialog-card.test.tsx index 8c1193568..1955d278f 100644 --- a/src/components/container/dialog-card/__tests__/dialog-card.test.tsx +++ b/src/components/container/dialog-card/__tests__/dialog-card.test.tsx @@ -8,6 +8,7 @@ describe('', () => { return render( = props => { }); return ( -
+

diff --git a/src/dialogs/story-stylesheet.tsx b/src/dialogs/story-stylesheet.tsx index d5348f7c4..c9682c6fa 100644 --- a/src/dialogs/story-stylesheet.tsx +++ b/src/dialogs/story-stylesheet.tsx @@ -33,7 +33,7 @@ export const StoryStylesheetDialog: React.FC = props return (

{t('dialogs.storyStylesheet.explanation')}

From 9033ca648d133fbdac3dcaefcf0d345590ec62a4 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Wed, 6 Oct 2021 21:13:36 -0400 Subject: [PATCH 09/42] Add container component tests --- .../container/__tests__/button-card.test.tsx | 17 +++++++++ .../container/__tests__/image-card-test.tsx | 30 ++++++++++++++++ .../container/__tests__/main-content.test.tsx | 35 +++++++++++++++++++ .../__tests__/button-bar-separator.test.tsx | 16 +++++++++ .../button-bar/__tests__/button-bar.test.tsx | 17 +++++++++ .../card/__tests__/card-content.test.tsx | 17 +++++++++ .../card/__tests__/card-header.test.tsx | 17 +++++++++ .../container/card/__tests__/card.test.tsx | 25 +++++++++++++ src/components/container/card/card-header.tsx | 6 +--- src/components/container/main-content.tsx | 3 +- .../top-bar/__tests__/top-bar.test.tsx | 17 +++++++++ 11 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 src/components/container/__tests__/button-card.test.tsx create mode 100644 src/components/container/__tests__/image-card-test.tsx create mode 100644 src/components/container/__tests__/main-content.test.tsx create mode 100644 src/components/container/button-bar/__tests__/button-bar-separator.test.tsx create mode 100644 src/components/container/button-bar/__tests__/button-bar.test.tsx create mode 100644 src/components/container/card/__tests__/card-content.test.tsx create mode 100644 src/components/container/card/__tests__/card-header.test.tsx create mode 100644 src/components/container/card/__tests__/card.test.tsx create mode 100644 src/components/container/top-bar/__tests__/top-bar.test.tsx diff --git a/src/components/container/__tests__/button-card.test.tsx b/src/components/container/__tests__/button-card.test.tsx new file mode 100644 index 000000000..c19ccb84d --- /dev/null +++ b/src/components/container/__tests__/button-card.test.tsx @@ -0,0 +1,17 @@ +import {render, screen} from '@testing-library/react'; +import {axe} from 'jest-axe'; +import * as React from 'react'; +import {ButtonCard} from '../button-card'; + +describe('', () => { + it('renders its children', () => { + render(children); + expect(screen.getByText('children')).toBeInTheDocument(); + }); + + it('is accessible', async () => { + const {container} = render(children); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/components/container/__tests__/image-card-test.tsx b/src/components/container/__tests__/image-card-test.tsx new file mode 100644 index 000000000..73b39a058 --- /dev/null +++ b/src/components/container/__tests__/image-card-test.tsx @@ -0,0 +1,30 @@ +import {render, screen} from '@testing-library/react'; +import {axe} from 'jest-axe'; +import * as React from 'react'; +import {ImageCard, ImageCardProps} from '../image-card'; + +describe('', () => { + function renderComponent(props?: ImageCardProps) { + return render( + } {...props}> +
+ + ); + } + + it('renders its children', () => { + renderComponent(); + expect(screen.getByTestId('mock-image-card-child')).toBeInTheDocument(); + }); + + it('renders its image', () => { + renderComponent(); + expect(screen.getByTestId('mock-image-card-image')).toBeInTheDocument(); + }); + + it('is accessible', async () => { + const {container} = renderComponent(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/components/container/__tests__/main-content.test.tsx b/src/components/container/__tests__/main-content.test.tsx new file mode 100644 index 000000000..dd9920234 --- /dev/null +++ b/src/components/container/__tests__/main-content.test.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import {axe} from 'jest-axe'; +import {render, screen, waitFor} from '@testing-library/react'; +import {MainContent, MainContentProps} from '../main-content'; + +describe('', () => { + function renderComponent(props?: MainContentProps) { + return render( + +
+ + ); + } + + it('renders its children', () => { + renderComponent(); + expect(screen.getByTestId('mock-main-content-child')).toBeInTheDocument(); + }); + + it('renders its title', () => { + renderComponent({title: 'mock-title'}); + expect(screen.getByText('mock-title')).toBeInTheDocument(); + }); + + it('sets the page title to its title prop', async () => { + renderComponent({title: 'mock-title'}); + await waitFor(() => expect(document.title).not.toBe('')); + expect(document.title).toBe('mock-title'); + }); + + it('is accessible', async () => { + const {container} = renderComponent(); + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/components/container/button-bar/__tests__/button-bar-separator.test.tsx b/src/components/container/button-bar/__tests__/button-bar-separator.test.tsx new file mode 100644 index 000000000..9f1c474dd --- /dev/null +++ b/src/components/container/button-bar/__tests__/button-bar-separator.test.tsx @@ -0,0 +1,16 @@ +import {render, screen} from '@testing-library/react'; +import {axe} from 'jest-axe'; +import * as React from 'react'; +import {ButtonBarSeparator} from '../button-bar-separator'; + +describe('', () => { + it('renders', () => { + expect(() => render()).not.toThrow(); + }); + + it('is accessible', async () => { + const {container} = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/components/container/button-bar/__tests__/button-bar.test.tsx b/src/components/container/button-bar/__tests__/button-bar.test.tsx new file mode 100644 index 000000000..10a090a04 --- /dev/null +++ b/src/components/container/button-bar/__tests__/button-bar.test.tsx @@ -0,0 +1,17 @@ +import {render, screen} from '@testing-library/react'; +import {axe} from 'jest-axe'; +import * as React from 'react'; +import {ButtonBar} from '../button-bar'; + +describe('', () => { + it('renders its children', () => { + render(children); + expect(screen.getByText('children')).toBeInTheDocument(); + }); + + it('is accessible', async () => { + const {container} = render(children); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/components/container/card/__tests__/card-content.test.tsx b/src/components/container/card/__tests__/card-content.test.tsx new file mode 100644 index 000000000..7b0332976 --- /dev/null +++ b/src/components/container/card/__tests__/card-content.test.tsx @@ -0,0 +1,17 @@ +import {render, screen} from '@testing-library/react'; +import {axe} from 'jest-axe'; +import * as React from 'react'; +import {CardContent} from '../card-content'; + +describe('', () => { + it('renders its children', () => { + render(children); + expect(screen.getByText('children')).toBeInTheDocument(); + }); + + it('is accessible', async () => { + const {container} = render(children); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/components/container/card/__tests__/card-header.test.tsx b/src/components/container/card/__tests__/card-header.test.tsx new file mode 100644 index 000000000..47cab4a79 --- /dev/null +++ b/src/components/container/card/__tests__/card-header.test.tsx @@ -0,0 +1,17 @@ +import {render, screen} from '@testing-library/react'; +import {axe} from 'jest-axe'; +import * as React from 'react'; +import {CardHeader} from '../card-header'; + +describe('', () => { + it('renders its children', () => { + render(children); + expect(screen.getByText('children')).toBeInTheDocument(); + }); + + it('is accessible', async () => { + const {container} = render(children); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/components/container/card/__tests__/card.test.tsx b/src/components/container/card/__tests__/card.test.tsx new file mode 100644 index 000000000..5ed1261c4 --- /dev/null +++ b/src/components/container/card/__tests__/card.test.tsx @@ -0,0 +1,25 @@ +import {render, screen} from '@testing-library/react'; +import {axe} from 'jest-axe'; +import * as React from 'react'; +import {Card, CardProps} from '../card'; + +describe('', () => { + function renderComponent(props?: Partial) { + return render( + +
+ + ); + } + + it('renders its children', () => { + renderComponent(); + expect(screen.getByTestId('mock-card-child')).toBeInTheDocument(); + }); + + it('is accessible', async () => { + const {container} = renderComponent(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/components/container/card/card-header.tsx b/src/components/container/card/card-header.tsx index 661c366eb..10896bb87 100644 --- a/src/components/container/card/card-header.tsx +++ b/src/components/container/card/card-header.tsx @@ -1,10 +1,6 @@ import * as React from 'react'; import './card-header.css'; -export interface CardHeaderProps { - level?: 1 | 2; -} - -export const CardHeader: React.FC = ({children}) => ( +export const CardHeader: React.FC = ({children}) => (
{children}
); diff --git a/src/components/container/main-content.tsx b/src/components/container/main-content.tsx index 7e92619bc..5f5532ff5 100644 --- a/src/components/container/main-content.tsx +++ b/src/components/container/main-content.tsx @@ -3,7 +3,8 @@ import classNames from 'classnames'; import {Helmet} from 'react-helmet'; import './main-content.css'; -interface MainContentProps extends React.ComponentPropsWithoutRef<'div'> { +export interface MainContentProps + extends React.ComponentPropsWithoutRef<'div'> { grabbable?: boolean; padded?: boolean; title?: string; diff --git a/src/components/container/top-bar/__tests__/top-bar.test.tsx b/src/components/container/top-bar/__tests__/top-bar.test.tsx new file mode 100644 index 000000000..6c1c0f5ed --- /dev/null +++ b/src/components/container/top-bar/__tests__/top-bar.test.tsx @@ -0,0 +1,17 @@ +import {render, screen} from '@testing-library/react'; +import {axe} from 'jest-axe'; +import * as React from 'react'; +import {TopBar} from '../top-bar'; + +describe('', () => { + it('renders its children', () => { + render(children); + expect(screen.getByText('children')).toBeInTheDocument(); + }); + + it('is accessible', async () => { + const {container} = render(children); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); From 9cad591460895b269d99ed07c8ae03fcd3a63259 Mon Sep 17 00:00:00 2001 From: Chris Klimas Date: Sat, 9 Oct 2021 16:42:57 -0400 Subject: [PATCH 10/42] Close passage edit dialog if passage is deleted --- .../control/code-area/__mocks__/code-area.tsx | 8 +++ .../__tests__/passage-edit.test.tsx | 49 +++++++++++++++++ src/dialogs/passage-edit/passage-edit.tsx | 18 ++++++- src/setupTests.ts | 13 +++++ src/test-util/index.ts | 2 + src/test-util/mock-state-context-provider.tsx | 52 +++++++++++++++++++ src/util/story-format/editor-extensions.ts | 3 -- 7 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 src/components/control/code-area/__mocks__/code-area.tsx create mode 100644 src/dialogs/passage-edit/__tests__/passage-edit.test.tsx create mode 100644 src/test-util/index.ts create mode 100644 src/test-util/mock-state-context-provider.tsx diff --git a/src/components/control/code-area/__mocks__/code-area.tsx b/src/components/control/code-area/__mocks__/code-area.tsx new file mode 100644 index 000000000..babad57f3 --- /dev/null +++ b/src/components/control/code-area/__mocks__/code-area.tsx @@ -0,0 +1,8 @@ +import * as React from 'react'; +import {CodeAreaProps} from '../code-area'; + +export const CodeArea: React.FC = props => ( +