diff --git a/src/examples/autocomplete/component.ts b/src/examples/autocomplete/component.ts index fa43ade3..ece52c9f 100644 --- a/src/examples/autocomplete/component.ts +++ b/src/examples/autocomplete/component.ts @@ -12,8 +12,6 @@ export interface AutocompleteContext { loading: boolean; } -const idToWs = new Map(); - export class AutocompleteLiveViewComponent extends BaseLiveViewComponent implements LiveViewExternalEventListener>, LiveViewExternalEventListener>, diff --git a/src/examples/index.ts b/src/examples/index.ts index 2b5f5ed9..907c9497 100644 --- a/src/examples/index.ts +++ b/src/examples/index.ts @@ -4,6 +4,7 @@ import { AutocompleteLiveViewComponent } from './autocomplete/component'; import { LicenseLiveViewComponent } from './license_liveview'; import { LightLiveViewComponent } from './light_liveview'; import { SearchLiveViewComponent } from './live-search/component'; +import { PaginateLiveViewComponent } from './pagination/component'; import { SalesDashboardLiveViewComponent } from './sales_dashboard_liveview'; import { ServersLiveViewComponent } from './servers/component'; @@ -20,6 +21,7 @@ export const router: LiveViewRouter = { '/search': new SearchLiveViewComponent(), "/autocomplete": new AutocompleteLiveViewComponent(), "/light": new LightLiveViewComponent(), + "/paginate": new PaginateLiveViewComponent(), } // register all routes diff --git a/src/examples/license_liveview.ts b/src/examples/license_liveview.ts index d0ed6a49..75b24f86 100644 --- a/src/examples/license_liveview.ts +++ b/src/examples/license_liveview.ts @@ -46,9 +46,9 @@ export class LicenseLiveViewComponent extends BaseLiveViewComponent) { + handleEvent(event: "update", params: { seats: string }, socket: LiveViewSocket) { // console.log("event:", event, params, socket); - const { seats } = params; + const seats = Number(params.seats || 2); const amount = calculateLicenseAmount(seats); return { seats, amount }; } diff --git a/src/examples/pagination/component.ts b/src/examples/pagination/component.ts new file mode 100644 index 00000000..fb71e698 --- /dev/null +++ b/src/examples/pagination/component.ts @@ -0,0 +1,143 @@ +import { options_for_select } from "../../server/templates/helpers/options_for_select"; +import { live_patch } from "../../server/templates/helpers/live_patch"; +import html, { HtmlSafeString, join } from "../../server/templates"; +import { BaseLiveViewComponent, LiveViewExternalEventListener, LiveViewSocket, StringPropertyValues } from "../../server/types"; +import { almostExpired, Donation, listItems } from "./data"; + +interface Options { + page: number; + perPage: number; +} + +export interface PaginateContext { + options: Options + donations: Donation[] +} + +export class PaginateLiveViewComponent extends BaseLiveViewComponent implements LiveViewExternalEventListener> { + + mount(params: any, session: any, socket: LiveViewSocket) { + const options = { page: 1, perPage: 10 } + return { + options: options, + donations: listItems(options.page, options.perPage) + }; + }; + + handleParams(params: StringPropertyValues, url: string, socket: LiveViewSocket): PaginateContext { + const page = Number(params.page || 1); + const perPage = Number(params.perPage || 10); + return { + options: { page, perPage }, + donations: listItems(page, perPage) + }; + } + + render(context: PaginateContext) { + const { options: { perPage, page }, donations } = context; + return html` +

Food Bank Donations

+
+
+ Show + + +
+
+ + + + + + + + + + ${this.renderDonations(donations)} + +
+ Item + + Quantity + + Days Until Expires +
+ +
+
+ ` + }; + + handleEvent(event: "select-per-page", params: StringPropertyValues>, socket: LiveViewSocket): PaginateContext { + const page = socket.context.options.page; + const perPage = Number(params.perPage || 10); + + this.pushPatch(socket, { to: { path: "/paginate", params: { page: String(page), perPage: String(perPage) } } }); + + return { + options: { page, perPage }, + donations: listItems(page, perPage) + }; + } + + pageLinks(page: number, perPage: number) { + let links: HtmlSafeString[] = []; + for (var p = page - 2; p <= page + 2; p++) { + if (p > 0) { + links.push(this.paginationLink(String(p), p, perPage, p === page ? "active" : "")) + } + } + return join(links, "") + } + + paginationLink(text: string, pageNum: number, perPageNum: number, className: string) { + const page = String(pageNum); + const perPage = String(perPageNum); + return live_patch(text, { + to: { + path: "/paginate", + params: { page, perPage } + }, + class: className + }) + } + + renderDonations(donations: Donation[]) { + return donations.map(donation => html` + + + ${donation.id} + ${donation.emoji} ${donation.item} + + + ${donation.quantity} lbs + + + + ${donation.days_until_expires} + + + + `) + } + + expiresClass(donation: Donation) { + if (almostExpired(donation)) { + return "eat-now" + } else { + return "fresh" + } + } + +} + diff --git a/src/examples/pagination/data.ts b/src/examples/pagination/data.ts new file mode 100644 index 00000000..57abed7f --- /dev/null +++ b/src/examples/pagination/data.ts @@ -0,0 +1,76 @@ + + +export interface Donation { + id: string; + emoji: string + item: string + quantity: number + days_until_expires: number +} + +const items = [ + { emoji: "☕️", item: "Coffee" }, + { emoji: "🥛", item: "Milk" }, + { emoji: "🥩", item: "Beef" }, + { emoji: "🍗", item: "Chicken" }, + { emoji: "🍖", item: "Pork" }, + { emoji: "🍗", item: "Turkey" }, + { emoji: "🥔", item: "Potatoes" }, + { emoji: "🥣", item: "Cereal" }, + { emoji: "🥣", item: "Oatmeal" }, + { emoji: "🥚", item: "Eggs" }, + { emoji: "🥓", item: "Bacon" }, + { emoji: "🧀", item: "Cheese" }, + { emoji: "🥬", item: "Lettuce" }, + { emoji: "🥒", item: "Cucumber" }, + { emoji: "🐠", item: "Smoked Salmon" }, + { emoji: "🐟", item: "Tuna" }, + { emoji: "🐡", item: "Halibut" }, + { emoji: "🥦", item: "Broccoli" }, + { emoji: "🧅", item: "Onions" }, + { emoji: "🍊", item: "Oranges" }, + { emoji: "🍯", item: "Honey" }, + { emoji: "🍞", item: "Sourdough Bread" }, + { emoji: "🥖", item: "French Bread" }, + { emoji: "🍐", item: "Pear" }, + { emoji: "🥜", item: "Nuts" }, + { emoji: "🍎", item: "Apples" }, + { emoji: "🥥", item: "Coconut" }, + { emoji: "🧈", item: "Butter" }, + { emoji: "🧀", item: "Mozzarella" }, + { emoji: "🍅", item: "Tomatoes" }, + { emoji: "🍄", item: "Mushrooms" }, + { emoji: "🍚", item: "Rice" }, + { emoji: "🍜", item: "Pasta" }, + { emoji: "🍌", item: "Banana" }, + { emoji: "🥕", item: "Carrots" }, + { emoji: "🍋", item: "Lemons" }, + { emoji: "🍉", item: "Watermelons" }, + { emoji: "🍇", item: "Grapes" }, + { emoji: "🍓", item: "Strawberries" }, + { emoji: "🍈", item: "Melons" }, + { emoji: "🍒", item: "Cherries" }, + { emoji: "🍑", item: "Peaches" }, + { emoji: "🍍", item: "Pineapples" }, + { emoji: "🥝", item: "Kiwis" }, + { emoji: "🍆", item: "Eggplants" }, + { emoji: "🥑", item: "Avocados" }, + { emoji: "🌶", item: "Peppers" }, + { emoji: "🌽", item: "Corn" }, + { emoji: "🍠", item: "Sweet Potatoes" }, + { emoji: "🥯", item: "Bagels" }, + { emoji: "🥫", item: "Soup" }, + { emoji: "🍪", item: "Cookies" } +] + +export const donations: Donation[] = items.map((item, id) => { + const quantity = Math.floor(Math.random() * 20) + 1; + const days_until_expires = Math.floor(Math.random() * 30) + 1; + return { ...item, quantity, days_until_expires, id: (id + 1).toString() } +}) + +export const listItems = (page: number, perPage: number) => { + return donations.slice((page - 1) * perPage, page * perPage) +} + +export const almostExpired = (donation: Donation) => donation.days_until_expires <= 10 \ No newline at end of file diff --git a/src/server/socket/component_manager.ts b/src/server/socket/component_manager.ts index a10af4ff..397e70e9 100644 --- a/src/server/socket/component_manager.ts +++ b/src/server/socket/component_manager.ts @@ -1,6 +1,6 @@ import { WebSocket } from "ws"; -import { LiveViewComponent, LiveViewSocket } from ".."; -import { newHeartbeatReply, newPhxReply, PhxClickPayload, PhxDiffReply, PhxFormPayload, PhxHeartbeatIncoming, PhxIncomingMessage, PhxJoinIncoming, PhxJoinPayload, PhxLivePatchIncoming, PhxOutgoingMessage } from "./types"; +import { BaseLiveViewComponent, LiveViewComponent, LiveViewSocket, StringPropertyValues } from ".."; +import { newHeartbeatReply, newPhxReply, PhxClickPayload, PhxDiffReply, PhxFormPayload, PhxHeartbeatIncoming, PhxIncomingMessage, PhxJoinIncoming, PhxJoinPayload, PhxLivePatchIncoming, PhxLivePatchPushPayload, PhxOutgoingLivePatchPush, PhxOutgoingMessage, PhxSocketProtocolNames } from "./types"; import jwt from 'jsonwebtoken'; import { SessionData } from "express-session"; @@ -17,6 +17,9 @@ export class LiveViewComponentManager { this.component = component; this.signingSecret = signingSecret; this.context = {}; + if (component instanceof BaseLiveViewComponent) { + component.registerComponentManager(this); + } } handleJoin(ws: WebSocket, message: PhxJoinIncoming) { @@ -129,6 +132,25 @@ export class LiveViewComponentManager { this.sendPhxReply(ws, newPhxReply(message, replyPayload)); } + onPushPatch(liveViewSocket: LiveViewSocket, patch: { to: { path: string, params: StringPropertyValues } }) { + const urlParams = new URLSearchParams(patch.to.params); + const to = `${patch.to.path}?${urlParams}` + const message: PhxOutgoingLivePatchPush = [ + null, // no join reference + null, // no message reference + liveViewSocket.id, + "live_patch", + { kind: "push", to } + ] + + // @ts-ignore - URLSearchParams has an entries method but not typed + const params = Object.fromEntries(urlParams); + + this.context = this.component.handleParams(params, to, liveViewSocket); + + this.sendPhxReply(liveViewSocket.ws!, message) + } + repeat(fn: () => void, intervalMillis: number) { this.intervals.push(setInterval(fn, intervalMillis)); } @@ -171,6 +193,7 @@ export class LiveViewComponentManager { } } + private buildLiveViewSocket(ws: WebSocket, topic: string): LiveViewSocket { return { id: topic, @@ -190,7 +213,7 @@ export class LiveViewComponentManager { this.shutdown(); console.error("socket is closed", err, "...shutting down topic", reply[2], "for component", this.component); } else { - + console.error("socket error", err); } } }); diff --git a/src/server/socket/types.ts b/src/server/socket/types.ts index 4d858e15..a1c5e056 100644 --- a/src/server/socket/types.ts +++ b/src/server/socket/types.ts @@ -21,7 +21,7 @@ export type PhxOutgoingMessage = [ joinRef: string | null, // number messageRef: string | null, // number topic: "phoenix" | string, - event: "phx_reply" | "diff", + event: "phx_reply" | "diff" | "live_patch", payload: Payload ] @@ -52,6 +52,12 @@ export interface PhxReplyPayload { export type PhxReply = PhxOutgoingMessage; export type PhxDiffReply = PhxOutgoingMessage; +export interface PhxLivePatchPushPayload { + kind: "push", + to: string, +} +export type PhxOutgoingLivePatchPush = PhxOutgoingMessage; + export interface PhxEventPayload { type: Type, event: string, diff --git a/src/server/templates/helpers/options_for_select.ts b/src/server/templates/helpers/options_for_select.ts new file mode 100644 index 00000000..5506e624 --- /dev/null +++ b/src/server/templates/helpers/options_for_select.ts @@ -0,0 +1,68 @@ +import html, { HtmlSafeString, join } from ".."; + +type Options = + string[] | + { [key: string]: string } + +type Selected = string | string[] + +export const options_for_select = (options: Options, selected: Selected): HtmlSafeString => { + // string[] options + if (typeof options === "object" && Array.isArray(options)) { + const htmlOptions = mapOptionsValueArrayAndSelectedToHtmlOptions(options, selected); + return renderOptions(htmlOptions); + } + // key-value options + else {//if (typeof options === "object" && !Array.isArray(options)) { + const htmlOptions = mapOptionsLabelValuesAndSelectedToHtmlOptions(options, selected); + return renderOptions(htmlOptions); + } + +} + +function isSelected(value: string, selected: string | string[]): boolean { + if (Array.isArray(selected)) { + return selected.includes(value); + } + return value === selected; +} + +function mapOptionsValueArrayAndSelectedToHtmlOptions(options: string[], selected: Selected): HtmlOption[] { + return options.map((option) => { + return { + label: option, + value: option, + selected: isSelected(option, selected) + } + }) +} + +function mapOptionsLabelValuesAndSelectedToHtmlOptions(options: { [key: string]: string }, selected: Selected) { + return Object.entries(options).map(([label, value]) => { + return { + label, + value, + selected: isSelected(value, selected) + } + }) +} + + +function renderOptions(options: HtmlOption[]): HtmlSafeString { + return join(options.map(renderOption)); +} + +function renderOption(option: HtmlOption): HtmlSafeString { + return html``; +} + +interface HtmlOption { + label: string; + value: string; + selected: boolean +} + +interface HtmlOptionGroup { + label: string; + options: HtmlOption[]; +} \ No newline at end of file diff --git a/src/server/templates/index.ts b/src/server/templates/index.ts index b6c5ae4a..d6aaef50 100644 --- a/src/server/templates/index.ts +++ b/src/server/templates/index.ts @@ -20,10 +20,7 @@ const inspect = Symbol.for('nodejs.util.inspect.custom'); const ENT_REGEX = new RegExp(Object.keys(ENTITIES).join('|'), 'g') -export function join(array: (string | HtmlSafeString)[], separator: string | HtmlSafeString) { - if (separator === undefined || separator === null) { - separator = ',' - } +export function join(array: (string | HtmlSafeString)[], separator: string | HtmlSafeString = "") { if (array.length <= 0) { return new HtmlSafeString([''], []) } diff --git a/src/server/types.ts b/src/server/types.ts index 8ac160f6..d3e9c0f9 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -1,5 +1,7 @@ import { SessionData } from "express-session"; import { WebSocket } from "ws"; +import { LiveViewComponentManager } from "./socket/component_manager"; +import { PhxOutgoingLivePatchPush } from "./socket/types"; import { HtmlSafeString } from "./templates"; export interface LiveViewSocket { @@ -23,17 +25,20 @@ export interface LiveViewSessionParams { [key: string]: string; } +// params on url are strings +export type StringPropertyValues = { [Property in keyof Type]: string; }; + export interface LiveViewComponent { mount(params: LiveViewMountParams, session: Partial, socket: LiveViewSocket): T; render(context: T): LiveViewTemplate; - handleParams(params: P, url: string, socket: LiveViewSocket): T; + handleParams(params: StringPropertyValues

, url: string, socket: LiveViewSocket): T; } // TODO: support event returing Partial? export interface LiveViewExternalEventListener { - handleEvent(event: Lowercase, params: P, socket: LiveViewSocket): T; + handleEvent(event: E, params: StringPropertyValues

, socket: LiveViewSocket): T; } // TODO: support event returing Partial? @@ -47,11 +52,25 @@ export interface LiveViewRouter { export abstract class BaseLiveViewComponent implements LiveViewComponent { + private componentManager: LiveViewComponentManager; + abstract mount(params: any, session: any, socket: LiveViewSocket): T; abstract render(context: T): LiveViewTemplate; - handleParams(params: P, url: string, socket: LiveViewSocket): T { + handleParams(params: StringPropertyValues

, url: string, socket: LiveViewSocket): T { return socket.context; } + pushPatch(socket: LiveViewSocket, patch: { to: { path: string, params: StringPropertyValues } }) { + if (this.componentManager) { + this.componentManager.onPushPatch(socket, patch); + } else { + console.error("component manager not registered for component", this); + } + } + + registerComponentManager(manager: LiveViewComponentManager) { + this.componentManager = manager; + } + }