Bring the repo up to speed with our recent developments
Summary: Remove redundant files User login fixes Reviewers: #reviewers Subscribers: jenkins-user Differential Revision: https://hub.sealcode.org/D1253master
parent
9e3667398e
commit
651cd48220
@ -1,8 +1,8 @@
|
||||
{
|
||||
"phabricator.uri": "https://hub.sealcode.org/",
|
||||
"arc.land.onto.default": "hotwire",
|
||||
"arc.land.onto.default": "master",
|
||||
"load": ["arcanist-linters", "arc-unit-mocha/src"],
|
||||
"unit.engine": "MochaEngine",
|
||||
"unit.mocha.include": ["./lib/**/*.test.js"],
|
||||
"unit.mocha.dockerRoot": "/opt/sealious-playground"
|
||||
"unit.mocha.dockerRoot": "/opt/sealious-app"
|
||||
}
|
||||
|
@ -0,0 +1,44 @@
|
||||
{
|
||||
"connector": {
|
||||
"name": "jsdom"
|
||||
},
|
||||
"formatters": ["codeframe"],
|
||||
"hintsTimeout": 20000,
|
||||
"extends": ["web-recommended", "accessibility"],
|
||||
"hints": {
|
||||
"no-friendly-error-pages": "off",
|
||||
"no-broken-links": "error",
|
||||
"doctype": "error",
|
||||
"apple-touch-icons": "error",
|
||||
"button-type": "error",
|
||||
"compat-api/css": "error",
|
||||
"compat-api/html": "error",
|
||||
"create-element-svg": "error",
|
||||
"css-prefix-order": "error",
|
||||
"disown-opener": "error",
|
||||
"highest-available-document-mode": "error",
|
||||
"leading-dot-classlist": "error",
|
||||
"manifest-exists": "error",
|
||||
"meta-charset-utf-8": "error",
|
||||
"meta-viewport": "error",
|
||||
"no-bom": "error",
|
||||
"no-inline-styles": "error",
|
||||
"no-protocol-relative-urls": "error",
|
||||
"html-checker": "error",
|
||||
"scoped-svg-styles": "error",
|
||||
"sri": "error",
|
||||
"axe/aria": "error",
|
||||
"axe/color": "error",
|
||||
"axe/forms": "error",
|
||||
"axe/keyboard": "error",
|
||||
"axe/language": "error",
|
||||
"axe/name-role-value": "error",
|
||||
"axe/parsing": "error",
|
||||
"axe/semantics": "error",
|
||||
"axe/sensory-and-visual-cues": "error",
|
||||
"axe/structure": "error",
|
||||
"axe/tables": "error",
|
||||
"axe/text-alternatives": "error",
|
||||
"axe/time-and-media": "error"
|
||||
}
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
{
|
||||
"connector": "local",
|
||||
"extends": ["web-recommended", "accessibility"],
|
||||
"formatters": ["codeframe"],
|
||||
"hints": {
|
||||
"apple-touch-icons": "error",
|
||||
"button-type": "error",
|
||||
"compat-api/css": "error",
|
||||
"compat-api/html": "error",
|
||||
"create-element-svg": "error",
|
||||
"css-prefix-order": "error",
|
||||
"disown-opener": "error",
|
||||
"highest-available-document-mode": "error",
|
||||
"leading-dot-classlist": "error",
|
||||
"manifest-exists": "error",
|
||||
"meta-charset-utf-8": "off",
|
||||
"meta-viewport": "error",
|
||||
"no-bom": "error",
|
||||
"no-inline-styles": "error",
|
||||
"no-protocol-relative-urls": "error",
|
||||
"scoped-svg-styles": "error",
|
||||
"sri": "error",
|
||||
"axe/aria": "error",
|
||||
"axe/color": "error",
|
||||
"axe/forms": "error",
|
||||
"axe/keyboard": "error",
|
||||
"axe/language": "error",
|
||||
"axe/name-role-value": "error",
|
||||
"axe/parsing": "error",
|
||||
"axe/semantics": "error",
|
||||
"axe/sensory-and-visual-cues": "error",
|
||||
"axe/structure": "error",
|
||||
"axe/tables": "error",
|
||||
"axe/text-alternatives": "error",
|
||||
"axe/time-and-media": "error",
|
||||
"no-friendly-error-pages": "off",
|
||||
"content-type": "off",
|
||||
"http-cache": "off",
|
||||
"http-compression": "off",
|
||||
"no-disallowed-headers": "off",
|
||||
"no-html-only-headers": "off",
|
||||
"no-http-redirects": "off",
|
||||
"no-vulnerable-javascript-libraries": "off",
|
||||
"ssllabs": "off",
|
||||
"strict-transport-security": "off",
|
||||
"stylesheet-limits": "off",
|
||||
"validate-set-cookie-header": "off",
|
||||
"x-content-type-options": "off",
|
||||
"no-broken-links": "off",
|
||||
"typescript-config/consistent-casing": "off",
|
||||
"typescript-config/is-valid": "off",
|
||||
"typescript-config/strict": "off",
|
||||
"typescript-config/target": "off"
|
||||
},
|
||||
"hintsTimeout": 10000,
|
||||
"parsers": [
|
||||
"babel-config",
|
||||
"css",
|
||||
"html",
|
||||
"javascript",
|
||||
"jsx",
|
||||
"less",
|
||||
"sass",
|
||||
"typescript",
|
||||
"typescript-config",
|
||||
"webpack-config"
|
||||
]
|
||||
}
|
@ -1,23 +1,21 @@
|
||||
# Sealious playground
|
||||
# Sealious App
|
||||
|
||||
A simple todo app written in Sealious with a Hotwire-enhanced, server-side
|
||||
rendered front-end.
|
||||
|
||||
## Running
|
||||
## Installation
|
||||
|
||||
```
|
||||
docker-compose up -d db
|
||||
|
||||
npm install
|
||||
npm run watch
|
||||
./npm.sh install
|
||||
```
|
||||
|
||||
## Running on a custom port
|
||||
Always use ./npm.sh when installing dependencies.
|
||||
|
||||
## Running the app in development mode
|
||||
|
||||
```
|
||||
export SEALIOUS_PORT=8888
|
||||
export SEALIOUS_BASE_URL="https://888.dep.sealcode.org"
|
||||
npm run watch
|
||||
```
|
||||
|
||||
If you want Sealious to send emails to mailcatcher and not log them in the console, add `SEALIOUS_MAILER=mailcatcher`
|
||||
## Testing
|
||||
|
||||
```
|
||||
./npm.sh run test
|
||||
```
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,6 +0,0 @@
|
||||
describe("collections", () => {
|
||||
require("./password-reset-intents.subtest");
|
||||
require("./registration-intents.subtest");
|
||||
require("./user-roles.subtest");
|
||||
require("./users.subtest");
|
||||
});
|
@ -0,0 +1,26 @@
|
||||
// DO NOT EDIT! This file is generated automaticaly with 'npm run generate-collections'
|
||||
import { App } from "sealious";
|
||||
|
||||
import _GroupsToUsers from "./groups-to-users";
|
||||
import _Groups from "./groups";
|
||||
import _PasswordResetIntents from "./password-reset-intents";
|
||||
import _Secrets from "./secrets";
|
||||
import _UserRoles from "./user-roles";
|
||||
import _Users from "./users";
|
||||
|
||||
export const GroupsToUsers = new _GroupsToUsers();
|
||||
export const Groups = new _Groups();
|
||||
export const PasswordResetIntents = new _PasswordResetIntents();
|
||||
export const Secrets = new _Secrets();
|
||||
export const UserRoles = new _UserRoles();
|
||||
export const Users = new _Users();
|
||||
|
||||
export const collections = {
|
||||
...App.BaseCollections,
|
||||
"groups-to-users": GroupsToUsers,
|
||||
groups: Groups,
|
||||
"password-reset-intents": PasswordResetIntents,
|
||||
secrets: Secrets,
|
||||
"user-roles": UserRoles,
|
||||
users: Users,
|
||||
};
|
@ -0,0 +1,20 @@
|
||||
import { Collection, FieldTypes, Policies } from "sealious";
|
||||
import { Roles } from "../policy-types/roles";
|
||||
|
||||
export default class GroupsToUsers extends Collection {
|
||||
fields = {
|
||||
user: new FieldTypes.SingleReference("users"),
|
||||
group: new FieldTypes.SingleReference("groups"),
|
||||
};
|
||||
defaultPolicy = new Roles(["admin"]);
|
||||
policies = {
|
||||
show: new Policies.Or([
|
||||
new Roles(["admin"]),
|
||||
new Policies.UserReferencedInField("user"),
|
||||
]),
|
||||
list: new Policies.Or([
|
||||
new Roles(["admin"]),
|
||||
new Policies.UserReferencedInField("user"),
|
||||
]),
|
||||
};
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
import { Collection, FieldTypes, Policies } from "sealious";
|
||||
import { Roles } from "../policy-types/roles";
|
||||
|
||||
export default class Groups extends Collection {
|
||||
fields = {
|
||||
name: new FieldTypes.Text(),
|
||||
};
|
||||
defaultPolicy = new Policies.LoggedIn();
|
||||
policies = {
|
||||
create: new Roles(["admin"]),
|
||||
edit: new Roles(["admin"]),
|
||||
};
|
||||
|
||||
async populate(): Promise<void> {
|
||||
if (await this.app.Metadata.get("groups_populated")) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("### Populating groups");
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
await this.app.Metadata.set("groups_populated", "true");
|
||||
}
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
import axios from "axios";
|
||||
import assert from "assert";
|
||||
import { TestUtils, Policies } from "sealious";
|
||||
import { withProdApp } from "../test_utils/with-prod-app";
|
||||
|
||||
describe("registration-intents", () => {
|
||||
it("doesn't allow setting a role for registration intention when the user in context can't create user-roles", async () =>
|
||||
withProdApp(async ({ app, base_url }) => {
|
||||
app.collections["user-roles"].setPolicy("create", new Policies.Noone());
|
||||
await TestUtils.assertThrowsAsync(
|
||||
() =>
|
||||
axios.post(`${base_url}/api/v1/collections/registration-intents`, {
|
||||
email: "cunning@fox.com",
|
||||
role: "admin",
|
||||
}),
|
||||
(e: any) => {
|
||||
assert.equal(
|
||||
e.response.data.data.field_messages.role.message,
|
||||
app.i18n("policy_users_who_can_deny", [
|
||||
"create",
|
||||
"user-roles",
|
||||
app.i18n("policy_noone_deny"),
|
||||
])
|
||||
);
|
||||
}
|
||||
);
|
||||
}));
|
||||
|
||||
it("allows setting a role for registration intention when the user in context can create user-roles", async () =>
|
||||
withProdApp(async ({ app, base_url }) => {
|
||||
app.collections["user-roles"].setPolicy("create", new Policies.Public());
|
||||
const intent = (
|
||||
await axios.post(`${base_url}/api/v1/collections/registration-intents`, {
|
||||
email: "genuine@fox.com",
|
||||
role: "admin",
|
||||
})
|
||||
).data;
|
||||
assert.equal(intent.role, "admin");
|
||||
|
||||
const role = (
|
||||
await app.collections["registration-intents"].suGetByID(
|
||||
intent.id as string
|
||||
)
|
||||
).get("role");
|
||||
|
||||
assert.equal(role, "admin");
|
||||
}));
|
||||
});
|
@ -1,43 +0,0 @@
|
||||
import { App, Collection, FieldTypes, Policies } from "sealious";
|
||||
import RegistrationIntentTemplate from "../email-templates/registration-intent";
|
||||
|
||||
export default class RegistrationIntents extends Collection {
|
||||
fields = {
|
||||
email: new FieldTypes.ValueNotExistingInCollection({
|
||||
collection: "users",
|
||||
field: "email",
|
||||
include_forbidden: true,
|
||||
}),
|
||||
token: new FieldTypes.SecretToken(),
|
||||
role: new FieldTypes.SettableBy(
|
||||
new FieldTypes.Enum((app: App) => app.ConfigManager.get("roles")),
|
||||
new Policies.UsersWhoCan(["create", "user-roles"])
|
||||
),
|
||||
};
|
||||
|
||||
policies = {
|
||||
create: new Policies.Public(),
|
||||
edit: new Policies.Noone(),
|
||||
};
|
||||
|
||||
defaultPolicy = new Policies.Super();
|
||||
|
||||
async init(app: App, name: string) {
|
||||
await super.init(app, name);
|
||||
this.on("after:create", async ([context, intent]) => {
|
||||
await intent.decode(context);
|
||||
const {
|
||||
items: [item],
|
||||
} = await app.collections["registration-intents"]
|
||||
.suList()
|
||||
.ids([intent.id])
|
||||
.fetch();
|
||||
const token = item.get("token") as string;
|
||||
const message = await RegistrationIntentTemplate(app, {
|
||||
email_address: intent.get("email") as string,
|
||||
token,
|
||||
});
|
||||
await message.send(app);
|
||||
});
|
||||
}
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
import { Collection, FieldTypes, Policies } from "sealious";
|
||||
|
||||
export class Tasks extends Collection {
|
||||
fields = {
|
||||
title: new FieldTypes.Text(),
|
||||
done: new (class extends FieldTypes.Boolean {
|
||||
hasDefaultValue = () => true;
|
||||
async getDefaultValue() {
|
||||
return false;
|
||||
}
|
||||
})(),
|
||||
};
|
||||
defaultPolicy = new Policies.Public();
|
||||
}
|
||||
|
||||
export default new Tasks();
|
@ -1,20 +0,0 @@
|
||||
import assert from "assert";
|
||||
import { withProdApp } from "../test_utils/with-prod-app";
|
||||
|
||||
describe("users", () => {
|
||||
it.skip("should properly handle route to account creation", async () =>
|
||||
withProdApp(async ({ app, rest_api }) => {
|
||||
const sealious_response = await app.collections["registration-intents"]
|
||||
.suList()
|
||||
.filter({ email: app.manifest.admin_email })
|
||||
.fetch();
|
||||
|
||||
const { email, token } = sealious_response.items[0].serializeBody();
|
||||
const response = await rest_api.get(
|
||||
`/account-creation-details?token=${token as string}&email=${
|
||||
email as string
|
||||
}`
|
||||
);
|
||||
assert(response.includes("Please fill in the details of your account"));
|
||||
}));
|
||||
});
|
@ -0,0 +1,344 @@
|
||||
import { is, predicates } from "@sealcode/ts-predicates";
|
||||
import { BaseContext } from "koa";
|
||||
import { Templatable, tempstream } from "tempstream";
|
||||
import { ChekboxedListField, FormField, PickFromListField } from "./field";
|
||||
import Form, { FormData } from "./form";
|
||||
import { FormFieldsList } from "./form-fields-list";
|
||||
|
||||
export abstract class FormControl {
|
||||
abstract render(
|
||||
ctx: BaseContext,
|
||||
formFields: FormField[],
|
||||
data: FormData
|
||||
): Templatable | Promise<Templatable>;
|
||||
abstract role: "input" | "decoration" | "messages" | "submit";
|
||||
}
|
||||
|
||||
export class FormHeader extends FormControl {
|
||||
role = <const>"decoration";
|
||||
constructor(
|
||||
public text: string,
|
||||
public isVisible: (ctx: BaseContext) => Promise<boolean> = async () => true
|
||||
) {
|
||||
super();
|
||||
}
|
||||
async render(ctx: BaseContext) {
|
||||
const isVsbl = await this.isVisible(ctx);
|
||||
return isVsbl ? `<h2>${this.text}</h2>` : "";
|
||||
}
|
||||
}
|
||||
|
||||
export class FormParagraph extends FormControl {
|
||||
role = <const>"decoration";
|
||||
constructor(public text: string) {
|
||||
super();
|
||||
}
|
||||
render() {
|
||||
return `<p>${this.text}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class FormFieldControl extends FormControl {
|
||||
role = <const>"input";
|
||||
constructor(public fieldnames: string[]) {
|
||||
super();
|
||||
}
|
||||
|
||||
areFieldNamesValid(fields: FormField[]) {
|
||||
return this.fieldnames.every((fieldname) =>
|
||||
fields.some((f) => f.name == fieldname)
|
||||
);
|
||||
}
|
||||
|
||||
abstract _render(
|
||||
ctx: BaseContext,
|
||||
fields: FormField[],
|
||||
data: FormData
|
||||
): Templatable | Promise<Templatable>;
|
||||
|
||||
render(
|
||||
ctx: BaseContext,
|
||||
fields: FormField[],
|
||||
data: FormData
|
||||
): Templatable | Promise<Templatable> {
|
||||
if (!this.areFieldNamesValid(fields)) {
|
||||
throw new Error(
|
||||
`Invalid field names given to form control: "${this.fieldnames.join(
|
||||
", "
|
||||
)}". Allowed fields are: ${fields.map((f) => f.name).join(", ")}`
|
||||
);
|
||||
}
|
||||
return this._render(ctx, fields, data);
|
||||
}
|
||||
}
|
||||
|
||||
export class SimpleInput extends FormFieldControl {
|
||||
constructor(
|
||||
public fieldname: string,
|
||||
public options: {
|
||||
id?: string;
|
||||
label?: string;
|
||||
autocomplete?: boolean;
|
||||
type?:
|
||||
| "color"
|
||||
| "date"
|
||||
| "email"
|
||||
| "file"
|
||||
| "month"
|
||||
| "number"
|
||||
| "password"
|
||||
| "search"
|
||||
| "tel"
|
||||
| "text"
|
||||
| "time"
|
||||
| "url"
|
||||
| "week";
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
step?: number;
|
||||
} = {}
|
||||
) {
|
||||
super([fieldname]);
|
||||
}
|
||||
|
||||
_render(_: BaseContext, fields: FormField[], data: FormData) {
|
||||
const field = FormFieldsList.getField(fields, this.fieldname);
|
||||
if (!field) {
|
||||
throw new Error("wrong field name");
|
||||
}
|
||||
const id = this.options.id || field.name;
|
||||
const label = this.options.label || field.name;
|
||||
const type = this.options.type || "text";
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const value = data.values[field.name] as string;
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const raw_value = data.raw_values[field.name] as string;
|
||||
const placeholder = this.options.placeholder || type;
|
||||
const readonly = this.options.readonly || false;
|
||||
const required = field.required;
|
||||
const error = data.errors[field.name];
|
||||
return /* HTML */ `<div class="input">
|
||||
<label for="${id}">${label}</label>
|
||||
<input
|
||||
id="${id}"
|
||||
type="${type}"
|
||||
name="${field.name}"
|
||||
value="${value === undefined
|
||||
? raw_value == undefined
|
||||
? ""
|
||||
: raw_value
|
||||
: value}"
|
||||
placeholder="${placeholder}"
|
||||
${readonly ? "readonly" : ""}
|
||||
${required ? "required" : ""}
|
||||
${!this.options.autocomplete ? `autocomplete="off"` : ""}
|
||||
${this.options.step ? `step="${this.options.step}"` : ""}
|
||||
/>
|
||||
${error ? `<div class="input__error">${error}</div>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
export class Dropdown extends FormFieldControl {
|
||||
constructor(
|
||||
public fieldname: string,
|
||||
public options: {
|
||||
label: string;
|
||||
autosubmit?: boolean;
|
||||
autocomplete?: boolean;
|
||||
} = {
|
||||
label: fieldname,
|
||||
autosubmit: false,
|
||||
autocomplete: true,
|
||||
}
|
||||
) {
|
||||
super([fieldname]);
|
||||
}
|
||||
|
||||
areFieldNamesValid(fields: FormField[]) {
|
||||
return (
|
||||
super.areFieldNamesValid(fields) &&
|
||||
FormFieldsList.getField(fields, this.fieldname) instanceof PickFromListField
|
||||
);
|
||||
}
|
||||
|
||||
_render(ctx: BaseContext, fields: FormField[], data: FormData) {
|
||||
// safe to disable this as isValidFieldName takes care of checking if the field is of this type
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const field = FormFieldsList.getField(
|
||||
fields,
|
||||
this.fieldname
|
||||
) as PickFromListField;
|
||||
const picked_value = data.values[field.name] || "";
|
||||
const id = field.name;
|
||||
return tempstream/* HTML */ `<label for="${id}">${this.options.label}</label
|
||||
><select
|
||||
name="${this.fieldnames}"
|
||||
id="${id}"
|
||||
${this.options.autosubmit ? `onchange='this.form.submit()'` : ""}
|
||||
${!this.options.autocomplete ? `autocomplete="off"` : ""}
|
||||
>
|
||||
${Promise.resolve(field.generateOptions(ctx)).then((options) =>
|
||||
Object.entries(options).map(
|
||||
([value, text]) =>
|
||||
`<option value="${value}" ${
|
||||
(value || "") == picked_value ? "selected" : ""
|
||||
}>${text}</option>`
|
||||
)
|
||||
)}
|
||||
</select>`;
|
||||
}
|
||||
}
|
||||
|
||||
export class CheboxedListInput extends FormFieldControl {
|
||||
constructor(
|
||||
public fieldname: string,
|
||||
public options: { label: string } = { label: fieldname }
|
||||
) {
|
||||
super([fieldname]);
|
||||
}
|
||||
|
||||
isValidFieldName(form: Form) {
|
||||
return form.fields.some((f) => f.name == this.fieldname);
|
||||
}
|
||||
|
||||
async _render(ctx: BaseContext, fields: FormField[], data: FormData) {
|
||||
// safe to disable this as isValidFieldName takes care of checking if the field is of this type
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const field = FormFieldsList.getField(
|
||||
fields,
|
||||
this.fieldname
|
||||
) as ChekboxedListField;
|
||||
const pickedValues = data.values[field.name] || "";
|
||||
if (!is(pickedValues, predicates.array(predicates.string))) {
|
||||
throw new Error("picked values is not an array of strings");
|
||||
}
|
||||
const [options, isVisible] = await Promise.all([
|
||||
field.generateOptions(ctx),
|
||||
field.isVisible(ctx),
|
||||
]);
|
||||
return tempstream/* HTML */ `${isVisible
|
||||
? Object.entries(options).map(
|
||||
([value, text]) => /* HTML */ `<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="${field.name}.${value}"
|
||||
name="${field.name}.${value}"
|
||||
${pickedValues.includes(value) ? "checked" : ""}
|
||||
/>
|
||||
<label for="${field.name}.${value}">${text}</label>
|
||||
</div>`
|
||||
)
|
||||
: ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This class will render `turbo-frame` tag so that u can
|
||||
* embed other route inside your form. This will require
|
||||
* to add value to `data: FormData` (inside your master form
|
||||
* render function) with key `Frame.FRAME_PATH_KEY`. Value
|
||||
* needs to be url to route that you want to embed. If you
|
||||
* this value wont be provided frame will redner empty string.
|
||||
* See `src/back/routes/profile/[id].form.ts` for an example.
|
||||
*/
|
||||
export class Frame extends FormControl {
|
||||
constructor(public src: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
render(): Templatable | Promise<Templatable> {
|
||||
return /* HTML */ `<turbo-frame
|
||||
id="contrahents"
|
||||
loading="lazy"
|
||||
src="${this.src}"
|
||||
></turbo-frame>`;
|
||||
}
|
||||
role = <const>"decoration";
|
||||
}
|
||||
|
||||
/**
|
||||
* This control has own forms in it so if you want to use it you
|
||||
* probably shouldn't use `await super.render(ctx, data, path)` in
|
||||
* render method of you from implementation and you should write
|
||||
* your own implementation of this method. See forms that uses
|
||||
* this control for reference.
|
||||
*/
|
||||
export class EditableCollectionSubset extends FormFieldControl {
|
||||
constructor(
|
||||
public fieldname: string,
|
||||
public actionname: string,
|
||||
public listLabel?: string,
|
||||
public selectLabel?: string
|
||||
) {
|
||||
super([fieldname]);
|
||||
}
|
||||
|
||||
_render(
|
||||
ctx: BaseContext,
|
||||
fields: FormField[],
|
||||
data: FormData<string>
|
||||
): Templatable | Promise<Templatable> {
|
||||
// safe to disable this as isValidFieldName takes care of checking if the field is of this type
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const field = FormFieldsList.getField(
|
||||
fields,
|
||||
this.fieldname
|
||||
) as PickFromListField;
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const values = data.values[this.fieldname] as string[];
|
||||
|
||||
return tempstream/* HTML */ `<div>
|
||||
<ul>
|
||||
${Promise.resolve(field.generateOptions(ctx)).then((options) =>
|
||||
Object.entries(options)
|
||||
.filter(([value]) => values.includes(value))
|
||||
.map(
|
||||
([value, text]) => /* HTML */ `
|
||||
<li>
|
||||
<form method="POST" action="${ctx.path}">
|
||||
<span>${text}</span>
|
||||
<input
|
||||
type="hidden"
|
||||
name="${this.fieldname}"
|
||||
value="${value}"
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="${this.actionname}"
|
||||
value="list"
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
${this.listLabel
|
||||
? `value="${this.listLabel}"`
|
||||
: ""}
|
||||
/>
|
||||
</form>
|
||||
</li>
|
||||
`
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
<form method="POST" action="${ctx.path}">
|
||||
<select name="${this.fieldname}">
|
||||
${Promise.resolve(field.generateOptions(ctx)).then((options) =>
|
||||
Object.entries(options)
|
||||
.filter(([value]) => !values.includes(value))
|
||||
.map(
|
||||
([value, text]) =>
|
||||
`<option value="${value}">${text}</option>`
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
<input type="hidden" name="${this.actionname}" value="select" />
|
||||
<input
|
||||
type="submit"
|
||||
${this.selectLabel ? `value="${this.selectLabel}"` : ""}
|
||||
/>
|
||||
</form>
|
||||
</div>`;
|
||||
}
|
||||
role = <const>"input";
|
||||
}
|
@ -0,0 +1,119 @@
|
||||
import { is, predicates } from "@sealcode/ts-predicates";
|
||||
import { BaseContext } from "koa";
|
||||
|
||||
export type FormFieldValidationResponse = { valid: boolean; message: string };
|
||||
|
||||
export type FormFieldValidationFn = (
|
||||
ctx: BaseContext,
|
||||
value: unknown,
|
||||
field: FormField
|
||||
) => Promise<FormFieldValidationResponse>;
|
||||
|
||||
export class FormField<Fieldnames extends string = string> {
|
||||
constructor(
|
||||
public name: Fieldnames,
|
||||
public required: boolean = false,
|
||||
public validator: FormFieldValidationFn = async () => ({
|
||||
valid: true,
|
||||
message: "",
|
||||
})
|
||||
) {}
|
||||
|
||||
public async _validate(
|
||||
ctx: BaseContext,
|
||||
value: unknown
|
||||
): Promise<FormFieldValidationResponse> {
|
||||
if (this.required && (value == "" || value == null || value == undefined)) {
|
||||
return { valid: false, message: "This field is required" };
|
||||
}
|
||||
return this.validator(ctx, value, this);
|
||||
}
|
||||
|
||||
public getEmptyValue() {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export class PickFromListField<
|
||||
Fieldnames extends string = string
|
||||
> extends FormField<Fieldnames> {
|
||||
constructor(
|
||||
public name: Fieldnames,
|
||||
public required: boolean = false,
|
||||
public generateOptions: (
|
||||
ctx: BaseContext
|
||||
) => Promise<Record<string, string> | { [i: string]: string }>,
|
||||
public customValidation: (
|
||||
ctx: BaseContext,
|
||||
value: unknown,
|
||||
instance: PickFromListField
|
||||
) => Promise<FormFieldValidationResponse> = (ctx, value, instance) =>
|
||||
instance.valueInList(ctx, value)
|
||||
) {
|
||||
super(name, required, (ctx, value) => this.customValidation(ctx, value, this));
|
||||
}
|
||||
|
||||
async valueInList(
|
||||
ctx: BaseContext,
|
||||
value: unknown
|
||||
): Promise<FormFieldValidationResponse> {
|
||||
const options = await this.generateOptions(ctx);
|
||||
if (!is(value, predicates.string)) {
|
||||
return { valid: false, message: "not a string" };
|
||||
}
|
||||
if (!Object.keys(options).includes(value)) {
|
||||
return { valid: false, message: `"${value}" is not one of the options` };
|
||||
}
|
||||
return { valid: true, message: "" };
|
||||
}
|
||||
}
|
||||
|
||||
export class ChekboxedListField<
|
||||
Fieldnames extends string = string
|
||||
> extends FormField<Fieldnames> {
|
||||
constructor(
|
||||
public name: Fieldnames,
|
||||
public required: boolean = false,
|
||||
public generateOptions: (
|
||||
ctx: BaseContext
|
||||
) => Promise<Record<string, string> | { [i: string]: string }>,
|
||||
public isVisible: (ctx: BaseContext) => Promise<boolean> = () =>
|
||||
Promise.resolve(true)
|
||||
) {
|
||||
super(name, required, (ctx, value) => this.isValueValid(ctx, value));
|
||||
}
|
||||
|
||||
private async isValueValid(
|
||||
_: BaseContext,
|
||||
value: unknown
|
||||
): Promise<FormFieldValidationResponse> {
|
||||
if (is(value, predicates.string)) {
|
||||
return { valid: false, message: "you need an array" };
|
||||
}
|
||||
if (is(value, predicates.null)) {
|
||||
return { valid: false, message: "you need an array" };
|
||||
}
|
||||
return { valid: true, message: "" };
|
||||
}
|
||||
}
|
||||
|
||||
export class NumberField<
|
||||
Fieldnames extends string = string
|
||||
> extends FormField<Fieldnames> {
|
||||
constructor(field_name: Fieldnames, required: boolean) {
|
||||
super(field_name, required, (_, value) => this.isValueValid(_, value));
|
||||
}
|
||||
|
||||
private async isValueValid(_: BaseContext, value: unknown) {
|
||||
if (
|
||||
(is(value, predicates.string) &&
|
||||
!isNaN(parseFloat(value)) &&
|
||||
parseFloat(value).toString() == value.trim()) ||
|
||||
is(value, predicates.number) ||
|
||||
((is(value, predicates.undefined) || value == "") && !this.required)
|
||||