Skip to content

Commit

Permalink
Merge pull request #9 from floodfx/pagination
Browse files Browse the repository at this point in the history
implemented pagination which required implementing pushPatch server m…
  • Loading branch information
floodfx authored Jan 27, 2022
2 parents c04bca6 + e202439 commit 0a3f773
Show file tree
Hide file tree
Showing 10 changed files with 347 additions and 15 deletions.
2 changes: 0 additions & 2 deletions src/examples/autocomplete/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ export interface AutocompleteContext {
loading: boolean;
}

const idToWs = new Map<string, WebSocket>();

export class AutocompleteLiveViewComponent extends BaseLiveViewComponent<AutocompleteContext, unknown> implements
LiveViewExternalEventListener<AutocompleteContext, "zip-search", Pick<AutocompleteContext, "zip">>,
LiveViewExternalEventListener<AutocompleteContext, "suggest-city", Pick<AutocompleteContext, "city">>,
Expand Down
2 changes: 2 additions & 0 deletions src/examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -20,6 +21,7 @@ export const router: LiveViewRouter = {
'/search': new SearchLiveViewComponent(),
"/autocomplete": new AutocompleteLiveViewComponent(),
"/light": new LightLiveViewComponent(),
"/paginate": new PaginateLiveViewComponent(),
}

// register all routes
Expand Down
4 changes: 2 additions & 2 deletions src/examples/license_liveview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ export class LicenseLiveViewComponent extends BaseLiveViewComponent<LicenseConte
`
};

handleEvent(event: "update", params: { seats: number }, socket: LiveViewSocket<LicenseContext>) {
handleEvent(event: "update", params: { seats: string }, socket: LiveViewSocket<LicenseContext>) {
// console.log("event:", event, params, socket);
const { seats } = params;
const seats = Number(params.seats || 2);
const amount = calculateLicenseAmount(seats);
return { seats, amount };
}
Expand Down
143 changes: 143 additions & 0 deletions src/examples/pagination/component.ts
Original file line number Diff line number Diff line change
@@ -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<PaginateContext, Options> implements LiveViewExternalEventListener<PaginateContext, "select-per-page", Pick<Options, "perPage">> {

mount(params: any, session: any, socket: LiveViewSocket<PaginateContext>) {
const options = { page: 1, perPage: 10 }
return {
options: options,
donations: listItems(options.page, options.perPage)
};
};

handleParams(params: StringPropertyValues<Options>, url: string, socket: LiveViewSocket<PaginateContext>): 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`
<h1>Food Bank Donations</h1>
<div id="donations">
<form phx-change="select-per-page">
Show
<select name="perPage">
${options_for_select([5, 10, 15, 20].map(n => String(n)), String(perPage))}
</select>
<label for="perPage">per page</label>
</form>
<div class="wrapper">
<table>
<thead>
<tr>
<th class="item">
Item
</th>
<th>
Quantity
</th>
<th>
Days Until Expires
</th>
</tr>
</thead>
<tbody>
${this.renderDonations(donations)}
</tbody>
</table>
<div class="footer">
<div class="pagination">
${page > 1 ? this.paginationLink("Previous", page - 1, perPage, "previous") : ""}
${this.pageLinks(page, perPage)}
${this.paginationLink("Next", page + 1, perPage, "next")}
</div>
</div>
</div>
</div>
`
};

handleEvent(event: "select-per-page", params: StringPropertyValues<Pick<Options, "perPage">>, socket: LiveViewSocket<PaginateContext>): 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`
<tr>
<td class="item">
<span class="id">${donation.id}</span>
${donation.emoji} ${donation.item}
</td>
<td>
${donation.quantity} lbs
</td>
<td>
<span class="${this.expiresClass(donation)}">
${donation.days_until_expires}
</span>
</td>
</tr>
`)
}

expiresClass(donation: Donation) {
if (almostExpired(donation)) {
return "eat-now"
} else {
return "fresh"
}
}

}

76 changes: 76 additions & 0 deletions src/examples/pagination/data.ts
Original file line number Diff line number Diff line change
@@ -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
29 changes: 26 additions & 3 deletions src/server/socket/component_manager.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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) {
Expand Down Expand Up @@ -129,6 +132,25 @@ export class LiveViewComponentManager {
this.sendPhxReply(ws, newPhxReply(message, replyPayload));
}

onPushPatch(liveViewSocket: LiveViewSocket<unknown>, patch: { to: { path: string, params: StringPropertyValues<any> } }) {
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));
}
Expand Down Expand Up @@ -171,6 +193,7 @@ export class LiveViewComponentManager {
}
}


private buildLiveViewSocket(ws: WebSocket, topic: string): LiveViewSocket<unknown> {
return {
id: topic,
Expand All @@ -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);
}
}
});
Expand Down
8 changes: 7 additions & 1 deletion src/server/socket/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export type PhxOutgoingMessage<Payload> = [
joinRef: string | null, // number
messageRef: string | null, // number
topic: "phoenix" | string,
event: "phx_reply" | "diff",
event: "phx_reply" | "diff" | "live_patch",
payload: Payload
]

Expand Down Expand Up @@ -52,6 +52,12 @@ export interface PhxReplyPayload {
export type PhxReply = PhxOutgoingMessage<PhxReplyPayload>;
export type PhxDiffReply = PhxOutgoingMessage<Diff>;

export interface PhxLivePatchPushPayload {
kind: "push",
to: string,
}
export type PhxOutgoingLivePatchPush = PhxOutgoingMessage<PhxLivePatchPushPayload>;

export interface PhxEventPayload<Type extends string, Value> {
type: Type,
event: string,
Expand Down
Loading

0 comments on commit 0a3f773

Please sign in to comment.