Repo restructure.
This commit is contained in:
1
packages/declaform/README.md
Normal file
1
packages/declaform/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# Declaform
|
||||
14
packages/declaform/package.json
Normal file
14
packages/declaform/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "declaform",
|
||||
"version": "1.0.0",
|
||||
"description": "Web component which maps a form to a JSON object",
|
||||
"main": "index.js",
|
||||
"directories": {
|
||||
"test": "tests"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "npx jest"
|
||||
},
|
||||
"author": "Austin Smith",
|
||||
"license": "ISC"
|
||||
}
|
||||
226
packages/declaform/src/index.ts
Normal file
226
packages/declaform/src/index.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
interface FieldElement {
|
||||
name: string;
|
||||
type: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
function isFieldElement(x: unknown): x is FieldElement {
|
||||
if (typeof x !== 'object' || !x) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!('name' in x) || !('type' in x) || !('value' in x)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the object that ties a group of inputs to a name.
|
||||
* It assumes that every input in the group is of the same type.
|
||||
*/
|
||||
class Field {
|
||||
|
||||
private _type: string;
|
||||
|
||||
constructor(private _name: string, private _inputs: FieldElement[]) {
|
||||
if (this._inputs.length === 0) {
|
||||
throw new Error("[Declaform] Error: cannot construct Field with empty inputs array.")
|
||||
}
|
||||
this._type = this._inputs[0]!.type;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this._name;
|
||||
}
|
||||
|
||||
get value(): string | string[] | number | number[] | undefined {
|
||||
switch(this._type) {
|
||||
case 'checkbox':
|
||||
return (this._inputs as HTMLInputElement[]).filter(x => x.checked).map(x => x.value);
|
||||
case 'radio':
|
||||
return (this._inputs as HTMLInputElement[]).find(x => x.checked)?.value;
|
||||
case 'number':
|
||||
return (this._inputs.length > 1) ? this._inputs.map(x => Number(x.value)) : Number(this._inputs[0]!.value);
|
||||
default:
|
||||
case 'date':
|
||||
case 'datatime-local':
|
||||
case 'email':
|
||||
case 'password':
|
||||
case 'select':
|
||||
case 'text':
|
||||
// can assert this._inputs[0]! because we throw Error in constructor if _inputs.length = 0
|
||||
return (this._inputs.length > 1) ? this._inputs.map(x => x.value) : this._inputs[0]!.value;
|
||||
}
|
||||
}
|
||||
|
||||
set value(val: string) {
|
||||
switch(this._type) {
|
||||
case 'radio':
|
||||
case 'checkbox':
|
||||
(this._inputs as HTMLInputElement[])
|
||||
.forEach(input => {
|
||||
if (input.value === val) {
|
||||
input.setAttribute('checked', '');
|
||||
} else {
|
||||
input.removeAttribute('checked');
|
||||
}
|
||||
});
|
||||
break;
|
||||
default:
|
||||
case 'date':
|
||||
case 'datatime-local':
|
||||
case 'email':
|
||||
case 'number':
|
||||
case 'password':
|
||||
case 'select':
|
||||
case 'text':
|
||||
this._inputs.forEach(x => x.value = val);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
get type() {
|
||||
return this._type;
|
||||
}
|
||||
}
|
||||
|
||||
export class Declaform extends HTMLElement {
|
||||
|
||||
static observedAttributes = [
|
||||
'customInputs', // Lets user specify additional inputs to be considered "fields" by the form. Example: <object-list>
|
||||
'src', // defines api endpoint used to hydrate this object
|
||||
'action', // defines api endpoint used to submit this object to
|
||||
'method', // defines http method to use on "action" endpoint
|
||||
]
|
||||
|
||||
/* The character that gets used to join multiple field values */
|
||||
static joining_char = ',';
|
||||
|
||||
private _fields: Field[];
|
||||
private _form: HTMLFormElement | null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.attachInternals();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.shadowRoot!.innerHTML = '<slot name="form"></slot>';
|
||||
this._fields = [];
|
||||
this._form = null;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
const formSlot = this.shadowRoot?.querySelector('slot[name="form"]') as HTMLSlotElement;
|
||||
if (!formSlot) {
|
||||
throw new Error('[Declaform] Error: "form" slot is unassigned.')
|
||||
}
|
||||
if (formSlot.assignedElements()[0]?.tagName !== 'FORM') {
|
||||
throw new Error('[Declaform] Error: "form" slot must be assigned to a <form>.')
|
||||
}
|
||||
this._form = formSlot.assignedElements()[0] as HTMLFormElement;
|
||||
|
||||
const customInputs = this.getAttribute('customInputs')?.split(',').map(x => x.trim()) ?? [];
|
||||
const selectors = ['input', 'select'].concat(customInputs).map(x => `${x}[name]`).join(',');
|
||||
const inputs = Array.from(this.querySelectorAll(selectors));
|
||||
|
||||
// group inputs by name
|
||||
const inputGroups = new Map<string, FieldElement[]>();
|
||||
for (const input of inputs) {
|
||||
if (!isFieldElement(input)) {
|
||||
throw new Error(`[Declaform] Error: input does not have all fields: ["name", "type", "value"].`);
|
||||
}
|
||||
const name = input.getAttribute('name');
|
||||
if (name) {
|
||||
const group = inputGroups.get(name);
|
||||
if (group) {
|
||||
inputGroups.set(name, group.concat(input))
|
||||
} else {
|
||||
inputGroups.set(name, [input]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, inputs] of inputGroups.entries()) {
|
||||
this._fields.push(new Field(name, inputs))
|
||||
}
|
||||
|
||||
const src = this.getAttribute('src');
|
||||
if (src) {
|
||||
this.hydrateFromEndpoint(src);
|
||||
}
|
||||
|
||||
this._form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
await this.submit();
|
||||
})
|
||||
}
|
||||
|
||||
get form() {
|
||||
return this._form;
|
||||
}
|
||||
|
||||
get fields() {
|
||||
return this._fields;
|
||||
}
|
||||
|
||||
toObject() {
|
||||
const setProperty = (obj: Record<string, any>, name: string, value: any) => {
|
||||
const [current, children] = name.split('.');
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
if (!children) {
|
||||
obj[current] = value;
|
||||
return;
|
||||
}
|
||||
const iter_obj = obj[current] ?? {};
|
||||
setProperty(iter_obj, children, value);
|
||||
obj[current] = iter_obj;
|
||||
};
|
||||
let obj = {};
|
||||
for (const field of this._fields) {
|
||||
setProperty(obj, field.name, field.value)
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
hydrate(obj: Record<string, any>) {
|
||||
const populateFields = (current: Record<string, any>, path: string) => {
|
||||
for (const key of Object.keys(current)) {
|
||||
const value = current[key];
|
||||
const propPath = (path.length) ? `${path}.${key}` : key;
|
||||
if (typeof value !== 'object') {
|
||||
const fields = this.fields.filter(x => x.name === propPath);
|
||||
fields.forEach(x => x.value = value);
|
||||
} else {
|
||||
populateFields(current[key], propPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
populateFields(obj, '');
|
||||
}
|
||||
|
||||
private async hydrateFromEndpoint(endpoint: string): Promise<void> {
|
||||
const response = await fetch(endpoint);
|
||||
const obj = await response.json();
|
||||
this.hydrate(obj);
|
||||
}
|
||||
|
||||
private async submit(): Promise<void> {
|
||||
const method = this.getAttribute('method') ?? 'POST';
|
||||
const endpoint = new URL(this.getAttribute('action') ?? '');
|
||||
const obj = this.toObject();
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
body: JSON.stringify(obj),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('decla-form', Declaform);
|
||||
172
packages/declaform/tests/declaform.test.ts
Normal file
172
packages/declaform/tests/declaform.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { Declaform } from '../src';
|
||||
|
||||
const MOVIE_FORM_NO_SRC = `
|
||||
<decla-form id="form">
|
||||
<form slot="form">
|
||||
<div class="input-group">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" />
|
||||
</div>
|
||||
<select id="genre" name="genre">
|
||||
<option value="comedy">Comedy</option>
|
||||
<option value="horror">Horror</option>
|
||||
</select>
|
||||
<fieldset>
|
||||
<legend>Rating</legend>
|
||||
<div class="input-group">
|
||||
<label for="rating-type-user">Type</label>
|
||||
<input id="rating-type-user" name="rating.type" type="radio" value="user" />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="rating-type-critic">Type</label>
|
||||
<input id="rating-type-critic" name="rating.type" type="radio" value="critic" />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="rating-score">Score</label>
|
||||
<input id="rating-score" name="rating.score" type="number" />
|
||||
</div>
|
||||
</fieldset>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</decla-form>
|
||||
`;
|
||||
|
||||
const MOVIE_FORM_HTTP = `
|
||||
<decla-form id="form" src="movie/jaws" action="movie/jaws" method="PUT">
|
||||
<form slot="form">
|
||||
<div class="input-group">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" />
|
||||
</div>
|
||||
<select id="genre" name="genre">
|
||||
<option value="comedy">Comedy</option>
|
||||
<option value="horror">Horror</option>
|
||||
</select>
|
||||
<fieldset>
|
||||
<legend>Rating</legend>
|
||||
<div class="input-group">
|
||||
<label for="rating-type-user">Type</label>
|
||||
<input id="rating-type-user" name="rating.type" type="radio" value="user" />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="rating-type-critic">Type</label>
|
||||
<input id="rating-type-critic" name="rating.type" type="radio" value="critic" />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="rating-score">Score</label>
|
||||
<input id="rating-score" name="rating.score" type="number" />
|
||||
</div>
|
||||
</fieldset>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</decla-form>
|
||||
`;
|
||||
|
||||
describe('Declaform', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
})
|
||||
|
||||
it('Should mount.', () => {
|
||||
document.body.innerHTML = MOVIE_FORM_NO_SRC;
|
||||
expect(document.getElementById('form')).toBeTruthy();
|
||||
})
|
||||
|
||||
it('.toObject() should serialize form into a JS object', () => {
|
||||
document.body.innerHTML = MOVIE_FORM_NO_SRC;
|
||||
const form = document.getElementById('form') as Declaform;
|
||||
setInputValue('name', 'Monty Python');
|
||||
setInputValue('genre', 'comedy');
|
||||
setRadioValue('rating.type', 'user');
|
||||
setInputValue('rating-score', '5');
|
||||
const obj = form.toObject();
|
||||
expect(obj).toMatchObject({
|
||||
name: 'Monty Python',
|
||||
genre: 'comedy',
|
||||
rating: {
|
||||
score: 5,
|
||||
type: 'user',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('.hydrate() should populate input fields from a JS object', () => {
|
||||
document.body.innerHTML = MOVIE_FORM_NO_SRC;
|
||||
const form = document.getElementById('form') as Declaform;
|
||||
form.hydrate({
|
||||
name: 'Jaws',
|
||||
genre: 'horror',
|
||||
rating: {
|
||||
score: 5,
|
||||
type: 'critic',
|
||||
}
|
||||
});
|
||||
expect(getInputValue('name')).toBe('Jaws')
|
||||
expect(getInputValue('genre')).toBe('horror')
|
||||
expect(getInputValue('rating-score')).toBe('5');
|
||||
expect(document.getElementById('rating-type-critic')?.hasAttribute('checked')).toBe(true)
|
||||
});
|
||||
|
||||
it('Supplying "src" attribute should hydrate form with the supplied endpoint.', async () => {
|
||||
globalThis.fetch = jest.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
name: 'Jaws',
|
||||
genre: 'horror',
|
||||
rating: {
|
||||
score: 5,
|
||||
type: 'critic',
|
||||
}
|
||||
})
|
||||
})
|
||||
document.body.innerHTML = MOVIE_FORM_HTTP;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(getInputValue('name')).toBe('Jaws')
|
||||
expect(getInputValue('genre')).toBe('horror')
|
||||
expect(getInputValue('rating-score')).toBe('5');
|
||||
expect(document.getElementById('rating-type-critic')?.hasAttribute('checked')).toBe(true)
|
||||
});
|
||||
});
|
||||
|
||||
function setInputValue(id: string, value: string) {
|
||||
const input = document.getElementById(id);
|
||||
if (!input) {
|
||||
throw new Error(`No input with id "${id}"!`);
|
||||
}
|
||||
if (!('value' in input)) {
|
||||
throw new Error(`Tag with id "${id}" does not have "value" attribute!`);
|
||||
}
|
||||
input.value = value;
|
||||
}
|
||||
|
||||
function getInputValue(id: string) {
|
||||
const input = document.getElementById(id);
|
||||
if (!input) {
|
||||
throw new Error(`No input with id "${id}"!`);
|
||||
}
|
||||
if (!('value' in input)) {
|
||||
throw new Error(`Tag with id "${id}" does not have "value" attribute!`);
|
||||
}
|
||||
return input.value;
|
||||
}
|
||||
|
||||
function setRadioValue(name: string, value: string) {
|
||||
const radios = document.querySelectorAll(`[name="${name}"]`);
|
||||
if (!radios) {
|
||||
throw new Error(`No radios with name "${name}"!`);
|
||||
}
|
||||
radios.forEach(radio => {
|
||||
if (!('value' in radio)) {
|
||||
throw new Error('Radio option does not have "value" attribute!');
|
||||
}
|
||||
radio.removeAttribute('checked');
|
||||
})
|
||||
const option = ([...radios] as HTMLInputElement[]).find(x => x.value);
|
||||
if (!option) {
|
||||
throw new Error(`No option with value "${value}"`);
|
||||
}
|
||||
option.setAttribute('checked', '');
|
||||
}
|
||||
9
packages/declaform/tsconfig.json
Normal file
9
packages/declaform/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./",
|
||||
"outDir": "../../dist/declaform",
|
||||
"types": ["jest"]
|
||||
},
|
||||
"include": ["src", "tests"]
|
||||
}
|
||||
Reference in New Issue
Block a user