Skip to content

Commit 00224a4

Browse files
committed
save, most features implemented
- externalized store with some optimizations - externalized plugins - working on making it extensible via javascript - custom html with injected plugins - read and admin permissions still need to do a few things - server input validation: all the pieces are made, I just need to wire it up - make commands easier to extend, probably just by making it easier to specify a list of commands via javascript. - clean up my spaghetti code into nice straight rows of pasta.
1 parent f4d843f commit 00224a4

34 files changed

Lines changed: 1369 additions & 925 deletions

packages/admin-vanilla/src/app-profile.tsx

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const profileFields = [
2323
{
2424
key: "roles",
2525
label: "Roles",
26-
type: "permission-table",
26+
type: "search-multiselect",
2727
mode: "server"
2828
}
2929
] as const satisfies FieldDefinition[];
@@ -51,9 +51,7 @@ const updatePasswordFields = [
5151
] as const satisfies FieldDefinition[];
5252

5353

54-
55-
type ProfileDraft = Drafter<typeof profileFields>;
56-
type UpdatePasswordDraft = Drafter<typeof updatePasswordFields>;
54+
type ProfileDraft = Drafter<typeof profileFields> & Drafter<typeof updatePasswordFields>;
5755

5856
type LoginServerState = {
5957
csrfToken: string;
@@ -63,34 +61,39 @@ type LoginServerState = {
6361
const renderCallout = (content: JSX.Node) => <div class="field-callout"><p>{content}</p></div>;
6462

6563
@addstyles(css)
66-
@customElement("mws-login-form")
67-
export class LoginForm extends JSXElement {
64+
@customElement("mws-profile-form")
65+
export class ProfileForm extends JSXElement {
6866
// AGENTS: When the user edits this class directly, treat those changes as intentional and
6967
// important signals about desired behavior unless they explicitly ask to remove them.
7068
useLightDOM: boolean = true;
7169
constructor() { super(); }
7270

73-
control: FomController<UpdatePasswordDraft> = new FomController<UpdatePasswordDraft>(this);
71+
control: FomController<ProfileDraft> = new FomController<ProfileDraft>(this);
72+
73+
handleAnySubmit: FomController<ProfileDraft>["handleAnySubmit"] = (...args) => this.control.handleAnySubmit(...args)
7474

75-
handleAnySubmit: FomController<UpdatePasswordDraft>["handleAnySubmit"] = (...args) => this.control.handleAnySubmit(...args)
75+
onCancel = async () => {
76+
location.pathname = pathPrefix + "/";
77+
}
7678

79+
@state() accessor props!: {}
7780

7881
// #region state
79-
@state() accessor draft: UpdatePasswordDraft = this.createDraft();
82+
@state() accessor draft: ProfileDraft = this.createDraft();
8083

81-
@state() private accessor mode: "profile" | "updatePassword" = "updatePassword";
84+
@state() private accessor mode: "profile" | "updatePassword" = "profile";
8285
@state() private accessor isSubmitting: boolean = false;
83-
@state() private accessor isResolvingServerState: boolean = false;
84-
@state() private accessor rememberMe: boolean = false;
85-
@state() private accessor serverState: LoginServerState | null = null;
8686
@state() private accessor submitMessage: string = new URLSearchParams(globalThis.location?.search ?? "").get("state") === "password-changed"
8787
? "Password updated. You can now log in."
8888
: "";
8989

9090

91-
private createDraft(): UpdatePasswordDraft {
91+
private createDraft(): ProfileDraft {
9292
return {
9393
id: new IdString(""),
94+
email: "",
95+
username: "",
96+
roles: [],
9497
password: "",
9598
newPassword: "",
9699
confirmNewPassword: "",
@@ -99,7 +102,24 @@ export class LoginForm extends JSXElement {
99102

100103
protected render() {
101104
switch (this.mode) {
102-
// #region login
105+
case "profile": {
106+
107+
return this.control.renderCommon({
108+
title: "User Profile",
109+
copy: "View your profile.",
110+
submitAction: this.handleProfileSubmit,
111+
submitDisabled: false,
112+
submitLabel: "Update Password",
113+
handleBackClick: this.onCancel,
114+
}, <>
115+
<div class="login-fields">
116+
{profileFields.map((field) => this.control.renderField(field))}
117+
</div>
118+
</>);
119+
120+
} break;
121+
122+
103123
case "updatePassword": {
104124

105125
const updatePasswordActionDisabled = this.isSubmitting
@@ -113,8 +133,7 @@ export class LoginForm extends JSXElement {
113133
submitAction: this.handleUpdatePasswordSubmit,
114134
submitDisabled: updatePasswordActionDisabled,
115135
submitLabel: "Update password",
116-
isStart: true,
117-
handleBackClick: async () => { },
136+
handleBackClick: async () => { this.mode = "profile"; },
118137
}, <>
119138
<div class="login-fields">
120139
{updatePasswordFields.map((field) => this.control.renderField(field))}
@@ -126,6 +145,17 @@ export class LoginForm extends JSXElement {
126145

127146
}
128147

148+
private readonly handleProfileSubmit = async () => {
149+
await this.handleAnySubmit(
150+
"Update password",
151+
"",
152+
async () => {
153+
this.mode = "updatePassword";
154+
return true;
155+
}
156+
);
157+
}
158+
129159
private readonly handleUpdatePasswordSubmit = async (): Promise<void> => {
130160
const newPassword = this.draft.newPassword;
131161
const confirmNewPassword = this.draft.confirmNewPassword;

packages/admin-vanilla/src/app.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
getSectionHeading,
2323
TemplateTypes,
2424
IdString,
25-
KeyString,
25+
2626
KeyFields
2727
} from "./definition/tabs";
2828

@@ -346,7 +346,7 @@ class PerTabStoreImpl implements PerTabStore {
346346
if (!modalState) return;
347347

348348
if (fieldKey === KeyFields[modalState.tabId] && typeof value === "string") {
349-
value = new KeyString(value);
349+
value = value;
350350
}
351351

352352
const nextDraft = { ...modalState.draft } as typeof modalState.draft;
@@ -902,7 +902,7 @@ export class App extends JSXElement {
902902
type="button"
903903
role="menuitem"
904904
onclick={() => {
905-
// TODO: implement profile action.
905+
location.pathname = pathPrefix + "/profile";
906906
}}
907907
>
908908
Profile
@@ -1018,6 +1018,7 @@ export class App extends JSXElement {
10181018
}
10191019
}
10201020

1021+
// #region table stuff
10211022

10221023
function renderListCellValue(columnKey: string, value: string | undefined) {
10231024
const formattedValue = formatFieldValue(value);
@@ -1045,7 +1046,7 @@ function getListColumnLinkMappers(tabId: TabId): Partial<Record<string, ListColu
10451046
return {
10461047
slug: (item) => {
10471048
definitely<WikiAdminRecord>(item);
1048-
return item.slug ? `${pathPrefix}/wiki/${encodeURIComponent(item.slug.toString())}` : null;
1049+
return item.slug ? `${pathPrefix}/wiki/${encodeURIComponent(item.slug)}` : null;
10491050
},
10501051
};
10511052
case "templates":

packages/admin-vanilla/src/definition/renders.tsx

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { DraftChangeHandler, OperationTriggerHandler, PendingRowsChangeHandler, PermissionRowsChangeHandler, PerTabFieldState, ResolverTitleChangeHandler } from "../app";
22
import { MaterialSymbol } from "../material-symbol";
3-
import { AdminRecordStore, FieldDefinition, FieldType, IdString, KeyString, PermissionRow, WikiAdminRecord, WritablePrefixRow } from "./tabs";
3+
import { AdminRecordStore, FieldDefinition, FieldType, IdString, PermissionRow, WikiAdminRecord, WritablePrefixRow } from "./tabs";
44
import { definitely, is } from "./utils";
55
import warningIcon from "@material-symbols/svg-400/outlined/warning.svg";
66
import { findTemplateRecordForWikiRecord, jsonReviver } from "./store";
@@ -55,25 +55,25 @@ function buildEffectivePrefixObject(writablePrefixBags: (readonly WritablePrefix
5555
}
5656
}
5757
return Object.entries(result)
58-
.map(([prefix, bagName]) => ({ prefix, bagName: new KeyString(bagName) }))
58+
.map(([prefix, bagName]) => ({ prefix, bagName }))
5959
.sort((a, b) => b.prefix.length - a.prefix.length);
6060
}
6161

6262

6363
function getLookupOptions(fieldKey: string, itemsByTab: AdminRecordStore): string[] {
6464
if (fieldKey === "readonlyBags" || fieldKey === "writablePrefixBags") {
65-
return Array.from(new Set(itemsByTab.bags.map((item) => item.name.toString()).filter(Boolean)));
65+
return Array.from(new Set(itemsByTab.bags.map((item) => item.name).filter(Boolean)));
6666
}
6767
if (fieldKey === "plugins") {
68-
return Array.from(new Set(itemsByTab.plugins.map((item) => item.name.toString()).filter(Boolean)));
68+
return Array.from(new Set(itemsByTab.plugins.map((item) => item.name).filter(Boolean)));
6969
}
7070
if (fieldKey === "userRoles") {
71-
return Array.from(new Set(itemsByTab.roles.map((item) => item.name.toString()).filter(Boolean)));
71+
return Array.from(new Set(itemsByTab.roles.map((item) => item.name).filter(Boolean)));
7272
}
7373
if (fieldKey === "permissions" || fieldKey === "recipePermissions") {
7474
return Array.from(new Set([
75-
...itemsByTab.bags.flatMap((item) => item.bagPermissions.map((row) => row.role.toString())),
76-
...itemsByTab.wikis.flatMap((item) => item.recipePermissions.map((row) => row.role.toString())),
75+
...itemsByTab.bags.flatMap((item) => item.bagPermissions.map((row) => row.role)),
76+
...itemsByTab.wikis.flatMap((item) => item.recipePermissions.map((row) => row.role)),
7777
].filter(Boolean)));
7878
}
7979
return [];
@@ -82,7 +82,7 @@ function getLookupOptions(fieldKey: string, itemsByTab: AdminRecordStore): strin
8282
export function formatFieldValue(value: any): string {
8383
if (typeof value === "string"
8484
|| value instanceof IdString
85-
|| value instanceof KeyString)
85+
)
8686
return value.trim() || "—";
8787
if (Array.isArray(value)) {
8888
if (!value.length) return "—";
@@ -186,7 +186,7 @@ function renderTableField(ctx: ReadonlyFieldContext) {
186186
function renderLinesList(value: readonly string[], key: string, itemsByTab?: AdminRecordStore) {
187187
const missingCheck =
188188
itemsByTab ?
189-
(key === "effectiveReadonlyBags" || key === "readonlyBags") ? new Set(Array.from(itemsByTab.availableBagNames, e => e.toString())) :
189+
(key === "effectiveReadonlyBags" || key === "readonlyBags") ? new Set(Array.from(itemsByTab.availableBagNames)) :
190190
(key === "effectivePluginSet" || key === "plugins") ? itemsByTab.availablePluginNames :
191191
null : null;
192192
const lines = value.map(line => ({ line, missing: missingCheck && !missingCheck.has(line), }));
@@ -320,7 +320,7 @@ function renderSearchMultiselectFieldEditor(ctx: FieldEditorContext<any>) {
320320
<div class="field-callout">
321321
<p>Readonly bags from template</p>
322322
<ul class="value-list">
323-
{templateReadonlyBagLines.length ? templateReadonlyBagLines.map((bag) => <li>{bag.toString()}</li>) : <li>No template readonly bags</li>}
323+
{templateReadonlyBagLines.length ? templateReadonlyBagLines.map((bag) => <li>{bag}</li>) : <li>No template readonly bags</li>}
324324
</ul>
325325
</div>
326326
) : null}
@@ -337,16 +337,33 @@ function renderSearchMultiselectFieldEditor(ctx: FieldEditorContext<any>) {
337337
);
338338
}
339339

340-
function renderPermissionTableFieldEditor(ctx: FieldEditorContext<any>) {
340+
function renderPermissionTableFieldViewer(ctx: ReadonlyFieldContext<readonly PermissionRow[]>) {
341+
const permissionRows = ctx.value;
342+
if (!permissionRows.length) {
343+
return <div class="field-callout"><p>No permissions assigned.</p></div>;
344+
}
345+
return <table class="value-table">
346+
{permissionRows.map((row) => (
347+
<tr>
348+
<td>{row.role || "—"}</td>
349+
<td><span class="pill-value pill-value-small">{formatPermissionLevel(row.level)}</span></td>
350+
</tr>
351+
))}
352+
</table>;
353+
}
354+
355+
function renderPermissionTableFieldEditor(ctx: FieldEditorContext<readonly PermissionRow[]>) {
341356
const { field, disabled, fieldState, itemsByTab, inputId, onDraftChange, onTransientPermissionRowsChange } = ctx;
342-
definitely<readonly PermissionRow[]>(ctx.value);
357+
if (field.mode === "server" || field.mode === "") {
358+
return renderPermissionTableFieldViewer(ctx);
359+
}
343360
const permissionRows = ctx.value;
344361
const lookupOptions = getLookupOptions(field.key, itemsByTab);
345362
const availableLevels = getPermissionLevelsForField(field.key);
346363
const transientPermissionRows = fieldState.transientPermissionRows[field.key] ?? [];
347364
const displayedPermissionRows = permissionRows.length || transientPermissionRows.length
348365
? [...permissionRows, ...transientPermissionRows]
349-
: [{ role: new KeyString(""), level: availableLevels[0] as PermissionLevel }];
366+
: [{ role: "", level: availableLevels[0] as PermissionLevel }];
350367

351368
const persistPermissionRows = (rows: PermissionRow[]) => {
352369
const persistedRows = rows.filter((row) => row.role.trim());
@@ -361,13 +378,13 @@ function renderPermissionTableFieldEditor(ctx: FieldEditorContext<any>) {
361378
<div key={`${field.key}-permission-${index}`} class="row-editor-row row-editor-row-wide row-editor-row-permission">
362379
{renderSearchableInput({
363380
id: `${inputId}-${index}-role`,
364-
currentValue: row.role.toString(),
381+
currentValue: row.role,
365382
placeholder: "Role",
366383
options: lookupOptions,
367384
disabled: ctx.disabled,
368385
onInput: (nextValue) => {
369386
const nextRows = [...displayedPermissionRows];
370-
nextRows[index] = { ...row, role: new KeyString(nextValue) };
387+
nextRows[index] = { ...row, role: nextValue };
371388
persistPermissionRows(nextRows);
372389
},
373390
})}
@@ -388,7 +405,7 @@ function renderPermissionTableFieldEditor(ctx: FieldEditorContext<any>) {
388405
))}
389406
<button type="button" class="ghost-button" disabled={disabled} onclick={() =>
390407
onTransientPermissionRowsChange(field.key, [...transientPermissionRows, {
391-
role: new KeyString(""), level: availableLevels[0] as PermissionLevel
408+
role: "", level: availableLevels[0] as PermissionLevel
392409
}])}>Add permission</button>
393410
</div>
394411
);
@@ -405,7 +422,7 @@ function renderPrefixMappingRows(displayedMappingRows: readonly WritablePrefixRo
405422
{displayedMappingRows.map((row) => (
406423
<tr>
407424
<td>{row.prefix ? <code>{'"' + row.prefix + '"'}</code> : defaultPrefixPill}</td>
408-
<td>{row.bagName.toString()}</td>
425+
<td>{row.bagName}</td>
409426
</tr>
410427
))}
411428
</table>;
@@ -416,7 +433,7 @@ function renderPrefixTableFieldSidebar(ctx: ReadonlyFieldContext<any>): JSX.Node
416433
return <dl class="prefix-bag-sidebar">
417434
{ctx.value.map((entry) => <>
418435
<dt class="prefix-bag-sidebar-term">{entry.prefix ? <span class="prefix-bag-sidebar-prefix">"{entry.prefix}"</span> : defaultPrefixPill}</dt>
419-
<dd class="prefix-bag-sidebar-value">{entry.bagName.toString()}</dd>
436+
<dd class="prefix-bag-sidebar-value">{entry.bagName}</dd>
420437
</>)}
421438
</dl>;
422439
}
@@ -429,8 +446,8 @@ function renderPrefixTableFieldEditor(ctx: FieldEditorContext<any>) {
429446
const pendingRowCount = fieldState.pendingRows[field.key] ?? 0;
430447
const lookupOptions = getLookupOptions(field.key, itemsByTab);
431448
const displayedMappingRows = mappingRows.length
432-
? [...mappingRows, ...Array.from({ length: pendingRowCount }, () => ({ prefix: "", bagName: new KeyString("") }))]
433-
: [{ prefix: "", bagName: new KeyString("") }, ...Array.from({ length: pendingRowCount }, () => ({ prefix: "", bagName: new KeyString("") }))];
449+
? [...mappingRows, ...Array.from({ length: pendingRowCount }, () => ({ prefix: "", bagName: "" }))]
450+
: [{ prefix: "", bagName: "" }, ...Array.from({ length: pendingRowCount }, () => ({ prefix: "", bagName: "" }))];
434451
const templateRecord = is<WikiAdminRecord>(fieldState.draft, fieldState.tabId === "wikis")
435452
? findTemplateRecordForWikiRecord(fieldState.draft, itemsByTab) : undefined;
436453
const inheritedRoutingRows = fieldState.tabId === "wikis" && templateRecord ? templateRecord.writablePrefixBags : [];
@@ -468,7 +485,7 @@ function renderPrefixTableFieldEditor(ctx: FieldEditorContext<any>) {
468485
</div>
469486
{renderSearchableInput({
470487
id: `${inputId}-${index}-target`,
471-
currentValue: row.bagName.toString(),
488+
currentValue: row.bagName,
472489
placeholder: "Target bag",
473490
options: lookupOptions,
474491
disabled: ctx.disabled,
@@ -525,7 +542,7 @@ function renderAutocompleteFieldEditor(ctx: FieldEditorContext<any>) {
525542
}}
526543
/>
527544
<datalist id={datalistId}>
528-
{Array.from(optionMap.keys(), (option) => <option value={option.toString()} />)}
545+
{Array.from(optionMap.keys(), (option) => <option value={option} />)}
529546
</datalist>
530547
</>
531548
);
@@ -539,7 +556,7 @@ function computeResolverPreview(draft: WikiAdminRecord, title: string) {
539556
: undefined;
540557
return {
541558
title: normalizedTitle,
542-
writeTo: writeTarget?.bagName.toString() ?? "No writable target",
559+
writeTo: writeTarget?.bagName ?? "No writable target",
543560
matchedPrefix: writeTarget ? (writeTarget.prefix || "default") : "none",
544561
};
545562
}
@@ -641,7 +658,7 @@ export const fieldTypeRenderSidebars = {
641658
"enter-password": () => null,
642659
"confirm-password": () => null,
643660
"search-multiselect": renderSearchMultiselectFieldSidebar,
644-
"permission-table": () => null,
661+
"permission-table": renderPermissionTableFieldViewer,
645662
"prefix-table": renderPrefixTableFieldSidebar,
646663
"select": () => null,
647664
"search": renderAutocompleteFieldSidebar,

0 commit comments

Comments
 (0)