Browse Source

Refactor code

Reorganize exports.
master
TheoryOfNekomata 1 year ago
parent
commit
1092e79baf
21 changed files with 381 additions and 35 deletions
  1. +5
    -3
      README.md
  2. +10
    -4
      packages/iceform-next-sandbox/src/handlers/greet.ts
  3. +266
    -0
      packages/iceform-next-sandbox/src/handlers/note.ts
  4. +5
    -2
      packages/iceform-next-sandbox/src/pages/a/greet.ts
  5. +14
    -0
      packages/iceform-next-sandbox/src/pages/a/notes/[noteId].ts
  6. +16
    -0
      packages/iceform-next-sandbox/src/pages/a/notes/index.ts
  7. +5
    -2
      packages/iceform-next-sandbox/src/pages/api/greet.ts
  8. +13
    -0
      packages/iceform-next-sandbox/src/pages/api/notes/[noteId].ts
  9. +15
    -0
      packages/iceform-next-sandbox/src/pages/api/notes/index.ts
  10. +1
    -0
      packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx
  11. +1
    -0
      packages/iceform-next-sandbox/src/pages/notes/index.tsx
  12. +6
    -6
      packages/iceform-next/package.json
  13. +1
    -1
      packages/iceform-next/src/client/common.ts
  14. +1
    -1
      packages/iceform-next/src/client/components/Form.tsx
  15. +1
    -1
      packages/iceform-next/src/client/hooks/useFormFetch.ts
  16. +7
    -2
      packages/iceform-next/src/client/hooks/useResponse.ts
  17. +0
    -10
      packages/iceform-next/src/common/enctypes.ts
  18. +9
    -0
      packages/iceform-next/src/common/types.ts
  19. +1
    -1
      packages/iceform-next/src/index.ts
  20. +3
    -1
      packages/iceform-next/src/server.ts
  21. +1
    -1
      packages/iceform-next/src/utils/serialization.ts

+ 5
- 3
README.md View File

@@ -8,13 +8,13 @@ Powered by [formxtra](https://code.modal.sh/TheoryOfNekomata/formxtra).

* Remove dependence from client-side JavaScript (graceful degradataion)
* Allow scriptability of forms
* Improved accessibility
* Improve accessibility

## Features

* Emulate client-side behavior in a purely server-side manner
* HTTP compliant (respects status code semantics)
* Easy setup (adapts to how receiving requests are made in the framework of choice)
* Observe HTTP compliance (respects status code semantics)
* Allow easy setup (adapts to how receiving requests are made in the framework of choice)

## Usage

@@ -124,8 +124,10 @@ In theory, any API route may have a corresponding action route.
## TODO

- [X] Content negotiation (custom request data)
- [ ] `<form method="dialog">` (on hold, see https://github.com/whatwg/html/issues/9625)
- [ ] Tests
- [ ] Form with redirects
- [ ] Form with files
- [ ] Documentation
- [ ] Remix support
- [ ] `accept-charset=""` attribute support

+ 10
- 4
packages/iceform-next-sandbox/src/handlers/greet.ts View File

@@ -1,8 +1,14 @@
import {NextApiHandler} from 'next';
import { NextApiHandler } from 'next';

export const greet: NextApiHandler = async (req, res) => {
const { name } = req.body;
res.status(202).json({
name: `Hello ${name}`,
});

if (req.method?.toLowerCase() === 'post') {
res.status(200).json({
name: `Hello ${name}`,
});
return;
}

res.status(405).send('Method Not Allowed');
};

+ 266
- 0
packages/iceform-next-sandbox/src/handlers/note.ts View File

@@ -0,0 +1,266 @@
import { NextApiHandler } from 'next';
import * as crypto from 'crypto';
import * as fs from 'fs/promises';

const ensureDatabaseDirectory = async () => {
try {
await fs.mkdir('.db');
} catch (errRaw) {
const err = errRaw as NodeJS.ErrnoException;

if (err.code !== 'EEXIST') {
throw new Error('Failed to create .db directory');
}

// noop, .db directory already exists
}
};

const getSingleNote: NextApiHandler = async (req, res) => {
const { noteId } = req.query;

if (typeof noteId !== 'string') {
res.status(400).send('Bad Request');
return;
}

const dataRaw = await fs.readFile('.db/notes.jsonl', {
encoding: 'utf-8',
});

const data = dataRaw
.split('\n')
.filter((s) => s.trim().length > 0)
.map((line) => JSON.parse(line));

const note = data.find((note) => note.id === noteId);

if (typeof note === 'undefined') {
res.status(404).send('Not Found');
return;
}

res.status(200).json(note);
};

const patchNote: NextApiHandler = async (req, res) => {
const { noteId } = req.query;

if (typeof noteId !== 'string') {
res.status(400).send('Bad Request');
return;
}

const { title, content } = req.body;

const dataRaw = await fs.readFile('.db/notes.jsonl', {
encoding: 'utf-8',
});

const data = dataRaw
.split('\n')
.filter((s) => s.trim().length > 0)
.map((line) => JSON.parse(line));

const noteIndex = data.findIndex((note) => note.id === noteId);

if (noteIndex === -1) {
res.status(404).send('Not Found');
return;
}

const note = data[noteIndex];

const updatedNote = {
...note,
title: title ?? note.title,
content: content ?? note.content,
};

data[noteIndex] = updatedNote;

try {
await ensureDatabaseDirectory();
await fs.writeFile(
'.db/notes.jsonl',
data.map((note) => JSON.stringify(note)).join('\n'),
);
} catch (errRaw) {
const err = errRaw as Error;
res.status(500).send(err.message);
return;
}

res.status(200).json(updatedNote);
};

const emplaceNote: NextApiHandler = async (req, res) => {
const { noteId } = req.query;

if (typeof noteId !== 'string') {
res.status(400).send('Bad Request');
return;
}

const dataRaw = await fs.readFile('.db/notes.jsonl', {
encoding: 'utf-8',
});

const data = dataRaw
.split('\n')
.filter((s) => s.trim().length > 0)
.map((line) => JSON.parse(line));

const noteIndex = data.findIndex((note) => note.id === noteId);

if (noteIndex === -1) {
return createNote({
basePath: '/api/notes',
})(req, res);
}

return patchNote(req, res);
}

const deleteNote: NextApiHandler = async (req, res) => {
const { noteId } = req.query;

if (typeof noteId !== 'string') {
res.status(400).send('Bad Request');
return;
}

const dataRaw = await fs.readFile('.db/notes.jsonl', {
encoding: 'utf-8',
});

const data = dataRaw
.split('\n')
.filter((s) => s.trim().length > 0)
.map((line) => JSON.parse(line));

const noteIndex = data.findIndex((note) => note.id === noteId);

if (noteIndex === -1) {
res.status(404).send('Not Found');
return;
}

data.splice(noteIndex, 1);

try {
await ensureDatabaseDirectory();
await fs.writeFile(
'.db/notes.jsonl',
data.map((note) => JSON.stringify(note)).join('\n'),
);
} catch (errRaw) {
const err = errRaw as Error;
res.status(500).send(err.message);
return;
}

res.status(204).end();
}

export const noteResource: NextApiHandler = async (req, res) => {
switch (req.method?.toLowerCase()) {
case 'get':
return getSingleNote(req, res);
case 'patch':
return patchNote(req, res);
case 'put':
return emplaceNote(req, res);
case 'delete':
return deleteNote(req, res);
default:
break;
}

res.status(405).send('Method not allowed');
};

export interface NoteCollectionParams {
basePath: string;
}

const createNote = (params: NoteCollectionParams): NextApiHandler => async (req, res) => {
const { title, content } = req.body;

if (typeof title !== 'string' || typeof content !== 'string') {
res.status(400).send('Bad Request');
return;
}

const newId = crypto.randomUUID();

try {
await ensureDatabaseDirectory();
await fs.writeFile(
'.db/notes.jsonl',
`${JSON.stringify({
id: newId,
title,
content,
})}\n`,
{
flag: 'a',
},
);
} catch (errRaw) {
const err = errRaw as Error;
res.status(500).send(err.message);
return;
}

// how to genericize the location URL?
res.setHeader('Location', `${params.basePath}/${newId}`);

res.status(201).json({
id: newId,
title,
content,
});
};

const getMultipleNotes: NextApiHandler = async (req, res) => {
const { ids, q } = req.query;
const dataRaw = await fs.readFile('.db/notes.jsonl', {
encoding: 'utf-8',
});

const data = dataRaw
.split('\n')
.filter((s) => s.trim().length > 0)
.map((line) => JSON.parse(line));

let filteredData = data;

if (typeof ids !== 'undefined') {
const idsArray = Array.isArray(ids) ? ids : [ids];
filteredData = data.filter((note) => idsArray.includes(note.id));
}

if (typeof q === 'string') {
const qToLowerCase = q.toLowerCase();
filteredData = data.filter((note) => (
note.title.toLowerCase().includes(qToLowerCase)
|| note.content.toLowerCase().includes(qToLowerCase)
));
}

res.status(200).json(filteredData);
};

export const noteCollection = (params: NoteCollectionParams): NextApiHandler =>async (req, res) => {
switch (req.method?.toLowerCase()) {
case 'post':
return createNote(params)(req, res);
case 'get':
return getMultipleNotes(req, res);
default:
break;
}

res.status(405).send('Method not allowed');
};

+ 5
- 2
packages/iceform-next-sandbox/src/pages/a/greet.ts View File

@@ -4,6 +4,9 @@ import {greet} from '@/handlers/greet';

const ActionGreetPage: NextPage = () => null;

export default ActionGreetPage;
const getServerSideProps = Iceform.action.getServerSideProps({ fn: greet });

export const getServerSideProps = Iceform.action.getServerSideProps({ fn: greet });
export {
getServerSideProps,
ActionGreetPage as default,
};

+ 14
- 0
packages/iceform-next-sandbox/src/pages/a/notes/[noteId].ts View File

@@ -0,0 +1,14 @@
import {NextPage} from 'next';
import * as Iceform from '@modal-sh/iceform-next';
import {noteResource} from '@/handlers/note';

const ActionNotesIndexPage: NextPage = () => null;

const getServerSideProps = Iceform.action.getServerSideProps({
fn: noteResource,
});

export {
getServerSideProps,
ActionNotesIndexPage as default,
};

+ 16
- 0
packages/iceform-next-sandbox/src/pages/a/notes/index.ts View File

@@ -0,0 +1,16 @@
import {NextPage} from 'next';
import * as Iceform from '@modal-sh/iceform-next';
import {noteCollection} from '@/handlers/note';

const ActionNotesIndexPage: NextPage = () => null;

const getServerSideProps = Iceform.action.getServerSideProps({
fn: noteCollection({
basePath: '/api/notes'
}),
});

export {
getServerSideProps,
ActionNotesIndexPage as default,
};

+ 5
- 2
packages/iceform-next-sandbox/src/pages/api/greet.ts View File

@@ -3,6 +3,9 @@ import { greet } from '@/handlers/greet';

const handler = Iceform.action.wrapApiHandler({ fn: greet });

export const config = Iceform.action.getApiConfig();
const config = Iceform.action.getApiConfig();

export default handler;
export {
config,
handler as default,
};

+ 13
- 0
packages/iceform-next-sandbox/src/pages/api/notes/[noteId].ts View File

@@ -0,0 +1,13 @@
import * as Iceform from '@modal-sh/iceform-next';
import { noteResource } from '@/handlers/note';

const handler = Iceform.action.wrapApiHandler({
fn: noteResource,
});

const config = Iceform.action.getApiConfig();

export {
config,
handler as default,
};

+ 15
- 0
packages/iceform-next-sandbox/src/pages/api/notes/index.ts View File

@@ -0,0 +1,15 @@
import * as Iceform from '@modal-sh/iceform-next';
import { noteCollection } from '@/handlers/note';

const handler = Iceform.action.wrapApiHandler({
fn: noteCollection({
basePath: '/api/notes'
}),
});

const config = Iceform.action.getApiConfig();

export {
config,
handler as default,
};

+ 1
- 0
packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx View File

@@ -0,0 +1 @@
// TODO view note page

+ 1
- 0
packages/iceform-next-sandbox/src/pages/notes/index.tsx View File

@@ -0,0 +1 @@
// TODO view all notes/add notes page

+ 6
- 6
packages/iceform-next/package.json View File

@@ -13,16 +13,16 @@
"pridepack"
],
"devDependencies": {
"@types/testing-library__jest-dom": "^5.14.9",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3",
"@types/testing-library__jest-dom": "^5.14.9",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3",
"@types/busboy": "^1.5.1",
"@types/cookie": "^0.5.2",
"@types/express": "^4.17.17",
"@types/node": "^18.14.1",
"@types/react": "^18.0.27",
"@vitest/coverage-v8": "^0.33.0",
"@vitest/coverage-v8": "^0.33.0",
"eslint": "^8.35.0",
"eslint-config-lxsmnsyc": "^0.5.0",
"express": "^4.18.2",
@@ -34,7 +34,7 @@
"react-test-renderer": "^18.2.0",
"tslib": "^2.5.0",
"typescript": "^4.9.5",
"vitest": "^0.34.1"
"vitest": "^0.34.1"
},
"peerDependencies": {
"next": "13.4.19",


+ 1
- 1
packages/iceform-next/src/client/common.ts View File

@@ -1,5 +1,5 @@
import { NextPage as DefaultNextPage } from 'next/types';
import { NextApiRequest, NextApiResponse } from '../common';
import { NextApiRequest, NextApiResponse } from '../common/types';

export const FormDerivedElementComponent = 'form' as const;



+ 1
- 1
packages/iceform-next/src/client/components/Form.tsx View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import {
ENCTYPE_APPLICATION_JSON,
ENCTYPE_MULTIPART_FORM_DATA,
} from '../../common';
} from '../../common/enctypes';
import {
DEFAULT_ENCTYPE_SERIALIZERS,
EncTypeSerializerMap,


+ 1
- 1
packages/iceform-next/src/client/hooks/useFormFetch.ts View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import fetchPonyfill from 'fetch-ponyfill';
import { EncTypeSerializerMap, serializeBody, SerializerOptions } from '../../utils/serialization';
import { ENCTYPE_MULTIPART_FORM_DATA } from '../../common';
import { ENCTYPE_MULTIPART_FORM_DATA } from '../../common/enctypes';
import { AllowedClientMethod, FormDerivedElement } from '../common';

export interface UseFormFetchParams {


+ 7
- 2
packages/iceform-next/src/client/hooks/useResponse.ts View File

@@ -1,7 +1,12 @@
import * as React from 'react';
import { NextApiResponse } from '../../common';
import { NextApiResponse } from '../../common/types';

export const useResponse = (res: NextApiResponse) => {
export interface UseResponseParams {
res: NextApiResponse;
}

export const useResponse = (params: UseResponseParams) => {
const { res } = params;
const [response, setResponse] = React.useState<Response | undefined>(
res.body ? new Response(res.body as unknown as BodyInit) : undefined,
);


packages/iceform-next/src/common.ts → packages/iceform-next/src/common/enctypes.ts View File

@@ -1,13 +1,3 @@
import {
NextApiRequest as DefaultNextApiRequest,
} from 'next';

export type NextApiRequest = Pick<DefaultNextApiRequest, 'query' | 'body'>;

export interface NextApiResponse {
body?: unknown;
}

export const ENCTYPE_APPLICATION_JSON = 'application/json' as const;

export const ENCTYPE_APPLICATION_OCTET_STREAM = 'application/octet-stream' as const;

+ 9
- 0
packages/iceform-next/src/common/types.ts View File

@@ -0,0 +1,9 @@
import {
NextApiRequest as DefaultNextApiRequest,
} from 'next';

export type NextApiRequest = Pick<DefaultNextApiRequest, 'query' | 'body'>;

export interface NextApiResponse {
body?: unknown;
}

+ 1
- 1
packages/iceform-next/src/index.ts View File

@@ -1,3 +1,3 @@
export type * from './common';
export * from './common/types';
export * from './client';
export * from './server';

+ 3
- 1
packages/iceform-next/src/server.ts View File

@@ -9,9 +9,11 @@ import { deserialize, serialize } from 'seroval';
import {
NextApiResponse,
NextApiRequest,
} from './common/types';
import {
ENCTYPE_APPLICATION_JSON,
ENCTYPE_APPLICATION_OCTET_STREAM,
} from './common';
} from './common/enctypes';
import { getBody } from './utils/request';
import {
CookieManager,


+ 1
- 1
packages/iceform-next/src/utils/serialization.ts View File

@@ -4,7 +4,7 @@ import {
ENCTYPE_APPLICATION_JSON, ENCTYPE_APPLICATION_OCTET_STREAM,
ENCTYPE_MULTIPART_FORM_DATA,
ENCTYPE_X_WWW_FORM_URLENCODED,
} from '../common';
} from '../common/enctypes';
import { getBody, parseMultipartFormData } from './request';

export type EncTypeSerializer = (data: unknown) => string;


Loading…
Cancel
Save