@@ -203,7 +203,7 @@ const createNote = (params: NoteCollectionParams): NextApiHandler => async (req, | |||
id: newId, | |||
title, | |||
content, | |||
image: `data:${image.type};base64,${image.toString('base64')}`, | |||
image: image ? `data:${image.type};base64,${image.toString('base64')}` : null, | |||
})}\n`, | |||
{ | |||
flag: 'a', | |||
@@ -225,7 +225,7 @@ const createNote = (params: NoteCollectionParams): NextApiHandler => async (req, | |||
id: newId, | |||
title, | |||
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; | |||
content: string; | |||
image: string; | |||
} | |||
}, | |||
noscript?: boolean, | |||
} | |||
const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | |||
req, | |||
res, | |||
note, | |||
noscript, | |||
}) => { | |||
const router = useRouter(); | |||
const body = (res.body ?? note ?? {}) as Record<string, unknown>; | |||
@@ -33,7 +35,11 @@ const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | |||
const [responseData, setResponseData] = React.useState<unknown>(); | |||
React.useEffect(() => { | |||
// 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) => { | |||
setResponseData(responseData); | |||
}); | |||
@@ -52,6 +58,7 @@ const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | |||
clientMethod="put" | |||
refresh={defaultRefresh} | |||
aria-label="Edit Existing Note" | |||
disableFetch={noscript} | |||
> | |||
<fieldset | |||
className="contents" | |||
@@ -72,10 +79,16 @@ const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | |||
/> | |||
</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> | |||
<span className="after:block">Image</span> | |||
<input type="file" name="image" /> | |||
@@ -91,10 +104,10 @@ const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | |||
</div> | |||
<div className="flex justify-end gap-4"> | |||
<div> | |||
<ActionButton type="submit" className="bg-white text-black">Update</ActionButton> | |||
<ActionButton type="submit" form="delete-note-form">Delete</ActionButton> | |||
</div> | |||
<div> | |||
<ActionButton type="submit" form="delete-note-form">Delete</ActionButton> | |||
<ActionButton type="submit" className="bg-white text-black">Update</ActionButton> | |||
</div> | |||
</div> | |||
</div> | |||
@@ -112,6 +125,8 @@ const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | |||
await router.push('/notes'); | |||
}} | |||
id="delete-note-form" | |||
aria-label="Delete Note" | |||
disableFetch={noscript} | |||
/> | |||
</div> | |||
</div> | |||
@@ -121,7 +136,7 @@ const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | |||
// TODO type safety | |||
export const getServerSideProps = Iceform.destination.getServerSideProps({ | |||
fn: async (actionReq, actionRes, ctx) => { | |||
const {noteId} = ctx.query; | |||
const {noteId, noscript} = ctx.query; | |||
let origin: string; | |||
if (ctx.req.headers.referer) { | |||
const refererUrl = new URL(ctx.req.headers.referer as string); | |||
@@ -142,6 +157,7 @@ export const getServerSideProps = Iceform.destination.getServerSideProps({ | |||
return { | |||
props: { | |||
note, | |||
noscript: noscript === 'true', | |||
}, | |||
}; | |||
} | |||
@@ -13,10 +13,12 @@ export interface NotesPageProps { | |||
content: string; | |||
image: string; | |||
}[]; | |||
noscript?: boolean; | |||
} | |||
const NotesPage: NextPage<NotesPageProps> = ({ | |||
notes, | |||
noscript, | |||
}) => { | |||
const router = useRouter(); | |||
@@ -25,7 +27,7 @@ const NotesPage: NextPage<NotesPageProps> = ({ | |||
<div className="px-8 max-w-screen-sm mx-auto"> | |||
<Iceform.Form | |||
method="post" | |||
action="/a/notes" | |||
action={noscript ? '/a/notes?noscript=true' : '/a/notes'} | |||
clientAction="/api/notes" | |||
refresh={async (response) => { | |||
if (response.status !== 201) { | |||
@@ -36,6 +38,7 @@ const NotesPage: NextPage<NotesPageProps> = ({ | |||
}} | |||
aria-label="Create New Note" | |||
className="contents" | |||
disableFetch={noscript} | |||
> | |||
<fieldset | |||
className="contents" | |||
@@ -100,6 +103,7 @@ const NotesPage: NextPage<NotesPageProps> = ({ | |||
refresh={async () => { | |||
await router.push('/notes'); | |||
}} | |||
disableFetch={noscript} | |||
> | |||
<div> | |||
<ActionButton type="submit"> | |||
@@ -156,6 +160,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { | |||
return { | |||
props: { | |||
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/testing-library__jest-dom": "^5.14.9", | |||
"@vitest/coverage-v8": "^0.33.0", | |||
"cypress": "^13.3.0", | |||
"eslint": "^8.35.0", | |||
"eslint-config-lxsmnsyc": "^0.5.0", | |||
"express": "^4.18.2", | |||
@@ -50,7 +51,9 @@ | |||
"watch": "pridepack watch", | |||
"start": "pridepack start", | |||
"dev": "pridepack dev", | |||
"test": "vitest" | |||
"test:jsdom": "vitest", | |||
"test:dom": "cypress run", | |||
"cypress:config": "cypress open" | |||
}, | |||
"private": false, | |||
"description": "Simple isomorphic forms for Next.", | |||
@@ -70,8 +70,8 @@ export const Form = React.forwardRef<FormDerivedElement, FormProps>(({ | |||
React.useEffect(() => { | |||
// hide server override in client | |||
setServerMethodOverride(false); | |||
}, []); | |||
setServerMethodOverride(disableFetch); | |||
}, [disableFetch]); | |||
// TODO csrf token | |||
@@ -125,7 +125,13 @@ export const getServerSideProps = (options: ActionWrapperOptions): GetServerSide | |||
: referer; | |||
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 { | |||
redirect: { | |||
@@ -41,21 +41,4 @@ describe('Form', () => { | |||
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(); | |||
}); | |||
}); |