diff --git a/src/examples/index.ts b/src/examples/index.ts index 89668255..ce962204 100644 --- a/src/examples/index.ts +++ b/src/examples/index.ts @@ -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? }); @@ -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) diff --git a/src/examples/volunteers/component.ts b/src/examples/volunteers/component.ts new file mode 100644 index 00000000..0af38d34 --- /dev/null +++ b/src/examples/volunteers/component.ts @@ -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 +} + +export class VolunteerComponent extends BaseLiveViewComponent + implements LiveViewExternalEventListener { + + mount(params: LiveViewMountParams, session: Partial, socket: LiveViewSocket) { + return { + volunteers: [], + changeset: changeset({}, {}) + } + }; + + render(context: VolunteerContext) { + const { changeset, volunteers } = context; + return html` +

Volunteer Check-In

+
+ + ${form_for("#", { phx_submit: "save" })} + +
+ ${text_input(changeset, "name", { placeholder: "Name", autocomplete: "off" })} + ${error_tag(changeset, "name")} +
+ +
+ ${telephone_input(changeset, "phone", { placeholder: "Phone", autocomplete: "off" })} + ${error_tag(changeset, "phone")} +
+ ${submit("Check In", { phx_disable_with: "Saving..." })} + + +
+ ${volunteers.map(this.renderVolunteer)} +
+
+ ` + }; + + renderVolunteer(volunteer: Volunteer) { + return html` +
+
+ ${volunteer.name} +
+
+ 📞 ${volunteer.phone} +
+
+ ${volunteer.checked_out ? "☑️ Volunteer" : html``} +
+
+ ` + } + + handleEvent(event: "save", params: StringPropertyValues>, socket: LiveViewSocket): VolunteerContext { + const { volunteers } = socket.context; + console.log("save", params); + const volunteer: Partial = { + 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 + } + } + } + +} diff --git a/src/examples/volunteers/data.test.ts b/src/examples/volunteers/data.test.ts new file mode 100644 index 00000000..69581d5c --- /dev/null +++ b/src/examples/volunteers/data.test.ts @@ -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() + } + }); + +}); \ No newline at end of file diff --git a/src/examples/volunteers/data.ts b/src/examples/volunteers/data.ts new file mode 100644 index 00000000..32634c9f --- /dev/null +++ b/src/examples/volunteers/data.ts @@ -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; + +// in memory data store +export const volunteers: Record = {} + +export const changeset = (volunteer: Partial, attrs: Partial, action?: string): LiveViewChangeset => { + 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) + } + return { + action, + changes: updatedDiff(volunteer, merged), + data: result.success ? result.data : merged, + valid: result.success, + errors + } as LiveViewChangeset +} + +export const create_volunteer = (newVolunteer: Partial): LiveViewChangeset => { + 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): LiveViewChangeset => { + const result = changeset(volunteer, updated, 'update'); + if (result.valid) { + const v = result.data as Volunteer; + volunteers[v.id] = v; + } + return result; +} + diff --git a/src/server/templates/helpers/form_for.ts b/src/server/templates/helpers/form_for.ts new file mode 100644 index 00000000..c8b280a1 --- /dev/null +++ b/src/server/templates/helpers/form_for.ts @@ -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 = (action: string, options?: FormForOptions) => { + const method = options?.method ?? "post"; + const phx_submit = options?.phx_submit ? `phx-submit="${options.phx_submit}"` : ""; + return html` +
+ ` +} \ No newline at end of file diff --git a/src/server/templates/helpers/inputs.ts b/src/server/templates/helpers/inputs.ts new file mode 100644 index 00000000..c4736453 --- /dev/null +++ b/src/server/templates/helpers/inputs.ts @@ -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 = (changeset: LiveViewChangeset, 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` + + ` +} + +interface TelephoneInputOptions extends Omit { } + +export const telephone_input = (changeset: LiveViewChangeset, key: keyof T, options?: TelephoneInputOptions) => { + return text_input(changeset, key, { ...options, type: "tel" }); +} + +interface ErrorTagOptions { + className?: string +} + +export const error_tag = (changeset: LiveViewChangeset, 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` + ${error} + ` + } + return html`` +} \ No newline at end of file diff --git a/src/server/templates/helpers/submit.ts b/src/server/templates/helpers/submit.ts new file mode 100644 index 00000000..01ead6ea --- /dev/null +++ b/src/server/templates/helpers/submit.ts @@ -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` + + ` +} \ No newline at end of file