Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HAL-FORMS value manager and request encoder #40

Merged
merged 7 commits into from
Mar 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
/* Modules */
"module": "ES6", /* Specify what module code is generated. */
"rootDir": "./src", /* Specify the root folder within your source files. */
"moduleResolution": "node16", /* Specify how TypeScript looks up a file from a given module specifier. */
"moduleResolution": "Bundler", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
Expand Down
58 changes: 58 additions & 0 deletions packages/hal-forms/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,64 @@ template.properties.forEach(property => {

</details>

### Interactive form value management

When you have a form, you probably want to display it to your users in some way so they can enter values in your form fields.

The `@contentgrid/hal-forms/values` package is a simple utility to manage the form field values entered by users.

It uses the correct datatype belonging to each form field.

<details>

<summary>Code example for form value management using HAL-FORMS values</summary>

```typescript
import { createValues } from "@contentgrid/hal-forms/values"

const templateValues = createValues(template); // template is a HalFormsTemplate

templateValues.values.forEach(value => {
console.log(`Field ${value.property.name}: ${value.value}`)
})

templateValues = templateValues.withValue("name", "Jeff"); // Creates a new object with the value set

templateValues.values.forEach(value => {
console.log(`Field ${value.property.name}: ${value.value}`)
})
```

</details>

### Encoding HAL-FORMS into requests

After collecting user input, you probably want to send a request to the backend to submit the values entered in the form.

The `@contentgrid/hal-forms/codecs` sub-package encodes the values according to the content-type required by the HAL-FORMS template.

<details>

<summary>Code example for encoding form values with codecs</summary>

```typescript
import codecs from "@contentgrid/hal-forms/codecs";

const request = codecs.requireCodecFor(template) // template is a HalFormsTemplate
.encode(templateValues); // templateValues is a HalFormValues

// The HAL-FORMS template method, target, contentType and values are encoded in request

// Put some additional headers on your request here
request.headers.set('Authorization', 'Basic ....');

// Perform the HTTP request
const response = await fetch(request);
console.log(response);
```

</details>

### Shapes

The `@contentgrid/hal-forms/shape` sub-package provides POJO (plain old javascript object) types that can be used to represent the raw HAL-FORMS JSON data.
Expand Down
136 changes: 136 additions & 0 deletions packages/hal-forms/__tests__/codecs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, expect, test } from "@jest/globals";
import buildTemplate from "../src/builder";
import { default as codecs, Encoders, HalFormsCodecNotAvailableError, HalFormsCodecs } from "../src/codecs";
import { createValues } from "../src/values";

const form = buildTemplate("POST", "http://localhost/invoices")
.withContentType("application/json")
.addProperty("created_at", b => b
.withType("datetime")
.withRequired(true)
)
.addProperty("total.net", b => b
.withType("number")
)
.addProperty("total.vat", b => b
.withType("number")
)
.addProperty("senders", b => b.withType("url")
.withOptions(b => b.withMinItems(0))
)
.addProperty("name", b => b.withValue("Jefke"));

describe("HalFormsCodecs", () => {

describe("#findCodecFor()", () => {
test("finds a codec", () => {
const c = codecs.findCodecFor(form);
expect(c).not.toBeNull();
})

test("does not find a codec", () => {
const empty = HalFormsCodecs.builder()
.registerEncoder("example/json", Encoders.json())
.build();
const c = empty.findCodecFor(form);
expect(c).toBeNull();
})
});

describe("#requireCodecFor()", () => {

test("requires a codec", () => {
const c = codecs.requireCodecFor(form);
expect(c).not.toBeNull();
})

test("does not find a required codec", () => {
const empty = HalFormsCodecs.builder().build();
expect(() => empty.requireCodecFor(form))
.toThrowError(new HalFormsCodecNotAvailableError(form));
})

})
})

describe("Encoders.json()", () => {
const values = createValues(form);
const codecs = HalFormsCodecs.builder()
.registerEncoder("application/json", Encoders.json())
.build();

test("Encodes default values", () => {
const encoded = codecs.requireCodecFor(form).encode(values.values);

expect(encoded).toBeInstanceOf(Request);

expect(encoded.json())
.resolves
.toEqual({
"name": "Jefke"
});
})

test("Encodes nested values as flat JSON", () => {

const encoded = codecs.requireCodecFor(form).encode(
values
.withValue("total.net", 123)
.withValue("total.vat", 456)
.values
);

expect(encoded).toBeInstanceOf(Request);

expect(encoded.json())
.resolves
.toEqual({
"name": "Jefke",
"total.net": 123,
"total.vat": 456
})
});

})

describe("Encoders.nestedJson()", () => {
const values = createValues(form);
const codecs = HalFormsCodecs.builder()
.registerEncoder("application/json", Encoders.nestedJson())
.build();

test("Encodes default values", () => {
const encoded = codecs.requireCodecFor(form).encode(values.values);

expect(encoded).toBeInstanceOf(Request);

expect(encoded.json())
.resolves
.toEqual({
"name": "Jefke"
});
})

test("Encodes nested values as flat JSON", () => {

const encoded = codecs.requireCodecFor(form).encode(
values
.withValue("total.net", 123)
.withValue("total.vat", 456)
.values
);

expect(encoded).toBeInstanceOf(Request);

expect(encoded.json())
.resolves
.toEqual({
"name": "Jefke",
"total": {
"net": 123,
"vat": 456
}
})
});

})
18 changes: 12 additions & 6 deletions packages/hal-forms/__tests__/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe("resolveTemplate", () => {
_templates: {
["create-form"]: {
method: "GET",
title: "Create new",
target: "http://localhost/create",
properties: [
{
Expand Down Expand Up @@ -56,6 +57,7 @@ describe("resolveTemplate", () => {
},
other: {
method: "POST",
contentType: "application/x-www-form-urlencoded",
properties: [
{
name: "ZZZ",
Expand All @@ -73,13 +75,15 @@ describe("resolveTemplate", () => {
method: "GET",
url: "http://localhost/create"
})
expect(template?.contentType).toEqual("application/json");
expect(template?.title).toEqual("Create new");
expect(template?.properties.length).toEqual(3);
const propAbc = template!.property("abc");
expect(template?.property("abc").readOnly).toBe(false);
expect(template?.property("abc").type).toEqual("text");
const abcOpts = propAbc.options;
expect(abcOpts.isInline()).toBe(true);
expect(abcOpts.loadOptions(() => { throw new Error("Not implemented") }))
expect(abcOpts!.isInline()).toBe(true);
expect(abcOpts!.loadOptions(() => { throw new Error("Not implemented") }))
.resolves
.toEqual([
{
Expand All @@ -92,18 +96,18 @@ describe("resolveTemplate", () => {
}
])

if(abcOpts.isInline()) {
if(abcOpts!.isInline()) {
expect(abcOpts.inline.length).toBe(2);
}

expect(template?.property("def").readOnly).toBe(false);
expect(template?.property("def").type).toBe("number");
const defOpts = template!.property("def").options;
expect(defOpts.isRemote()).toBe(true);
expect(defOpts!.isRemote()).toBe(true);

const mockLoad = jest.fn(() => Promise.resolve(["1", "2", "3"]));

expect(defOpts.loadOptions(mockLoad))
expect(defOpts!.loadOptions(mockLoad))
.resolves
.toEqual([
{
Expand All @@ -120,7 +124,7 @@ describe("resolveTemplate", () => {
}
]);

if(abcOpts.isRemote()) {
if(abcOpts!.isRemote()) {
expect(mockLoad.mock.lastCall).toHaveBeenCalledWith(abcOpts.link);
expect(abcOpts.link.href).toEqual("http://localhost/numbers?q=4");
}
Expand All @@ -135,6 +139,8 @@ describe("resolveTemplate", () => {
method: "POST",
url: "http://localhost/item/4"
})
expect(template?.title).toBeUndefined();
expect(template?.contentType).toEqual("application/x-www-form-urlencoded")
expect(template?.properties.length).toEqual(1);
expect(template?.property("ZZZ").required).toBe(true);
})
Expand Down
Loading