Skip to content

Commit

Permalink
implementation of volunteers including some input and form helpers
Browse files Browse the repository at this point in the history
  • Loading branch information
floodfx committed Feb 8, 2022
1 parent 7fc268c commit 54e219f
Show file tree
Hide file tree
Showing 7 changed files with 296 additions and 2 deletions.
7 changes: 5 additions & 2 deletions src/examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import { SalesDashboardLiveViewComponent } from './sales_dashboard_liveview';
import { ServersLiveViewComponent } from './servers/component';
import { SortLiveViewComponent } from './sorting/component';
import { routeDetails } from './routeDetails';
import { VolunteerComponent } from './volunteers/component';

const lvServer = new LiveViewServer({
signingSecret: "MY_VERY_SECRET_KEY",
// port: 3002,
// rootView: "./examples/rootView.ejs",
viewsPath: path.join(__dirname, "views"),
// support different templates?
});


Expand All @@ -27,17 +28,19 @@ const router: LiveViewRouter = {
"/autocomplete": new AutocompleteLiveViewComponent(),
"/paginate": new PaginateLiveViewComponent(),
"/sort": new SortLiveViewComponent(),
'/servers': new ServersLiveViewComponent(),
}

// register all routes
lvServer.registerLiveViewRoutes(router)

// register single route
lvServer.registerLiveViewRoute("/servers", new ServersLiveViewComponent())
lvServer.registerLiveViewRoute("/volunteers", new VolunteerComponent())

// add your own routes to the express app
lvServer.expressApp.get("/", (req, res) => {

// this one renders the index of the examples
res.render("index.html.ejs", {
routes: Object.keys(router).map(path => {
return routeDetails.find(route => route.path === path)
Expand Down
91 changes: 91 additions & 0 deletions src/examples/volunteers/component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import html from "../../server/templates";
import { BaseLiveViewComponent, LiveViewChangeset, LiveViewExternalEventListener, LiveViewMountParams, LiveViewSocket, StringPropertyValues } from "../../server/types";
import { SessionData } from "express-session";
import { Volunteer, changeset, create_volunteer } from "./data";
import { submit } from "../../server/templates/helpers/submit";
import { form_for } from "../../server/templates/helpers/form_for";
import { error_tag, telephone_input, text_input } from "../../server/templates/helpers/inputs";

export interface VolunteerContext {
volunteers: Volunteer[]
changeset: LiveViewChangeset<Volunteer>
}

export class VolunteerComponent extends BaseLiveViewComponent<VolunteerContext, unknown>
implements LiveViewExternalEventListener<VolunteerContext, "save", Volunteer> {

mount(params: LiveViewMountParams, session: Partial<SessionData>, socket: LiveViewSocket<VolunteerContext>) {
return {
volunteers: [],
changeset: changeset({}, {})
}
};

render(context: VolunteerContext) {
const { changeset, volunteers } = context;
return html`
<h1>Volunteer Check-In</h1>
<div id="checkin">
${form_for<Volunteer>("#", { phx_submit: "save" })}
<div class="field">
${text_input<Volunteer>(changeset, "name", { placeholder: "Name", autocomplete: "off" })}
${error_tag(changeset, "name")}
</div>
<div class="field">
${telephone_input<Volunteer>(changeset, "phone", { placeholder: "Phone", autocomplete: "off" })}
${error_tag(changeset, "phone")}
</div>
${submit("Check In", { phx_disable_with: "Saving..." })}
</form>
<div id="volunteers" phx-update="prepend">
${volunteers.map(this.renderVolunteer)}
</div>
</div>
`
};

renderVolunteer(volunteer: Volunteer) {
return html`
<div id="${volunteer.id}" class="volunteer ${volunteer.checked_out ? "out" : ""}">
<div class="name">
${volunteer.name}
</div>
<div class="phone">
📞 ${volunteer.phone}
</div>
<div class="status">
${volunteer.checked_out ? "☑️ Volunteer" : html`<button>Check Out</button>`}
</div>
</div>
`
}

handleEvent(event: "save", params: StringPropertyValues<Pick<Volunteer, "name" | "phone">>, socket: LiveViewSocket<VolunteerContext>): VolunteerContext {
const { volunteers } = socket.context;
console.log("save", params);
const volunteer: Partial<Volunteer> = {
name: params.name,
phone: params.phone,
}
const createChangeset = create_volunteer(volunteer);
if (createChangeset.valid) {
const newVolunteer = createChangeset.data as Volunteer;
const newVolunteers = [newVolunteer, ...volunteers];
const newChangeset = changeset({}, {});
return {
volunteers: newVolunteers,
changeset: newChangeset
}
} else {
return {
volunteers,
changeset: createChangeset
}
}
}

}
73 changes: 73 additions & 0 deletions src/examples/volunteers/data.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@

import { Volunteer, VolunteerSchema } from './data';

describe("test volunteers", () => {
it("valid volunteer", () => {
const volunteer = {
id: '1',
name: 'John Doe',
phone: '123-456-7890',
checked_out: false
} as Volunteer;
expect(VolunteerSchema.safeParse(volunteer).success).toBe(true);
});

it("invalid volunteer, name too short", () => {
const volunteer = {
id: '1',
name: 'J',
phone: '123-456-7890',
checked_out: false
} as Volunteer;
const result = VolunteerSchema.safeParse(volunteer);
expect(result.success).toBe(false);
if (result.success === false) {
const formatted = result.error.format();
expect(result.error.isEmpty).toBe(false);
expect(result.error.issues.length).toBe(1);
expect(result.error.issues[0].message).toBe('Should be at least 2 characters');
}
});

it("invalid volunteer, phone invalid", () => {
const volunteer = {
id: '1',
name: 'John Doe',
phone: '123',
checked_out: false
} as Volunteer;
const result = VolunteerSchema.safeParse(volunteer);
expect(result.success).toBe(false);
if (result.success === false) {
expect(result.error.isEmpty).toBe(false);
expect(result.error.issues.length).toBe(1);
expect(result.error.issues[0].message).toBe('Should be a valid phone number');
}
});

it("valid volunteer, default id, checkout out", () => {
const volunteer = {
name: 'Jane Doe',
phone: '123-456-7890',
} as Volunteer;
const result = VolunteerSchema.safeParse(volunteer);
expect(result.success).toBe(true);
});

it("invalid volunteer, phone and name invalid", () => {
const volunteer = {
name: 'J',
phone: '123',
} as Volunteer;
const result = VolunteerSchema.safeParse(volunteer);
expect(result.success).toBe(false);
if (result.success === false) {
expect(result.error.isEmpty).toBe(false);
expect(result.error.issues.length).toBe(2);
const formatted = result.error.format();
expect(formatted.name).not.toBeUndefined()
expect(formatted.phone).not.toBeUndefined()
}
});

});
60 changes: 60 additions & 0 deletions src/examples/volunteers/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { z } from 'zod';
import { nanoid } from 'nanoid';
import { updatedDiff } from 'deep-object-diff'
import { LiveViewChangeset, LiveViewChangesetErrors } from '../../server/types';

const phoneRegex = /^\d{3}[\s-.]?\d{3}[\s-.]?\d{4}$/

// Use Zod to define the schema for the Volunteer model
// More on Zod - https://github.com/colinhacks/zod
export const VolunteerSchema = z.object({
id: z.string().default(nanoid()),
name: z.string().min(2).max(100),
phone: z.string().regex(phoneRegex, 'Should be a valid phone number'),
checked_out: z.boolean().default(false),
})

// infer the Volunteer model from the Zod Schema
export type Volunteer = z.infer<typeof VolunteerSchema>;

// in memory data store
export const volunteers: Record<string, Volunteer> = {}

export const changeset = (volunteer: Partial<Volunteer>, attrs: Partial<Volunteer>, action?: string): LiveViewChangeset<Volunteer> => {
const merged = { ...volunteer, ...attrs };
const result = VolunteerSchema.safeParse(merged);
let errors;
if (result.success === false) {
errors = result.error.issues.reduce((acc, issue) => {
// @ts-ignore
acc[issue.path[0]] = issue.message;
return acc;
}, {} as LiveViewChangesetErrors<Volunteer>)
}
return {
action,
changes: updatedDiff(volunteer, merged),
data: result.success ? result.data : merged,
valid: result.success,
errors
} as LiveViewChangeset<Volunteer>
}

export const create_volunteer = (newVolunteer: Partial<Volunteer>): LiveViewChangeset<Volunteer> => {
const result = changeset({}, newVolunteer, 'create');
if (result.valid) {
const v = result.data as Volunteer;
volunteers[v.id] = v;
}
return result;
}

export const update_volunteer = (volunteer: Volunteer, updated: Partial<Volunteer>): LiveViewChangeset<Volunteer> => {
const result = changeset(volunteer, updated, 'update');
if (result.valid) {
const v = result.data as Volunteer;
volunteers[v.id] = v;
}
return result;
}

15 changes: 15 additions & 0 deletions src/server/templates/helpers/form_for.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import html from ".."

interface FormForOptions {
phx_submit?: string
method?: "get" | "post"
}

// TODO insert hidden input for CSRF token?
export const form_for = <T>(action: string, options?: FormForOptions) => {
const method = options?.method ?? "post";
const phx_submit = options?.phx_submit ? `phx-submit="${options.phx_submit}"` : "";
return html`
<form action="${action}" method="${method}" ${phx_submit}>
`
}
40 changes: 40 additions & 0 deletions src/server/templates/helpers/inputs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { LiveViewChangeset } from "../../types";
import html from ".."

interface InputOptions {
placeholder?: string
autocomplete?: "off" | "on"
type?: "text" | "tel"
}

export const text_input = <T>(changeset: LiveViewChangeset<T>, key: keyof T, options?: InputOptions) => {
const placeholder = options?.placeholder ? "placeholder=\"" + options.placeholder + "\"" : "";
const autocomplete = options?.autocomplete ? "autocomplete=\"" + options.autocomplete + "\"" : "";
const type = options?.type ?? "text";
const id = `input_${key}`;
const value = changeset.data[key] ?? "";
return html`
<input type="${type}" id="${id}" name="${key}" value="${value}" ${autocomplete} ${placeholder} />
`
}

interface TelephoneInputOptions extends Omit<InputOptions, "type"> { }

export const telephone_input = <T>(changeset: LiveViewChangeset<T>, key: keyof T, options?: TelephoneInputOptions) => {
return text_input(changeset, key, { ...options, type: "tel" });
}

interface ErrorTagOptions {
className?: string
}

export const error_tag = <T>(changeset: LiveViewChangeset<T>, key: keyof T, options?: ErrorTagOptions) => {
const error = changeset.errors ? changeset.errors[key] : undefined;
if (changeset.action && error) {
const className = options?.className ?? "invalid-feedback";
return html`
<span class="${className}" phx-feedback-for="${key}">${error}</span>
`
}
return html``
}
12 changes: 12 additions & 0 deletions src/server/templates/helpers/submit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import html from ".."

interface SubmitOptions {
phx_disable_with: string
}

export const submit = (label: string, options?: SubmitOptions) => {
const phx_disable_with = options?.phx_disable_with ?? "";
return html`
<button ${phx_disable_with} type="submit">${label}</button>
`
}

0 comments on commit 54e219f

Please sign in to comment.