@@ -203,7 +203,7 @@ const createNote = (params: NoteCollectionParams): NextApiHandler => async (req, | |||||
id: newId, | id: newId, | ||||
title, | title, | ||||
content, | content, | ||||
image: `data:${image.type};base64,${image.toString('base64')}`, | |||||
image: image ? `data:${image.type};base64,${image.toString('base64')}` : null, | |||||
})}\n`, | })}\n`, | ||||
{ | { | ||||
flag: 'a', | flag: 'a', | ||||
@@ -225,7 +225,7 @@ const createNote = (params: NoteCollectionParams): NextApiHandler => async (req, | |||||
id: newId, | id: newId, | ||||
title, | title, | ||||
content, | content, | ||||
image: `data:${image.type};base64,${image.toString('base64')}`, | |||||
image: image ? `data:${image.type};base64,${image.toString('base64')}` : null, | |||||
}); | }); | ||||
}; | }; | ||||
@@ -11,13 +11,15 @@ export interface NotesItemPageProps { | |||||
title: string; | title: string; | ||||
content: string; | content: string; | ||||
image: string; | image: string; | ||||
} | |||||
}, | |||||
noscript?: boolean, | |||||
} | } | ||||
const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | ||||
req, | req, | ||||
res, | res, | ||||
note, | note, | ||||
noscript, | |||||
}) => { | }) => { | ||||
const router = useRouter(); | const router = useRouter(); | ||||
const body = (res.body ?? note ?? {}) as Record<string, unknown>; | const body = (res.body ?? note ?? {}) as Record<string, unknown>; | ||||
@@ -33,7 +35,11 @@ const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | |||||
const [responseData, setResponseData] = React.useState<unknown>(); | const [responseData, setResponseData] = React.useState<unknown>(); | ||||
React.useEffect(() => { | React.useEffect(() => { | ||||
// response.bodyUsed might be undefined, so we use a strict comparison | // response.bodyUsed might be undefined, so we use a strict comparison | ||||
if (response?.bodyUsed === false && response.status !== 204) { | |||||
if ( | |||||
response?.bodyUsed === false | |||||
&& response.status !== 204 | |||||
&& response.headers.get('Content-Type')?.startsWith('application/json') | |||||
) { | |||||
response?.json().then((responseData) => { | response?.json().then((responseData) => { | ||||
setResponseData(responseData); | setResponseData(responseData); | ||||
}); | }); | ||||
@@ -52,6 +58,7 @@ const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | |||||
clientMethod="put" | clientMethod="put" | ||||
refresh={defaultRefresh} | refresh={defaultRefresh} | ||||
aria-label="Edit Existing Note" | aria-label="Edit Existing Note" | ||||
disableFetch={noscript} | |||||
> | > | ||||
<fieldset | <fieldset | ||||
className="contents" | className="contents" | ||||
@@ -72,10 +79,16 @@ const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | |||||
/> | /> | ||||
</div> | </div> | ||||
<div> | <div> | ||||
<img | |||||
src={body.image as string} | |||||
alt={body.title as string} | |||||
/> | |||||
{ | |||||
typeof body.image === 'string' | |||||
&& body.image.length > 0 | |||||
&& ( | |||||
<img | |||||
src={body.image} | |||||
alt={body.title as string} | |||||
/> | |||||
) | |||||
} | |||||
<label> | <label> | ||||
<span className="after:block">Image</span> | <span className="after:block">Image</span> | ||||
<input type="file" name="image" /> | <input type="file" name="image" /> | ||||
@@ -91,10 +104,10 @@ const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | |||||
</div> | </div> | ||||
<div className="flex justify-end gap-4"> | <div className="flex justify-end gap-4"> | ||||
<div> | <div> | ||||
<ActionButton type="submit" className="bg-white text-black">Update</ActionButton> | |||||
<ActionButton type="submit" form="delete-note-form">Delete</ActionButton> | |||||
</div> | </div> | ||||
<div> | <div> | ||||
<ActionButton type="submit" form="delete-note-form">Delete</ActionButton> | |||||
<ActionButton type="submit" className="bg-white text-black">Update</ActionButton> | |||||
</div> | </div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
@@ -112,6 +125,8 @@ const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | |||||
await router.push('/notes'); | await router.push('/notes'); | ||||
}} | }} | ||||
id="delete-note-form" | id="delete-note-form" | ||||
aria-label="Delete Note" | |||||
disableFetch={noscript} | |||||
/> | /> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
@@ -121,7 +136,7 @@ const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | |||||
// TODO type safety | // TODO type safety | ||||
export const getServerSideProps = Iceform.destination.getServerSideProps({ | export const getServerSideProps = Iceform.destination.getServerSideProps({ | ||||
fn: async (actionReq, actionRes, ctx) => { | fn: async (actionReq, actionRes, ctx) => { | ||||
const {noteId} = ctx.query; | |||||
const {noteId, noscript} = ctx.query; | |||||
let origin: string; | let origin: string; | ||||
if (ctx.req.headers.referer) { | if (ctx.req.headers.referer) { | ||||
const refererUrl = new URL(ctx.req.headers.referer as string); | const refererUrl = new URL(ctx.req.headers.referer as string); | ||||
@@ -142,6 +157,7 @@ export const getServerSideProps = Iceform.destination.getServerSideProps({ | |||||
return { | return { | ||||
props: { | props: { | ||||
note, | note, | ||||
noscript: noscript === 'true', | |||||
}, | }, | ||||
}; | }; | ||||
} | } | ||||
@@ -13,10 +13,12 @@ export interface NotesPageProps { | |||||
content: string; | content: string; | ||||
image: string; | image: string; | ||||
}[]; | }[]; | ||||
noscript?: boolean; | |||||
} | } | ||||
const NotesPage: NextPage<NotesPageProps> = ({ | const NotesPage: NextPage<NotesPageProps> = ({ | ||||
notes, | notes, | ||||
noscript, | |||||
}) => { | }) => { | ||||
const router = useRouter(); | const router = useRouter(); | ||||
@@ -25,7 +27,7 @@ const NotesPage: NextPage<NotesPageProps> = ({ | |||||
<div className="px-8 max-w-screen-sm mx-auto"> | <div className="px-8 max-w-screen-sm mx-auto"> | ||||
<Iceform.Form | <Iceform.Form | ||||
method="post" | method="post" | ||||
action="/a/notes" | |||||
action={noscript ? '/a/notes?noscript=true' : '/a/notes'} | |||||
clientAction="/api/notes" | clientAction="/api/notes" | ||||
refresh={async (response) => { | refresh={async (response) => { | ||||
if (response.status !== 201) { | if (response.status !== 201) { | ||||
@@ -36,6 +38,7 @@ const NotesPage: NextPage<NotesPageProps> = ({ | |||||
}} | }} | ||||
aria-label="Create New Note" | aria-label="Create New Note" | ||||
className="contents" | className="contents" | ||||
disableFetch={noscript} | |||||
> | > | ||||
<fieldset | <fieldset | ||||
className="contents" | className="contents" | ||||
@@ -100,6 +103,7 @@ const NotesPage: NextPage<NotesPageProps> = ({ | |||||
refresh={async () => { | refresh={async () => { | ||||
await router.push('/notes'); | await router.push('/notes'); | ||||
}} | }} | ||||
disableFetch={noscript} | |||||
> | > | ||||
<div> | <div> | ||||
<ActionButton type="submit"> | <ActionButton type="submit"> | ||||
@@ -156,6 +160,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { | |||||
return { | return { | ||||
props: { | props: { | ||||
notes, | notes, | ||||
noscript: ctx.query?.noscript === 'true', | |||||
}, | }, | ||||
}; | }; | ||||
} | } | ||||
@@ -0,0 +1,26 @@ | |||||
import { defineConfig } from "cypress"; | |||||
import { resolve } from 'path'; | |||||
import { mkdir, rm, writeFile } from 'fs/promises'; | |||||
export default defineConfig({ | |||||
e2e: { | |||||
setupNodeEvents(on, config) { | |||||
// implement node event listeners here | |||||
on('task', { | |||||
async resetDb() { | |||||
console.log(resolve('../iceform-next-sandbox/.db')); | |||||
await rm('../iceform-next-sandbox/.db', { force: true, recursive: true }); | |||||
await mkdir('../iceform-next-sandbox/.db'); | |||||
await writeFile('../iceform-next-sandbox/.db/notes.jsonl', ''); | |||||
return null; | |||||
} | |||||
}); | |||||
}, | |||||
}, | |||||
component: { | |||||
devServer: { | |||||
framework: 'next', | |||||
bundler: 'webpack', | |||||
}, | |||||
}, | |||||
}); |
@@ -0,0 +1,93 @@ | |||||
describe('form', () => { | |||||
beforeEach(() => { | |||||
cy.task('resetDb'); | |||||
}); | |||||
describe('script', () => { | |||||
it('submits form with complete data', () => { | |||||
cy.visit('http://localhost:3000/notes'); | |||||
cy.intercept('POST', '/api/notes').as('action'); | |||||
cy.get('form').should('exist').within(() => { | |||||
cy.get('input[name="title"]').type('test title'); | |||||
cy.get('input[name="image"]').selectFile('cypress/fixtures/file.jpg'); | |||||
cy.get('textarea[name="content"]').type('test content'); | |||||
cy.get('button[type="submit"]').click(); | |||||
cy.wait('@action').its('response.statusCode').should('eq', 201); | |||||
}); | |||||
cy.url().should('match', /\/notes\/[a-z0-9-]+$/); | |||||
}); | |||||
it('submits form with incomplete data', () => { | |||||
cy.visit('http://localhost:3000/notes'); | |||||
cy.intercept('POST', '/api/notes').as('action'); | |||||
cy.get('form').should('exist').within(() => { | |||||
cy.get('input[name="title"]').type('test title'); | |||||
cy.get('textarea[name="content"]').type('test content'); | |||||
cy.get('button[type="submit"]').click(); | |||||
cy.wait('@action').its('response.statusCode').should('eq', 201); | |||||
}); | |||||
cy.url().should('match', /\/notes\/[a-z0-9-]+$/); | |||||
}); | |||||
it('handles redirects', () => { | |||||
cy.visit('http://localhost:3000/notes'); | |||||
cy.intercept('DELETE', '/api/notes/*').as('action'); | |||||
cy.get('form').should('exist').within(() => { | |||||
cy.get('input[name="title"]').type('test title'); | |||||
cy.get('textarea[name="content"]').type('test content'); | |||||
cy.get('button[type="submit"]').click(); | |||||
}); | |||||
cy.get('button').contains('Delete').click(); | |||||
cy.wait('@action').its('response.statusCode').should('eq', 204); | |||||
cy.url().should('match', /\/notes$/); | |||||
}); | |||||
}); | |||||
describe('noscript', () => { | |||||
it('submits form with complete data', () => { | |||||
cy.visit('http://localhost:3000/notes?noscript=true'); | |||||
cy.intercept('POST', '/a/notes?noscript=true').as('action'); | |||||
cy.intercept('POST', '/notes/*').as('destination'); | |||||
cy.get('form').should('exist').within(() => { | |||||
cy.get('input[name="title"]').type('test title'); | |||||
cy.get('input[name="image"]').selectFile('cypress/fixtures/file.jpg'); | |||||
cy.get('textarea[name="content"]').type('test content'); | |||||
cy.get('button[type="submit"]').click(); | |||||
}); | |||||
cy.wait('@action').its('response.statusCode').should('eq', 307); | |||||
cy.wait('@destination').its('response.statusCode').should('eq', 201); | |||||
cy.url().should('match', /\/notes\/[a-z0-9-]+\?/); | |||||
}); | |||||
it('submits form with incomplete data', () => { | |||||
cy.visit('http://localhost:3000/notes?noscript=true'); | |||||
cy.intercept('POST', '/a/notes?noscript=true').as('action'); | |||||
cy.intercept('POST', '/notes/*').as('destination'); | |||||
cy.get('form').should('exist').within(() => { | |||||
cy.get('input[name="title"]').type('test title'); | |||||
cy.get('textarea[name="content"]').type('test content'); | |||||
cy.get('button[type="submit"]').click(); | |||||
}); | |||||
cy.wait('@action').its('response.statusCode').should('eq', 307); | |||||
cy.wait('@destination').its('response.statusCode').should('eq', 201); | |||||
cy.url().should('match', /\/notes\/[a-z0-9-]+\?/); | |||||
}); | |||||
it('handles redirects', () => { | |||||
cy.visit('http://localhost:3000/notes?noscript=true'); | |||||
cy.get('form').should('exist').within(() => { | |||||
cy.get('input[name="title"]').type('test title'); | |||||
cy.get('textarea[name="content"]').type('test content'); | |||||
cy.get('button[type="submit"]').click(); | |||||
}); | |||||
cy.intercept('POST', '/a/notes/*').as('action'); | |||||
cy.intercept('POST', '/notes').as('destination'); | |||||
cy.get('button').contains('Delete').click(); | |||||
cy.wait('@action').its('response.statusCode').should('eq', 307); | |||||
cy.wait('@destination').its('response.statusCode').should('eq', 200); | |||||
cy.url().should('match', /\/notes$/); | |||||
}); | |||||
}); | |||||
}); |
@@ -0,0 +1,37 @@ | |||||
/// <reference types="cypress" /> | |||||
// *********************************************** | |||||
// This example commands.ts shows you how to | |||||
// create various custom commands and overwrite | |||||
// existing commands. | |||||
// | |||||
// For more comprehensive examples of custom | |||||
// commands please read more here: | |||||
// https://on.cypress.io/custom-commands | |||||
// *********************************************** | |||||
// | |||||
// | |||||
// -- This is a parent command -- | |||||
// Cypress.Commands.add('login', (email, password) => { ... }) | |||||
// | |||||
// | |||||
// -- This is a child command -- | |||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) | |||||
// | |||||
// | |||||
// -- This is a dual command -- | |||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) | |||||
// | |||||
// | |||||
// -- This will overwrite an existing command -- | |||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) | |||||
// | |||||
// declare global { | |||||
// namespace Cypress { | |||||
// interface Chainable { | |||||
// login(email: string, password: string): Chainable<void> | |||||
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element> | |||||
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element> | |||||
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element> | |||||
// } | |||||
// } | |||||
// } |
@@ -0,0 +1,20 @@ | |||||
// *********************************************************** | |||||
// This example support/e2e.ts is processed and | |||||
// loaded automatically before your test files. | |||||
// | |||||
// This is a great place to put global configuration and | |||||
// behavior that modifies Cypress. | |||||
// | |||||
// You can change the location of this file or turn off | |||||
// automatically serving support files with the | |||||
// 'supportFile' configuration option. | |||||
// | |||||
// You can read more here: | |||||
// https://on.cypress.io/configuration | |||||
// *********************************************************** | |||||
// Import commands.js using ES2015 syntax: | |||||
import './commands' | |||||
// Alternatively you can use CommonJS syntax: | |||||
// require('./commands') |
@@ -23,6 +23,7 @@ | |||||
"@types/react": "^18.0.27", | "@types/react": "^18.0.27", | ||||
"@types/testing-library__jest-dom": "^5.14.9", | "@types/testing-library__jest-dom": "^5.14.9", | ||||
"@vitest/coverage-v8": "^0.33.0", | "@vitest/coverage-v8": "^0.33.0", | ||||
"cypress": "^13.3.0", | |||||
"eslint": "^8.35.0", | "eslint": "^8.35.0", | ||||
"eslint-config-lxsmnsyc": "^0.5.0", | "eslint-config-lxsmnsyc": "^0.5.0", | ||||
"express": "^4.18.2", | "express": "^4.18.2", | ||||
@@ -50,7 +51,9 @@ | |||||
"watch": "pridepack watch", | "watch": "pridepack watch", | ||||
"start": "pridepack start", | "start": "pridepack start", | ||||
"dev": "pridepack dev", | "dev": "pridepack dev", | ||||
"test": "vitest" | |||||
"test:jsdom": "vitest", | |||||
"test:dom": "cypress run", | |||||
"cypress:config": "cypress open" | |||||
}, | }, | ||||
"private": false, | "private": false, | ||||
"description": "Simple isomorphic forms for Next.", | "description": "Simple isomorphic forms for Next.", | ||||
@@ -70,8 +70,8 @@ export const Form = React.forwardRef<FormDerivedElement, FormProps>(({ | |||||
React.useEffect(() => { | React.useEffect(() => { | ||||
// hide server override in client | // hide server override in client | ||||
setServerMethodOverride(false); | |||||
}, []); | |||||
setServerMethodOverride(disableFetch); | |||||
}, [disableFetch]); | |||||
// TODO csrf token | // TODO csrf token | ||||
@@ -125,7 +125,13 @@ export const getServerSideProps = (options: ActionWrapperOptions): GetServerSide | |||||
: referer; | : referer; | ||||
const url = new URL(redirectDestinationRaw, 'http://example.com'); | const url = new URL(redirectDestinationRaw, 'http://example.com'); | ||||
url.searchParams.set(options.requestIdFormKey ?? REQUEST_ID_FORM_KEY, requestId); | |||||
const redirectSearchParams = Object.fromEntries(new URLSearchParams(url.search).entries()); | |||||
const searchParams = new URLSearchParams({ | |||||
...((ctx.query ?? {}) as Record<string, string>), | |||||
...redirectSearchParams, | |||||
}); | |||||
searchParams.set(options.requestIdFormKey ?? REQUEST_ID_FORM_KEY, requestId); | |||||
url.search = searchParams.toString(); | |||||
return { | return { | ||||
redirect: { | redirect: { | ||||
@@ -41,21 +41,4 @@ describe('Form', () => { | |||||
expect(onSubmit).toHaveBeenCalled(); | expect(onSubmit).toHaveBeenCalled(); | ||||
}); | }); | ||||
it.skip('calls the submit handler on submit with a custom component', async () => { | |||||
// TODO | |||||
const onSubmit = vi.fn(); | |||||
render( | |||||
<Form onSubmit={onSubmit} aria-label="Form"> | |||||
<button type="submit">Submit</button> | |||||
</Form>, | |||||
); | |||||
const button = screen.getByRole('button', { name: 'Submit' }); | |||||
await userEvent.click(button); | |||||
expect(onSubmit).toHaveBeenCalled(); | |||||
}); | |||||
}); | }); |