Add i18n support: per-locale catalogs, URL-driven locale, translated core and examples
CI / full-gate (push) Successful in 2m37s
CI / full-gate (push) Successful in 2m37s
This commit is contained in:
@@ -17,6 +17,10 @@ What it demonstrates:
|
||||
reusing the core `field` partial.
|
||||
- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
|
||||
`scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant.
|
||||
- **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including
|
||||
the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and
|
||||
the views carry the visitor's language onto their links with `localeHref()`.
|
||||
(README → [Languages](../../../README.md#languages-i18n).)
|
||||
|
||||
The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and
|
||||
`fetch` is injectable, so they unit-test as pure functions (`shifts.test.ts`).
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// This plugin's own catalog, and the baseline its other locales are written against. Keys are
|
||||
// looked up here first and fall back to the host's, so a plugin owns its words without prefixing
|
||||
// them, and `shifts.count` shows the plural form (host: README → Translating).
|
||||
|
||||
import type { PluralMessage } from "#plugin-api";
|
||||
|
||||
const messages = {
|
||||
"scheduling.cancel": "Cancel",
|
||||
"scheduling.field.assignee": "Assignee",
|
||||
"scheduling.field.end": "End",
|
||||
"scheduling.field.start": "Start",
|
||||
"scheduling.field.title": "Shift title",
|
||||
"scheduling.filter.label": "Filter shifts",
|
||||
"scheduling.filter.search": "Search",
|
||||
"scheduling.filter.searchLabel": "Search shifts",
|
||||
"scheduling.filter.searchPlaceholder": "Search title or assignee…",
|
||||
"scheduling.form.submit": "Create shift",
|
||||
"scheduling.nav.overview": "Overview",
|
||||
"scheduling.nav.section": "Scheduling",
|
||||
"scheduling.nav.shifts": "Shifts",
|
||||
"scheduling.new.title": "New shift",
|
||||
"scheduling.overview.lead":
|
||||
"Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.",
|
||||
"scheduling.overview.signIn": "Sign in to view shifts",
|
||||
"scheduling.overview.title": "Scheduling",
|
||||
"scheduling.overview.view": "View shifts",
|
||||
"scheduling.shifts.count": { one: "{{count}} shift", other: "{{count}} shifts" } as PluralMessage,
|
||||
"scheduling.shifts.new": "New shift",
|
||||
"scheduling.shifts.title": "Shifts",
|
||||
"scheduling.table.assignee": "Assignee",
|
||||
"scheduling.table.end": "End",
|
||||
"scheduling.table.shift": "Shift",
|
||||
"scheduling.table.start": "Start",
|
||||
"scheduling.upstream.create": "Couldn't save the shift — the scheduling service is unavailable.",
|
||||
"scheduling.upstream.list": "Couldn't reach the scheduling service — try again shortly.",
|
||||
"scheduling.validation.assignee": "Assign the shift to someone.",
|
||||
"scheduling.validation.title": "A shift needs a title.",
|
||||
};
|
||||
|
||||
export type SchedulingMessages = typeof messages;
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { SchedulingMessages } from "./en-US.ts";
|
||||
|
||||
const messages: SchedulingMessages = {
|
||||
"scheduling.cancel": "Avbryt",
|
||||
"scheduling.field.assignee": "Tilldelad",
|
||||
"scheduling.field.end": "Slut",
|
||||
"scheduling.field.start": "Start",
|
||||
"scheduling.field.title": "Passets namn",
|
||||
"scheduling.filter.label": "Filtrera pass",
|
||||
"scheduling.filter.search": "Sök",
|
||||
"scheduling.filter.searchLabel": "Sök pass",
|
||||
"scheduling.filter.searchPlaceholder": "Sök på namn eller person…",
|
||||
"scheduling.form.submit": "Skapa pass",
|
||||
"scheduling.nav.overview": "Översikt",
|
||||
"scheduling.nav.section": "Schemaläggning",
|
||||
"scheduling.nav.shifts": "Pass",
|
||||
"scheduling.new.title": "Nytt pass",
|
||||
"scheduling.overview.lead":
|
||||
"Schemaläggningen samordnar teamets pass. Alla kan läsa den här översikten; själva passlistan kräver behörigheten <code>scheduling:read</code>.",
|
||||
"scheduling.overview.signIn": "Logga in för att se passen",
|
||||
"scheduling.overview.title": "Schemaläggning",
|
||||
"scheduling.overview.view": "Visa pass",
|
||||
"scheduling.shifts.count": { one: "{{count}} pass", other: "{{count}} pass" },
|
||||
"scheduling.shifts.new": "Nytt pass",
|
||||
"scheduling.shifts.title": "Pass",
|
||||
"scheduling.table.assignee": "Tilldelad",
|
||||
"scheduling.table.end": "Slut",
|
||||
"scheduling.table.shift": "Pass",
|
||||
"scheduling.table.start": "Start",
|
||||
"scheduling.upstream.create": "Passet kunde inte sparas — schemaläggningstjänsten är otillgänglig.",
|
||||
"scheduling.upstream.list": "Vi når inte schemaläggningstjänsten — försök igen om en stund.",
|
||||
"scheduling.validation.assignee": "Passet måste tilldelas någon.",
|
||||
"scheduling.validation.title": "Passet behöver ett namn.",
|
||||
};
|
||||
|
||||
export default messages;
|
||||
@@ -17,17 +17,18 @@ export default definePlugin({
|
||||
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
|
||||
hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") },
|
||||
|
||||
// Merged into the global menu + filtered per user. "Overview" is `public`, so the "Scheduling"
|
||||
// Merged into the global menu + filtered per user. Labels are keys in this plugin's own catalog
|
||||
// (i18n/<locale>.ts) — a plain string works too, it just isn't translated. "Overview" is `public`, so the "Scheduling"
|
||||
// header shows for everyone (even signed out); "Shifts" needs `scheduling:read`, so the gated data
|
||||
// stays hidden until a reader signs in (a plugin may make a page + its menu option public).
|
||||
nav: [{
|
||||
children: [
|
||||
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true },
|
||||
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ },
|
||||
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "scheduling.nav.overview", public: true },
|
||||
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ },
|
||||
],
|
||||
icon: "i-cal",
|
||||
id: "scheduling",
|
||||
label: "Scheduling",
|
||||
label: "scheduling.nav.section",
|
||||
}],
|
||||
|
||||
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
||||
|
||||
@@ -4,20 +4,23 @@ import { Readable } from "node:stream";
|
||||
import test from "node:test";
|
||||
// Import only from the #plugin-api barrel — the same contract boundary shifts.ts uses (the host may
|
||||
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
|
||||
import { GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api";
|
||||
import { createTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api";
|
||||
import enUS from "./i18n/en-US.ts";
|
||||
import {
|
||||
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
||||
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
||||
} from "./shifts.ts";
|
||||
|
||||
const t = createTranslator({ catalogs: [enUS], locale: "en-US" }); // this plugin's own catalog, as the host would pass it
|
||||
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
||||
|
||||
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||
return {
|
||||
chrome: CHROME, user: null, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
||||
permissions: opts.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
chrome: CHROME, user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
||||
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
|
||||
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
// pure functions against a mock upstream with no network (README.md → Local dev & test story).
|
||||
|
||||
// One import from the host's #plugin-api barrel — the stable author surface (see README.md → Building plugins).
|
||||
import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, tracedFetch } from "#plugin-api";
|
||||
import { can, createTranslator, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "#plugin-api";
|
||||
import enUS from "./i18n/en-US.ts";
|
||||
|
||||
// The plugin's own English, for a view model built outside a request (its unit tests). At runtime a
|
||||
// handler passes ctx.t, which reads this plugin's catalog for the visitor's locale first.
|
||||
const EN: Translate = createTranslator({ catalogs: [enUS], locale: "en-US" });
|
||||
|
||||
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
|
||||
export const SHIFTS_PATH = "/scheduling/shifts";
|
||||
@@ -87,58 +92,63 @@ function toShift(raw: unknown): Shift {
|
||||
|
||||
// ---- view models (pure; the EJS views read these) -----------------------------------
|
||||
|
||||
export function buildListModel(opts: { canWrite: boolean; chrome: PageChrome; error?: string; q: string; shifts: Shift[] }) {
|
||||
export function buildListModel(opts: { canWrite: boolean; chrome: PageChrome; error?: string; q: string; shifts: Shift[]; t?: Translate }) {
|
||||
const t = opts.t ?? EN;
|
||||
return {
|
||||
breadcrumbs: [{ label: "Shifts" }], // SHIFTS_PATH is the list itself; the form links back to it as "Shifts"
|
||||
breadcrumbs: [{ label: t("scheduling.shifts.title") }], // SHIFTS_PATH is the list itself; the form links back to it
|
||||
canWrite: opts.canWrite,
|
||||
chrome: opts.chrome,
|
||||
// A plural message: one catalog key, the right form per locale and count (Intl.PluralRules).
|
||||
count: t("scheduling.shifts.count", { count: opts.shifts.length }),
|
||||
...(opts.error ? { error: opts.error } : {}),
|
||||
filterBar: {
|
||||
applyLabel: "Search",
|
||||
applyLabel: t("scheduling.filter.search"),
|
||||
clearHref: SHIFTS_PATH,
|
||||
label: "Filter shifts",
|
||||
pills: opts.q ? [{ label: "Search", remove: SHIFTS_PATH, value: opts.q }] : [],
|
||||
label: t("scheduling.filter.label"),
|
||||
pills: opts.q ? [{ label: t("scheduling.filter.search"), remove: SHIFTS_PATH, value: opts.q }] : [],
|
||||
rows: [[
|
||||
{ label: "Search shifts", name: "q", placeholder: "Search title or assignee…", type: "search", value: opts.q },
|
||||
{ label: t("scheduling.filter.searchLabel"), name: "q", placeholder: t("scheduling.filter.searchPlaceholder"), type: "search", value: opts.q },
|
||||
{ type: "spacer" },
|
||||
]],
|
||||
},
|
||||
newHref: `${SHIFTS_PATH}/new`,
|
||||
table: {
|
||||
caption: "Shifts",
|
||||
columns: [{ label: "Shift" }, { label: "Assignee" }, { label: "Start" }, { label: "End" }],
|
||||
caption: t("scheduling.shifts.title"),
|
||||
columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.assignee") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }],
|
||||
rows: opts.shifts.map((s) => ({
|
||||
cells: [{ rowHeader: { text: s.title } }, s.assignee, s.start, s.end],
|
||||
name: s.title,
|
||||
})),
|
||||
},
|
||||
title: "Shifts",
|
||||
title: t("scheduling.shifts.title"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFormModel(opts: { chrome: PageChrome; errors?: Record<string, string>; formError?: string; values?: Partial<ShiftInput> }) {
|
||||
export function buildFormModel(opts: { chrome: PageChrome; errors?: Record<string, string>; formError?: string; t?: Translate; values?: Partial<ShiftInput> }) {
|
||||
const t = opts.t ?? EN;
|
||||
const v = opts.values ?? {};
|
||||
const e = opts.errors ?? {};
|
||||
const field = (cfg: { icon?: string; id: string; label: string; type?: string; value: string }) => ({
|
||||
...cfg, name: cfg.id, ...(e[cfg.id] ? { error: e[cfg.id] } : {}), ...(cfg.id === "title" || cfg.id === "assignee" ? { required: true } : {}),
|
||||
});
|
||||
return {
|
||||
breadcrumbs: [{ href: SHIFTS_PATH, label: "Shifts" }, { label: "New shift" }],
|
||||
breadcrumbs: [{ href: SHIFTS_PATH, label: t("scheduling.shifts.title") }, { label: t("scheduling.new.title") }],
|
||||
chrome: opts.chrome,
|
||||
...(opts.formError ? { formError: opts.formError } : {}),
|
||||
form: {
|
||||
action: SHIFTS_PATH,
|
||||
cancelHref: SHIFTS_PATH,
|
||||
csrfToken: opts.chrome.csrfToken,
|
||||
cancelLabel: t("scheduling.cancel"),
|
||||
fields: [
|
||||
field({ icon: "i-cal", id: "title", label: "Shift title", value: v.title ?? "" }),
|
||||
field({ icon: "i-user", id: "assignee", label: "Assignee", value: v.assignee ?? "" }),
|
||||
field({ id: "start", label: "Start", type: "datetime-local", value: v.start ?? "" }),
|
||||
field({ id: "end", label: "End", type: "datetime-local", value: v.end ?? "" }),
|
||||
field({ icon: "i-cal", id: "title", label: t("scheduling.field.title"), value: v.title ?? "" }),
|
||||
field({ icon: "i-user", id: "assignee", label: t("scheduling.field.assignee"), value: v.assignee ?? "" }),
|
||||
field({ id: "start", label: t("scheduling.field.start"), type: "datetime-local", value: v.start ?? "" }),
|
||||
field({ id: "end", label: t("scheduling.field.end"), type: "datetime-local", value: v.end ?? "" }),
|
||||
],
|
||||
submitLabel: "Create shift",
|
||||
submitLabel: t("scheduling.form.submit"),
|
||||
},
|
||||
title: "New shift",
|
||||
title: t("scheduling.new.title"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,10 +165,10 @@ export function readInput(form: URLSearchParams): ShiftInput {
|
||||
|
||||
// Required-field validation → { field: message } or null. Kept deliberately small; the upstream
|
||||
// owns the real domain rules (overlap, capacity, …) and rejects with a 4xx the handler surfaces.
|
||||
export function validate(input: ShiftInput): Record<string, string> | null {
|
||||
export function validate(input: ShiftInput, t: Translate = EN): Record<string, string> | null {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!input.title) errors["title"] = "A shift needs a title.";
|
||||
if (!input.assignee) errors["assignee"] = "Assign the shift to someone.";
|
||||
if (!input.title) errors["title"] = t("scheduling.validation.title");
|
||||
if (!input.assignee) errors["assignee"] = t("scheduling.validation.assignee");
|
||||
return Object.keys(errors).length ? errors : null;
|
||||
}
|
||||
|
||||
@@ -173,16 +183,16 @@ export function listShifts(upstream: ShiftsUpstream): RouteHandler {
|
||||
shifts = await upstream.list();
|
||||
} catch (err) {
|
||||
ctx.log.warn("scheduling upstream unreachable", { error: String(err) }); // plugin logging via ctx.log
|
||||
error = "Couldn't reach the scheduling service — try again shortly.";
|
||||
error = ctx.t("scheduling.upstream.list");
|
||||
}
|
||||
const needle = q.toLowerCase();
|
||||
const rows = needle ? shifts.filter((s) => s.title.toLowerCase().includes(needle) || s.assignee.toLowerCase().includes(needle)) : shifts;
|
||||
return { data: buildListModel({ canWrite: can(ctx, WRITE), chrome: ctx.chrome, ...(error ? { error } : {}), q, shifts: rows }), view: "shifts" };
|
||||
return { data: buildListModel({ canWrite: can(ctx, WRITE), chrome: ctx.chrome, ...(error ? { error } : {}), q, shifts: rows, t: ctx.t }), view: "shifts" };
|
||||
};
|
||||
}
|
||||
|
||||
export function newShiftForm(): RouteHandler {
|
||||
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome }), view: "shift-new" });
|
||||
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" });
|
||||
}
|
||||
|
||||
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
|
||||
@@ -191,7 +201,14 @@ export function newShiftForm(): RouteHandler {
|
||||
// else a prompt to sign in. ctx.user may be null here, so read the permission via can() (zero I/O).
|
||||
export function overview(): RouteHandler {
|
||||
return (ctx) => ({
|
||||
data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" },
|
||||
data: {
|
||||
breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }],
|
||||
canRead: can(ctx, READ),
|
||||
chrome: ctx.chrome,
|
||||
shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links
|
||||
signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`),
|
||||
title: ctx.t("scheduling.overview.title"),
|
||||
},
|
||||
view: "overview",
|
||||
});
|
||||
}
|
||||
@@ -202,13 +219,13 @@ export function createShift(upstream: ShiftsUpstream): RouteHandler {
|
||||
// A write is a first-party form, so guard it with the host's double-submit token (ctx.verifyCsrf).
|
||||
if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) throw new GuardError(403, "invalid CSRF token");
|
||||
const input = readInput(form);
|
||||
const errors = validate(input);
|
||||
if (errors) return { data: buildFormModel({ chrome: ctx.chrome, errors, values: input }), status: 400, view: "shift-new" };
|
||||
const errors = validate(input, ctx.t);
|
||||
if (errors) return { data: buildFormModel({ chrome: ctx.chrome, errors, t: ctx.t, values: input }), status: 400, view: "shift-new" };
|
||||
try {
|
||||
await upstream.create(input);
|
||||
} catch (err) {
|
||||
ctx.log.warn("scheduling shift create failed (upstream)", { error: String(err) });
|
||||
return { data: buildFormModel({ chrome: ctx.chrome, formError: "Couldn't save the shift — the scheduling service is unavailable.", values: input }), status: 502, view: "shift-new" };
|
||||
return { data: buildFormModel({ chrome: ctx.chrome, formError: ctx.t("scheduling.upstream.create"), t: ctx.t, values: input }), status: 502, view: "shift-new" };
|
||||
}
|
||||
ctx.log.info("scheduling shift created", { assignee: input.assignee, title: input.title });
|
||||
return { redirect: SHIFTS_PATH }; // POST-redirect-GET
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for
|
||||
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
|
||||
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
|
||||
Data: chrome, title, breadcrumbs, canRead, shiftsHref
|
||||
Data: chrome, title, breadcrumbs, canRead, shiftsHref, signInHref
|
||||
%><%
|
||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
const cta = canRead
|
||||
? '<a class="btn btn-primary" href="' + shiftsHref + '">View shifts</a>'
|
||||
: '<a class="btn btn-primary" href="/login?return_to=' + encodeURIComponent(shiftsHref) + '">Sign in to view shifts</a>';
|
||||
? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>'
|
||||
: '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>';
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions: "",
|
||||
body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.</p>' + cta + '</div>',
|
||||
body: '<div class="scheduling-page"><p>' + t("scheduling.overview.lead") + '</p>' + cta + '</div>',
|
||||
brand: chrome.brand,
|
||||
breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%#
|
||||
A plugin's own partial (resolved before the core ones). The new-shift form body, reusing the core
|
||||
`partials/field` + `partials/alert`. Config: form { action, csrfToken, submitLabel, cancelHref,
|
||||
fields: field.ejs config[] }, formError?
|
||||
cancelLabel, fields: field.ejs config[] }, formError?
|
||||
%><%
|
||||
const form = locals.form;
|
||||
-%>
|
||||
@@ -15,7 +15,7 @@
|
||||
<%- include("partials/field", field) %>
|
||||
<% }) -%>
|
||||
<div class="form-actions">
|
||||
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
||||
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= form.cancelLabel %></a>
|
||||
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
service; this view renders them with the core building blocks inside the native app shell
|
||||
(ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table,
|
||||
alert) — see docs/plugin-contract.md. Zero-JS: search round-trips the URL.
|
||||
Data: chrome, title, breadcrumbs, filterBar, table, canWrite, newHref, error?
|
||||
Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error?
|
||||
%><%
|
||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
const filtersHtml = include("partials/filter-bar", filterBar);
|
||||
const tableHtml = include("partials/data-table", table);
|
||||
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
|
||||
const actions = canWrite
|
||||
? '<a class="btn btn-primary" href="' + newHref + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>New shift</a>'
|
||||
? '<a class="btn btn-primary" href="' + localeHref(newHref) + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("scheduling.shifts.new") + '</a>'
|
||||
: "";
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions,
|
||||
body: '<div class="scheduling-page">' + alertHtml + filtersHtml + tableHtml + '</div>',
|
||||
body: '<div class="scheduling-page">' + alertHtml + filtersHtml + '<p class="shift-count">' + count + '</p>' + tableHtml + '</div>',
|
||||
brand: chrome.brand,
|
||||
breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
|
||||
Reference in New Issue
Block a user