Update to newest Sealgen
This commit is contained in:
parent
5261a3140d
commit
cd7bd7e7d3
1
.gitignore
vendored
1
.gitignore
vendored
@ -26,3 +26,4 @@ public/dist
|
|||||||
/log.html
|
/log.html
|
||||||
/hint-report/
|
/hint-report/
|
||||||
.vscode
|
.vscode
|
||||||
|
.env
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# Sealious App
|
# Sealious app
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@ -16,6 +16,8 @@ Always use ./npm.sh when installing dependencies.
|
|||||||
## Running the app in development mode
|
## Running the app in development mode
|
||||||
|
|
||||||
```
|
```
|
||||||
|
npx sealgen make-env
|
||||||
|
docker-compose up -d
|
||||||
npm run watch
|
npm run watch
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
FROM node:18-bullseye-slim
|
FROM node:18-bullseye-slim
|
||||||
|
|
||||||
ENV UID=node \
|
ENV HOME=/opt/sealious-app
|
||||||
GID=node \
|
|
||||||
HOME=/opt/sealious-app
|
|
||||||
|
|
||||||
# Tini will ensure that any orphaned processes get reaped properly.
|
# Tini will ensure that any orphaned processes get reaped properly.
|
||||||
ENV TINI_VERSION v0.19.0
|
ENV TINI_VERSION v0.19.0
|
||||||
@ -17,6 +15,8 @@ ENTRYPOINT ["/tini", "--"]
|
|||||||
VOLUME $HOME
|
VOLUME $HOME
|
||||||
WORKDIR $HOME
|
WORKDIR $HOME
|
||||||
|
|
||||||
|
RUN npm install -g npm@latest
|
||||||
|
|
||||||
USER $UID:$GID
|
USER $UID:$GID
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
75
esbuild.js
75
esbuild.js
@ -1,75 +0,0 @@
|
|||||||
const { build } = require("esbuild");
|
|
||||||
const { spawn } = require("child_process");
|
|
||||||
const { sassPlugin } = require("esbuild-sass-plugin");
|
|
||||||
const glob = require("tiny-glob");
|
|
||||||
const chokidar = require("chokidar");
|
|
||||||
|
|
||||||
const watch = process.argv.includes("--watch");
|
|
||||||
|
|
||||||
async function build_scss(watch) {
|
|
||||||
let scss_build;
|
|
||||||
if (watch) {
|
|
||||||
const scss_watcher = chokidar.watch("src", { ignoreInitial: true });
|
|
||||||
scss_watcher.on("all", (_, path) => {
|
|
||||||
if (!scss_build) return;
|
|
||||||
if (path.endsWith(".scss") && !path.endsWith("/includes.scss")) {
|
|
||||||
// refresh the list of all scss files in includes.scss
|
|
||||||
spawn("./node_modules/.bin/sealgen", ["generate-scss-includes"]).on(
|
|
||||||
"close",
|
|
||||||
() => {
|
|
||||||
try {
|
|
||||||
scss_build.rebuild();
|
|
||||||
console.log(`Built main.scss [on ${path}]`);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setTimeout(() => {
|
|
||||||
scss_build
|
|
||||||
.rebuild()
|
|
||||||
.catch((e) => conslole.error(e.message));
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
scss_build = await build({
|
|
||||||
entryPoints: ["./src/main.scss"],
|
|
||||||
sourcemap: true,
|
|
||||||
outfile: "./public/dist/style.css",
|
|
||||||
logLevel: "info",
|
|
||||||
incremental: watch,
|
|
||||||
plugins: [sassPlugin()],
|
|
||||||
});
|
|
||||||
scss_build.rebuild();
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const entryPoints = await glob("./src/back/**/*.ts");
|
|
||||||
build({
|
|
||||||
entryPoints,
|
|
||||||
sourcemap: true,
|
|
||||||
outdir: "./dist/back",
|
|
||||||
logLevel: "info",
|
|
||||||
platform: "node",
|
|
||||||
watch,
|
|
||||||
target: "node16",
|
|
||||||
format: "cjs",
|
|
||||||
});
|
|
||||||
|
|
||||||
build({
|
|
||||||
entryPoints: ["./src/front/index.ts"],
|
|
||||||
sourcemap: true,
|
|
||||||
outfile: "./public/dist/bundle.js",
|
|
||||||
logLevel: "info",
|
|
||||||
bundle: true,
|
|
||||||
watch,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await build_scss(watch);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e.message);
|
|
||||||
}
|
|
||||||
})();
|
|
@ -1,7 +1,9 @@
|
|||||||
#!/bin/bash -xe
|
#!/bin/bash -xe
|
||||||
|
set -e
|
||||||
|
|
||||||
docker-compose down
|
docker-compose down
|
||||||
|
|
||||||
|
./make-env.sh
|
||||||
cp secrets.example.json secrets.json
|
cp secrets.example.json secrets.json
|
||||||
|
|
||||||
export SEALIOUS_PORT="${PORT}0"
|
export SEALIOUS_PORT="${PORT}0"
|
||||||
@ -11,12 +13,14 @@ export SEALIOUS_BASE_URL
|
|||||||
# Create the npm cache directory if it isn't present yet. If it is not present, it will be created
|
# Create the npm cache directory if it isn't present yet. If it is not present, it will be created
|
||||||
# when the docker image is being built with root:root as the owner.
|
# when the docker image is being built with root:root as the owner.
|
||||||
mkdir -p ~/.npm_cacache
|
mkdir -p ~/.npm_cacache
|
||||||
|
|
||||||
|
# https://github.com/docker/compose/issues/4725
|
||||||
docker-compose build
|
docker-compose build
|
||||||
# Create .npm directory in the container, since it is not yet present and we need it for next step.
|
# Create .npm directory in the container, since it is not yet present and we need it for next step.
|
||||||
docker-compose run --user="$UID" --rm --service-ports test mkdir -p /opt/sealious-app/.npm
|
docker-compose run --user="$UID" --rm --service-ports test mkdir -p /opt/sealious-app/.npm
|
||||||
# Link the host-bound npm cache directory into the container's npm cache directory.
|
# Link the host-bound npm cache directory into the container's npm cache directory.
|
||||||
docker-compose run --user="$UID" --rm --service-ports test ln -s /opt/sealious-app/.npm_cacache /opt/sealious-app/.npm/_cacache
|
docker-compose run --user="$UID" --rm --service-ports test ln -s /opt/sealious-app/.npm_cacache/ /opt/sealious-app/.npm/_cacache
|
||||||
docker-compose up -d db
|
docker-compose up -d db
|
||||||
./npm.sh ci && ./npm.sh run build
|
./npm.sh --no-TTY --user="$UID" ci && ./npm.sh --no-TTY --user="$UID" run build
|
||||||
|
|
||||||
rm -f log.html
|
rm -f log.html
|
||||||
|
@ -4,8 +4,8 @@ export SEALIOUS_PORT=$PORT
|
|||||||
SEALIOUS_BASE_URL=$(cat .base_url)
|
SEALIOUS_BASE_URL=$(cat .base_url)
|
||||||
export SEALIOUS_BASE_URL
|
export SEALIOUS_BASE_URL
|
||||||
|
|
||||||
./npm.sh run typecheck:front
|
./npm.sh --no-TTY --user="$UID" run typecheck:front
|
||||||
./npm.sh run typecheck:back
|
./npm.sh --no-TTY --user="$UID" run typecheck:back
|
||||||
|
|
||||||
docker-compose run --user="$UID" \
|
docker-compose run --user="$UID" \
|
||||||
-e "SEALIOUS_MONGO_PORT=27017" \
|
-e "SEALIOUS_MONGO_PORT=27017" \
|
||||||
|
@ -4,7 +4,7 @@ SEALIOUS_PORT="${PORT}0"
|
|||||||
SEALIOUS_BASE_URL=$(cat .base_url)
|
SEALIOUS_BASE_URL=$(cat .base_url)
|
||||||
export SEALIOUS_BASE_URL
|
export SEALIOUS_BASE_URL
|
||||||
|
|
||||||
./npm.sh run build:front
|
./npm.sh --no-TTY --user="$UID" run build:front
|
||||||
|
|
||||||
docker-compose up -d mailcatcher
|
docker-compose up -d mailcatcher
|
||||||
|
|
||||||
|
2
npm.sh
2
npm.sh
@ -4,8 +4,6 @@
|
|||||||
# the "--no-TTY" option is crucial - without it the output is not captured in Jenkins
|
# the "--no-TTY" option is crucial - without it the output is not captured in Jenkins
|
||||||
|
|
||||||
docker-compose run \
|
docker-compose run \
|
||||||
--no-TTY \
|
|
||||||
--user="$UID" \
|
|
||||||
--rm \
|
--rm \
|
||||||
--service-ports \
|
--service-ports \
|
||||||
test \
|
test \
|
||||||
|
3944
package-lock.json
generated
3944
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@ -7,9 +7,9 @@
|
|||||||
"start": "docker-compose up -d db && node .",
|
"start": "docker-compose up -d db && node .",
|
||||||
"typecheck:back": "tsc --noEmit -p src/back",
|
"typecheck:back": "tsc --noEmit -p src/back",
|
||||||
"typecheck:front": "tsc --noEmit -p src/front",
|
"typecheck:front": "tsc --noEmit -p src/front",
|
||||||
"build": "node ./esbuild.js",
|
"build": "sealgen build",
|
||||||
"watch": "multiple-scripts-tmux \"npm run typecheck:back -- --watch\" \"SEALIOUS_PORT=$SEALIOUS_PORT SEALIOUS_BASE_URL=$SEALIOUS_BASE_URL nodemon --enable-source-maps .\" \"npm run build -- --watch\" \"npm run typecheck:front -- --watch\" ",
|
"watch": "multiple-scripts-tmux \"npm run typecheck:back -- --watch\" \"SEALIOUS_PORT=$SEALIOUS_PORT SEALIOUS_BASE_URL=$SEALIOUS_BASE_URL nodemon --enable-source-maps .\" \"npm run build -- --watch\" \"npm run typecheck:front -- --watch\" ",
|
||||||
"test": "TS_NODE_PROJECT='./src/back/tsconfig.json' mocha --recursive --require ts-node/register src/back/**/*.test.ts src/back/**/**/*.test.ts src/back/**/**/**/*.test.ts src/back/**/**/**/**/*.test.ts",
|
"test": "TS_NODE_PROJECT='./src/back/tsconfig.json' mocha --recursive --require ts-node/register src/back/app.ts src/back/**/*.test.ts src/back/**/**/*.test.ts src/back/**/**/**/*.test.ts src/back/**/**/**/**/*.test.ts",
|
||||||
"coverage": "nyc npm run test --",
|
"coverage": "nyc npm run test --",
|
||||||
"test-reports": "docker-compose up -d && ./npm.sh run coverage -- --reporter xunit --reporter-option output=.xunit",
|
"test-reports": "docker-compose up -d && ./npm.sh run coverage -- --reporter xunit --reporter-option output=.xunit",
|
||||||
"show-coverage": "npm run test-reports; xdg-open coverage/index.html"
|
"show-coverage": "npm run test-reports; xdg-open coverage/index.html"
|
||||||
@ -20,18 +20,15 @@
|
|||||||
"@babel/core": "^7.12.10",
|
"@babel/core": "^7.12.10",
|
||||||
"@hotwired/turbo": "^7.1.0",
|
"@hotwired/turbo": "^7.1.0",
|
||||||
"@koa/router": "^10.1.1",
|
"@koa/router": "^10.1.1",
|
||||||
"@sealcode/sealgen": "^0.1.7",
|
"@sealcode/sealgen": "^0.5.3",
|
||||||
"@sealcode/ts-predicates": "^0.4.0",
|
"@sealcode/ts-predicates": "^0.4.0",
|
||||||
"esbuild-node-tsc": "^1.8.2",
|
|
||||||
"hint": "^7.0.1",
|
"hint": "^7.0.1",
|
||||||
"locreq": "^2.0.2",
|
"locreq": "^2.0.2",
|
||||||
"merge": "^2.1.1",
|
|
||||||
"multiple-scripts-tmux": "^1.0.4",
|
"multiple-scripts-tmux": "^1.0.4",
|
||||||
"nodemon": "^2.0.7",
|
"nodemon": "^2.0.7",
|
||||||
"sealious": "^0.14.10",
|
"sealious": "^0.15.8",
|
||||||
"source-map-support": "^0.5.21",
|
|
||||||
"stimulus": "^2.0.0",
|
"stimulus": "^2.0.0",
|
||||||
"tempstream": "^0.0.19"
|
"tempstream": "^0.0.21"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hint/connector-jsdom": "^4.1.20",
|
"@hint/connector-jsdom": "^4.1.20",
|
||||||
@ -46,9 +43,6 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "^5.10.0",
|
"@typescript-eslint/eslint-plugin": "^5.10.0",
|
||||||
"@typescript-eslint/parser": "^5.10.2",
|
"@typescript-eslint/parser": "^5.10.2",
|
||||||
"axios": "^0.24.0",
|
"axios": "^0.24.0",
|
||||||
"chokidar": "^3.5.3",
|
|
||||||
"esbuild": "^0.14.10",
|
|
||||||
"esbuild-sass-plugin": "^2.0.0",
|
|
||||||
"eslint": "^7.19.0",
|
"eslint": "^7.19.0",
|
||||||
"eslint-config-prettier": "^7.2.0",
|
"eslint-config-prettier": "^7.2.0",
|
||||||
"eslint-plugin-prettier": "^3.3.1",
|
"eslint-plugin-prettier": "^3.3.1",
|
||||||
@ -58,7 +52,6 @@
|
|||||||
"mri": "^1.1.6",
|
"mri": "^1.1.6",
|
||||||
"nyc": "^15.1.0",
|
"nyc": "^15.1.0",
|
||||||
"prettier": "^2.2.1",
|
"prettier": "^2.2.1",
|
||||||
"tiny-glob": "^0.2.9",
|
|
||||||
"ts-loader": "^8.0.14",
|
"ts-loader": "^8.0.14",
|
||||||
"ts-node": "^10.4.0",
|
"ts-node": "^10.4.0",
|
||||||
"typescript": "^4.1.3"
|
"typescript": "^4.1.3"
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import _locreq from "locreq";
|
import _locreq from "locreq";
|
||||||
import { App, LoggerMailer, SMTPMailer } from "sealious";
|
import { default as Sealious, App, LoggerMailer, SMTPMailer } from "sealious";
|
||||||
import { LoggerLevel } from "sealious/@types/src/app/logger";
|
import { LoggerLevel } from "sealious/@types/src/app/logger";
|
||||||
import { collections } from "./collections/collections";
|
import { collections } from "./collections/collections";
|
||||||
const locreq = _locreq(__dirname);
|
const locreq = _locreq(__dirname);
|
||||||
@ -8,9 +8,16 @@ const PORT = process.env.SEALIOUS_PORT ? parseInt(process.env.SEALIOUS_PORT) : 8
|
|||||||
const base_url = process.env.SEALIOUS_BASE_URL || `http://localhost:${PORT}`;
|
const base_url = process.env.SEALIOUS_BASE_URL || `http://localhost:${PORT}`;
|
||||||
const MONGO_PORT = process.env.SEALIOUS_MONGO_PORT
|
const MONGO_PORT = process.env.SEALIOUS_MONGO_PORT
|
||||||
? parseInt(process.env.SEALIOUS_MONGO_PORT)
|
? parseInt(process.env.SEALIOUS_MONGO_PORT)
|
||||||
: 20726;
|
: 20747;
|
||||||
const MONGO_HOST = process.env.SEALIOUS_MONGO_HOST || "127.0.0.1";
|
const MONGO_HOST = process.env.SEALIOUS_MONGO_HOST || "127.0.0.1";
|
||||||
|
|
||||||
|
declare module "koa" {
|
||||||
|
interface BaseContext {
|
||||||
|
$context: Sealious.Context;
|
||||||
|
$app: TheApp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default class TheApp extends App {
|
export default class TheApp extends App {
|
||||||
config = {
|
config = {
|
||||||
upload_path: locreq.resolve("uploaded_files"),
|
upload_path: locreq.resolve("uploaded_files"),
|
||||||
|
@ -1,344 +0,0 @@
|
|||||||
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";
|
|
||||||
}
|
|
@ -1,119 +0,0 @@
|
|||||||
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)
|
|
||||||
) {
|
|
||||||
return { valid: true, message: "" };
|
|
||||||
}
|
|
||||||
return { valid: false, message: "Proszę wprowadzić liczbę" };
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,7 +0,0 @@
|
|||||||
import { FormField } from "./field";
|
|
||||||
|
|
||||||
export class FormFieldsList {
|
|
||||||
static getField(fields: FormField[], name: string) {
|
|
||||||
return fields.find((f) => f.name == name);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,231 +0,0 @@
|
|||||||
import { BaseContext } from "koa";
|
|
||||||
import Router from "@koa/router";
|
|
||||||
import { Templatable, tempstream } from "tempstream";
|
|
||||||
import { FormControl } from "./controls";
|
|
||||||
import { hasShape, is, predicates } from "@sealcode/ts-predicates";
|
|
||||||
import { Mountable, PageErrorMessage } from "../page/page";
|
|
||||||
import { FormField } from "./field";
|
|
||||||
|
|
||||||
export type FormData<Fieldnames extends string = string> = {
|
|
||||||
values: Record<Fieldnames, string | string[] | number>;
|
|
||||||
raw_values: Record<Fieldnames, string | string[] | number>;
|
|
||||||
errors: Partial<Record<Fieldnames, string>>;
|
|
||||||
messages: { type: "info" | "success" | "error"; text: string }[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default abstract class Form<Fieldnames extends string = string>
|
|
||||||
implements Mountable
|
|
||||||
{
|
|
||||||
abstract fields: FormField<Fieldnames>[];
|
|
||||||
abstract controls: FormControl[];
|
|
||||||
defaultSuccessMessage = "Done";
|
|
||||||
submitButtonText = "Wyslij";
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
async canAccess(_: BaseContext) {
|
|
||||||
return { canAccess: true, message: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
async renderError(_: BaseContext, error: PageErrorMessage) {
|
|
||||||
return tempstream/* HTML */ `<div>${error.message}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async validate(
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
_: BaseContext,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
__: Record<string, unknown>
|
|
||||||
): Promise<{ valid: boolean; error: string }> {
|
|
||||||
return {
|
|
||||||
valid: true,
|
|
||||||
error: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _validate(
|
|
||||||
ctx: BaseContext,
|
|
||||||
values: Record<string, unknown>
|
|
||||||
): Promise<{
|
|
||||||
valid: boolean;
|
|
||||||
errors: Record<Fieldnames | "form", string>;
|
|
||||||
}> {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
const errors = {} as Record<Fieldnames | "form", string>;
|
|
||||||
let valid = true;
|
|
||||||
await Promise.all(
|
|
||||||
this.fields.map(async (field) => {
|
|
||||||
const { valid: fieldvalid, message: fieldmessage } =
|
|
||||||
await field._validate(ctx, values[field.name]);
|
|
||||||
if (!fieldvalid) {
|
|
||||||
valid = false;
|
|
||||||
errors[field.name] = fieldmessage;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const formValidationResult = await this.validate(ctx, values);
|
|
||||||
if (!formValidationResult.valid) {
|
|
||||||
valid = false;
|
|
||||||
errors.form = formValidationResult.error;
|
|
||||||
}
|
|
||||||
return { valid, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
async render(
|
|
||||||
ctx: BaseContext,
|
|
||||||
data: FormData,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
form_path: string
|
|
||||||
): Promise<Templatable> {
|
|
||||||
return tempstream/* HTML */ `${this.makeFormTag(`${ctx.URL.pathname}/`)} ${
|
|
||||||
!this.controls.some((control) => control.role == "messages")
|
|
||||||
? this.renderMessages(ctx, data)
|
|
||||||
: ""
|
|
||||||
} ${
|
|
||||||
data.errors.form !== undefined
|
|
||||||
? `<div class="form__error">${data.errors.form}</div>`
|
|
||||||
: ""
|
|
||||||
} ${this.renderControls(ctx, data)}<input type="submit" value="${
|
|
||||||
this.submitButtonText
|
|
||||||
}"/></form>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public renderMessages(_: BaseContext, data: FormData): Templatable {
|
|
||||||
return tempstream/* HTML */ `<div class="form-messages">
|
|
||||||
${data.messages.map(
|
|
||||||
(message) =>
|
|
||||||
`<div class="form-message form-message--${message.type}">${message.text}</div>`
|
|
||||||
)}
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public renderControls(ctx: BaseContext, data: FormData): Templatable {
|
|
||||||
return tempstream/* HTML */ `${this.controls.map((control) =>
|
|
||||||
control.render(ctx, this.fields, data)
|
|
||||||
)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public makeFormTag(path: string) {
|
|
||||||
return `<form method="POST" action="${path}">`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateData(rawData: Record<string, unknown> = {}): FormData {
|
|
||||||
// generates a FormData object that has the correct shape to be passed to
|
|
||||||
// render(), so for example it makes sure that all fields either have values or
|
|
||||||
// are empty string (the aren't undefined, for example). If no argument is passed,
|
|
||||||
// creates an object that represents an empty state of the form. If some data
|
|
||||||
// object is passed in the first argument, then the values in that data object are
|
|
||||||
// incorporated into the generated object
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
const values = Object.fromEntries(
|
|
||||||
this.fields.map((f) => [f.name, rawData[f.name] || f.getEmptyValue()])
|
|
||||||
) as Record<Fieldnames, string>;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
const errors = Object.fromEntries(this.fields.map((f) => [f.name, ""])) as Record<
|
|
||||||
Fieldnames,
|
|
||||||
string
|
|
||||||
>;
|
|
||||||
return {
|
|
||||||
values,
|
|
||||||
errors,
|
|
||||||
messages: [],
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
raw_values: rawData as Record<string, string>,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async onValuesInvalid(
|
|
||||||
ctx: BaseContext,
|
|
||||||
errors: Record<Fieldnames, string>,
|
|
||||||
form_path: string
|
|
||||||
) {
|
|
||||||
ctx.status = 422;
|
|
||||||
const { values, raw_values } = this.generateData(ctx.$body);
|
|
||||||
ctx.body = await this.render(
|
|
||||||
ctx,
|
|
||||||
{
|
|
||||||
values,
|
|
||||||
raw_values,
|
|
||||||
errors,
|
|
||||||
messages: [{ type: "error", text: "Some fields are invalid" }],
|
|
||||||
},
|
|
||||||
form_path
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async onError(ctx: BaseContext, error: unknown, form_path: string) {
|
|
||||||
ctx.status = 422;
|
|
||||||
let error_message = "Unknown error has occured";
|
|
||||||
if (
|
|
||||||
is(error, predicates.object) &&
|
|
||||||
hasShape({ message: predicates.string }, error)
|
|
||||||
) {
|
|
||||||
error_message = error.message;
|
|
||||||
}
|
|
||||||
const { values, raw_values } = this.generateData(ctx.$body);
|
|
||||||
ctx.body = await this.render(
|
|
||||||
ctx,
|
|
||||||
{
|
|
||||||
values,
|
|
||||||
raw_values,
|
|
||||||
errors: {},
|
|
||||||
messages: [{ type: "error", text: error_message }],
|
|
||||||
},
|
|
||||||
form_path
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract onSubmit(
|
|
||||||
ctx: BaseContext,
|
|
||||||
values: Record<Fieldnames, string | string[] | number>
|
|
||||||
): void | Promise<void>;
|
|
||||||
|
|
||||||
public async onSuccess(ctx: BaseContext, form_path: string): Promise<void> {
|
|
||||||
const { values, raw_values } = this.generateData(ctx.$body);
|
|
||||||
ctx.body = await this.render(
|
|
||||||
ctx,
|
|
||||||
{
|
|
||||||
values,
|
|
||||||
raw_values,
|
|
||||||
errors: {},
|
|
||||||
messages: [{ type: "success", text: this.defaultSuccessMessage }],
|
|
||||||
},
|
|
||||||
form_path
|
|
||||||
);
|
|
||||||
ctx.status = 422;
|
|
||||||
}
|
|
||||||
|
|
||||||
public mount(router: Router, path: string) {
|
|
||||||
router.use(path, async (ctx, next) => {
|
|
||||||
const result = await this.canAccess(ctx);
|
|
||||||
if (!result.canAccess) {
|
|
||||||
ctx.body = this.renderError(ctx, {
|
|
||||||
type: "access",
|
|
||||||
message: result.message,
|
|
||||||
});
|
|
||||||
ctx.status = 403;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
router.get(path, async (ctx) => {
|
|
||||||
ctx.type = "html";
|
|
||||||
ctx.body = await this.render(ctx, this.generateData(), path);
|
|
||||||
});
|
|
||||||
router.post(path, async (ctx) => {
|
|
||||||
const { valid, errors } = await this._validate(ctx, ctx.$body);
|
|
||||||
if (!valid) {
|
|
||||||
await this.onValuesInvalid(ctx, errors, path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await this.onSubmit(ctx, this.generateData(ctx.$body).values);
|
|
||||||
await this.onSuccess(ctx, path);
|
|
||||||
} catch (e) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.dir(e, { depth: 5 });
|
|
||||||
await this.onError(ctx, e, path);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,18 +0,0 @@
|
|||||||
.form-message {
|
|
||||||
--color: black;
|
|
||||||
border: 1px solid var(--color);
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 10px;
|
|
||||||
|
|
||||||
&--error {
|
|
||||||
--color: #ff4136;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--success {
|
|
||||||
--color: #2ecc40;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form__error {
|
|
||||||
color: #ff4136;
|
|
||||||
}
|
|
@ -1,10 +0,0 @@
|
|||||||
import { BaseContext } from "koa";
|
|
||||||
import { Field } from "sealious";
|
|
||||||
import { FormFieldValidationFn } from "./field";
|
|
||||||
|
|
||||||
export function collectionFieldValidator(field: Field): FormFieldValidationFn {
|
|
||||||
return async (ctx: BaseContext, value) => {
|
|
||||||
const { valid, reason } = await field.checkValue(ctx.$context, value, undefined);
|
|
||||||
return { valid, message: reason || (valid ? "Wrong value" : "") };
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,17 +1,8 @@
|
|||||||
import _locreq from "locreq";
|
import _locreq from "locreq";
|
||||||
import Sealious from "sealious";
|
|
||||||
import TheApp from "./app";
|
import TheApp from "./app";
|
||||||
import { mainRouter } from "./routes";
|
import { mainRouter } from "./routes";
|
||||||
const locreq = _locreq(__dirname);
|
const locreq = _locreq(__dirname);
|
||||||
|
|
||||||
declare module "koa" {
|
|
||||||
interface BaseContext {
|
|
||||||
$context: Sealious.Context;
|
|
||||||
$app: TheApp;
|
|
||||||
$body: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = new TheApp();
|
const app = new TheApp();
|
||||||
|
|
||||||
app.start()
|
app.start()
|
||||||
|
@ -1,18 +0,0 @@
|
|||||||
import { BaseContext } from "koa";
|
|
||||||
import { ActionName, Collection } from "sealious";
|
|
||||||
|
|
||||||
export function peopleWhoCan(action: ActionName, collection: Collection) {
|
|
||||||
return async function (ctx: BaseContext) {
|
|
||||||
const policy = collection.getPolicy(action);
|
|
||||||
const result = await policy.check(ctx.$context);
|
|
||||||
if (!result) {
|
|
||||||
ctx.status = 403;
|
|
||||||
return { canAccess: false, message: "Not allowed" };
|
|
||||||
}
|
|
||||||
if (!result.allowed) {
|
|
||||||
ctx.status = 403;
|
|
||||||
return { canAccess: false, message: result.reason };
|
|
||||||
}
|
|
||||||
return { canAccess: true, message: "" };
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,200 +0,0 @@
|
|||||||
import { BaseContext } from "koa";
|
|
||||||
import { Collection, CollectionItem } from "sealious";
|
|
||||||
import { Templatable, tempstream } from "tempstream";
|
|
||||||
import { peopleWhoCan } from "./access-control";
|
|
||||||
import { naturalNumbers, UrlWithNewParams } from "../util";
|
|
||||||
import { Page } from "./page";
|
|
||||||
import { predicates, ShapeToType } from "@sealcode/ts-predicates";
|
|
||||||
import { PagePropsParser } from "./props-parser";
|
|
||||||
import { FormFieldControl } from "../forms/controls";
|
|
||||||
import { FormField } from "../forms/field";
|
|
||||||
import { FormData } from "../forms/form";
|
|
||||||
import { FormFieldsList } from "../forms/form-fields-list";
|
|
||||||
|
|
||||||
export const BasePagePropsShape = <const>{};
|
|
||||||
export type BasePageProps = ShapeToType<typeof BasePagePropsShape>;
|
|
||||||
|
|
||||||
export const BaseListPagePropsShape = <const>{
|
|
||||||
page: predicates.number,
|
|
||||||
itemsPerPage: predicates.number,
|
|
||||||
};
|
|
||||||
export type BaseListPageProps = ShapeToType<typeof BaseListPagePropsShape>;
|
|
||||||
export const BaseListPageDefaultProps = { page: 1, itemsPerPage: 25 };
|
|
||||||
|
|
||||||
export type PropsErrors<PropsType> = Partial<Record<keyof PropsType, string>>;
|
|
||||||
|
|
||||||
export abstract class ListPage<
|
|
||||||
ItemType,
|
|
||||||
PropsType extends BaseListPageProps = BaseListPageProps
|
|
||||||
> extends Page {
|
|
||||||
abstract getItems(ctx: BaseContext, props: PropsType): Promise<{ items: ItemType[] }>;
|
|
||||||
abstract getTotalPages(ctx: BaseContext, props: PropsType): Promise<number>;
|
|
||||||
abstract renderItem(ctx: BaseContext, item: ItemType): Promise<Templatable>;
|
|
||||||
abstract propsParser: PagePropsParser<PropsType>;
|
|
||||||
|
|
||||||
filterFields: FormField<keyof PropsType>[] = [];
|
|
||||||
filterControls: FormFieldControl[] = [];
|
|
||||||
|
|
||||||
renderListContainer(_: BaseContext, content: Templatable): Templatable {
|
|
||||||
return tempstream`<div>${content}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async validateProps(
|
|
||||||
ctx: BaseContext,
|
|
||||||
props: PropsType
|
|
||||||
): Promise<{ valid: boolean; errors: PropsErrors<PropsType> }> {
|
|
||||||
const errors: PropsErrors<PropsType> = {};
|
|
||||||
let has_errors = false;
|
|
||||||
const promises = [];
|
|
||||||
for (const [key, value] of Object.entries(props)) {
|
|
||||||
const field = FormFieldsList.getField(this.filterFields, key);
|
|
||||||
if (field) {
|
|
||||||
promises.push(
|
|
||||||
field._validate(ctx, value).then(({ valid, message }) => {
|
|
||||||
if (!valid) {
|
|
||||||
has_errors = true;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
errors[key as keyof PropsType] = message;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Promise.all(promises);
|
|
||||||
return { valid: has_errors, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
async getProps(ctx: BaseContext): Promise<{
|
|
||||||
parsed_props: PropsType;
|
|
||||||
errors: PropsErrors<PropsType>;
|
|
||||||
raw_props: PropsType;
|
|
||||||
}> {
|
|
||||||
const raw_props = this.propsParser.decode(ctx);
|
|
||||||
const parsed_props = { ...raw_props };
|
|
||||||
const { errors } = await this.validateProps(ctx, parsed_props);
|
|
||||||
for (const prop_name in errors) {
|
|
||||||
const default_value = this.propsParser.getDefaultValue(prop_name);
|
|
||||||
if (default_value !== undefined) {
|
|
||||||
parsed_props[prop_name] = default_value;
|
|
||||||
} else {
|
|
||||||
delete parsed_props[prop_name];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { parsed_props, errors, raw_props };
|
|
||||||
}
|
|
||||||
|
|
||||||
async render(ctx: BaseContext) {
|
|
||||||
const { parsed_props, errors, raw_props } = await this.getProps(ctx);
|
|
||||||
|
|
||||||
return tempstream`${this.renderPagination(ctx, parsed_props)}
|
|
||||||
${this.renderFilters(ctx, parsed_props, raw_props, errors)}
|
|
||||||
${this.getItems(ctx, parsed_props).then(({ items }) =>
|
|
||||||
this.renderListContainer(
|
|
||||||
ctx,
|
|
||||||
items.map((item) => this.renderItem(ctx, item))
|
|
||||||
)
|
|
||||||
)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async renderPagination(ctx: BaseContext, props: PropsType) {
|
|
||||||
const totalIems = await this.getTotalPages(ctx, props);
|
|
||||||
const currentPage = props.page;
|
|
||||||
|
|
||||||
return tempstream/* HTML */ `<center>
|
|
||||||
${currentPage > 1 ? this.renderPageButton(ctx, 1, "Pierwsza strona") : ""}
|
|
||||||
${currentPage > 1
|
|
||||||
? this.renderPageButton(ctx, currentPage - 1, "Poprzednia strona")
|
|
||||||
: ""}
|
|
||||||
|
|
||||||
<select onchange="if (this.value) Turbo.visit(this.value)">
|
|
||||||
${Array.from(naturalNumbers(1, await this.getTotalPages(ctx, props))).map(
|
|
||||||
(n) => /* HTML */ `<option
|
|
||||||
value="${UrlWithNewParams(
|
|
||||||
ctx,
|
|
||||||
//eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
this.propsParser.overwriteProp(ctx, {
|
|
||||||
page: n,
|
|
||||||
} as Partial<PropsType>)
|
|
||||||
)}"
|
|
||||||
${currentPage === n ? "selected" : ""}
|
|
||||||
>
|
|
||||||
${n}
|
|
||||||
</option>`
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
${currentPage < totalIems
|
|
||||||
? this.renderPageButton(ctx, currentPage + 1, "Następna strona")
|
|
||||||
: ""}
|
|
||||||
${currentPage < totalIems
|
|
||||||
? this.renderPageButton(ctx, totalIems, "Ostatnia strona")
|
|
||||||
: ""}
|
|
||||||
</center>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderPageButton(ctx: BaseContext, page: number, text: string) {
|
|
||||||
return /* HTML */ `<a
|
|
||||||
href="${UrlWithNewParams(
|
|
||||||
ctx,
|
|
||||||
//eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
this.propsParser.overwriteProp(ctx, {
|
|
||||||
page,
|
|
||||||
} as Partial<PropsType>)
|
|
||||||
)}"
|
|
||||||
>${text}</a
|
|
||||||
>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderFilters(
|
|
||||||
ctx: BaseContext,
|
|
||||||
parsed_props: PropsType, // parsed props don't include wrong values
|
|
||||||
raw_props: PropsType,
|
|
||||||
errors: PropsErrors<PropsType>
|
|
||||||
) {
|
|
||||||
return tempstream/* HTML */ `<form method="GET">
|
|
||||||
${this.propsParser.makeHiddenInputs(parsed_props, [
|
|
||||||
"page",
|
|
||||||
...this.filterFields.map((f) => f.name),
|
|
||||||
])}
|
|
||||||
${this.filterControls.map((control) =>
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
control.render(ctx, this.filterFields, {
|
|
||||||
values: parsed_props,
|
|
||||||
raw_values: raw_props,
|
|
||||||
errors,
|
|
||||||
messages: [],
|
|
||||||
} as FormData)
|
|
||||||
)}
|
|
||||||
</form>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export abstract class SealiousItemListPage<
|
|
||||||
C extends Collection,
|
|
||||||
PageProps extends BaseListPageProps = BaseListPageProps
|
|
||||||
> extends ListPage<CollectionItem<C>, PageProps> {
|
|
||||||
constructor(public collection: C) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getTotalPages(ctx: BaseContext, props: PageProps) {
|
|
||||||
const { items } = await this.collection.list(ctx.$context).fetch();
|
|
||||||
return Math.ceil(items.length / props.itemsPerPage);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getItems(ctx: BaseContext, props: PageProps) {
|
|
||||||
return {
|
|
||||||
items: (
|
|
||||||
await this.collection
|
|
||||||
.list(ctx.$context)
|
|
||||||
.paginate({ items: props.itemsPerPage, page: props.page })
|
|
||||||
.fetch()
|
|
||||||
).items,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async renderItem(_: BaseContext, item: CollectionItem<C>): Promise<Templatable> {
|
|
||||||
return `<div>${item.id}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
canAccess = peopleWhoCan("list", this.collection);
|
|
||||||
}
|
|
@ -1,31 +0,0 @@
|
|||||||
import Router from "@koa/router";
|
|
||||||
import { BaseContext } from "koa";
|
|
||||||
|
|
||||||
import { Templatable, tempstream } from "tempstream";
|
|
||||||
|
|
||||||
export type PageErrorMessage = { type: "access" | "internal"; message: string };
|
|
||||||
|
|
||||||
export interface Mountable {
|
|
||||||
mount: (router: Router, path: string) => void;
|
|
||||||
canAccess: (ctx: BaseContext) => Promise<{ canAccess: boolean; message: string }>;
|
|
||||||
renderError(ctx: BaseContext, error: PageErrorMessage): Promise<Templatable>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export abstract class Page implements Mountable {
|
|
||||||
mount(router: Router, path: string) {
|
|
||||||
router.get(path, async (ctx) => {
|
|
||||||
ctx.body = await this.render(ctx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
async canAccess(_: BaseContext) {
|
|
||||||
return { canAccess: true, message: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
async renderError(_: BaseContext, error: PageErrorMessage) {
|
|
||||||
return tempstream/* HTML */ `<div>${error.message}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract render(ctx: BaseContext): Promise<Templatable>;
|
|
||||||
}
|
|
@ -1,81 +0,0 @@
|
|||||||
import { hasShape, predicates, Shape, ShapeToType } from "@sealcode/ts-predicates";
|
|
||||||
import { BaseContext } from "koa";
|
|
||||||
import merge from "merge";
|
|
||||||
import { BasePageProps } from "./list";
|
|
||||||
|
|
||||||
export type EncodedProps = Record<string, unknown>;
|
|
||||||
|
|
||||||
// the intention here is to sometime in the future be able to store multiple frames on one document, so props for each frame will be in a different namespace, and parsers are going to help with that
|
|
||||||
|
|
||||||
function parseStringValues<TheShape extends Shape>(
|
|
||||||
shape: TheShape,
|
|
||||||
values: Record<string, string>
|
|
||||||
): ShapeToType<TheShape> {
|
|
||||||
const result: Record<string, unknown> = {};
|
|
||||||
for (const [key, value] of Object.entries(values)) {
|
|
||||||
if (!(key in shape)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const predicate = shape[key];
|
|
||||||
if (predicate == predicates.number) {
|
|
||||||
result[key] = parseFloat(value);
|
|
||||||
} else {
|
|
||||||
result[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
return result as ShapeToType<TheShape>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export abstract class PagePropsParser<PropsType extends BasePageProps> {
|
|
||||||
abstract decode(ctx: BaseContext): PropsType;
|
|
||||||
abstract encode(props: PropsType): EncodedProps;
|
|
||||||
abstract getHTMLInputName(prop_name: string): string;
|
|
||||||
|
|
||||||
constructor(public propsShape: Shape, public defaultValues: Partial<PropsType>) {}
|
|
||||||
|
|
||||||
overwriteProp(ctx: BaseContext, new_props: Partial<PropsType>): EncodedProps {
|
|
||||||
const result = {};
|
|
||||||
merge.recursive(result, this.decode(ctx), new_props);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
makeHiddenInputs(values: PropsType, fields_to_skip: string[]): string {
|
|
||||||
return Object.entries(values)
|
|
||||||
.filter(([key]) => !fields_to_skip.includes(key))
|
|
||||||
.map(
|
|
||||||
([key, value]: [string, string | number]) =>
|
|
||||||
/* HTML */ `<input type="hidden" name="${key}" value="${value}" />`
|
|
||||||
)
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
getDefaultValue<Key extends keyof PropsType>(key: Key): PropsType[Key] | undefined {
|
|
||||||
return this.defaultValues[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AllQueryParams<
|
|
||||||
PropsType extends BasePageProps
|
|
||||||
> extends PagePropsParser<PropsType> {
|
|
||||||
decode(ctx: BaseContext): PropsType {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
const query = parseStringValues(this.propsShape, {
|
|
||||||
...this.defaultValues,
|
|
||||||
...ctx.query,
|
|
||||||
} as unknown as Record<string, string>);
|
|
||||||
if (!hasShape(this.propsShape, query)) {
|
|
||||||
throw new Error("Wrong props shape");
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
return query as PropsType;
|
|
||||||
}
|
|
||||||
|
|
||||||
encode(props: PropsType): Record<string, unknown> {
|
|
||||||
return props;
|
|
||||||
}
|
|
||||||
|
|
||||||
getHTMLInputName(prop_name: string): string {
|
|
||||||
return prop_name;
|
|
||||||
}
|
|
||||||
}
|
|
17
src/back/routes/hello.page.ts
Normal file
17
src/back/routes/hello.page.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Context } from "koa";
|
||||||
|
import { tempstream } from "tempstream";
|
||||||
|
import { Page } from "@sealcode/sealgen";
|
||||||
|
import html from "../html";
|
||||||
|
|
||||||
|
export const actionName = "Hello";
|
||||||
|
|
||||||
|
export default new (class HelloPage extends Page {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
async canAccess(_: Context) {
|
||||||
|
return { canAccess: true, message: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async render(ctx: Context) {
|
||||||
|
return html(ctx, "Hello", tempstream/* HTML */ `<div></div>`);
|
||||||
|
}
|
||||||
|
})();
|
@ -1,16 +1,16 @@
|
|||||||
import { withProdApp } from "../test_utils/with-prod-app";
|
import { withProdApp } from "../test_utils/with-prod-app";
|
||||||
import { LONG_TEST_TIMEOUT, webhintURL } from "../test_utils/webhint";
|
import { LONG_TEST_TIMEOUT, webhintURL } from "../test_utils/webhint";
|
||||||
import { TestComplexURL } from "./routes";
|
import { HelloURL } from "./urls";
|
||||||
|
|
||||||
describe("TestComplex", () => {
|
describe("Hello", () => {
|
||||||
it("doesn't crash", async function () {
|
it("doesn't crash", async function () {
|
||||||
this.timeout(LONG_TEST_TIMEOUT);
|
this.timeout(LONG_TEST_TIMEOUT);
|
||||||
return withProdApp(async ({ base_url, rest_api }) => {
|
return withProdApp(async ({ base_url, rest_api }) => {
|
||||||
await rest_api.get(TestComplexURL);
|
await rest_api.get(HelloURL);
|
||||||
await webhintURL(base_url + TestComplexURL);
|
await webhintURL(base_url + HelloURL);
|
||||||
// alternatively you can use webhintHTML for faster but less precise scans
|
// alternatively you can use webhintHTML for faster but less precise scans
|
||||||
// or for scanning responses of requests that use some form of authorization:
|
// or for scanning responses of requests that use some form of authorization:
|
||||||
// const response = await rest_api.get(TestComplexURL);
|
// const response = await rest_api.get(HelloURL);
|
||||||
// await webhintHTML(response);
|
// await webhintHTML(response);
|
||||||
});
|
});
|
||||||
});
|
});
|
@ -1,7 +1,6 @@
|
|||||||
import Router from "@koa/router";
|
import Router from "@koa/router";
|
||||||
import { Middlewares } from "sealious";
|
import { Middlewares } from "sealious";
|
||||||
import { MainView } from "./common/main-view";
|
import { MainView } from "./common/main-view";
|
||||||
import { loginRouter } from "./login/login.routes";
|
|
||||||
import mountAutoRoutes from "./routes";
|
import mountAutoRoutes from "./routes";
|
||||||
|
|
||||||
export const mainRouter = (router: Router): void => {
|
export const mainRouter = (router: Router): void => {
|
||||||
@ -11,6 +10,5 @@ export const mainRouter = (router: Router): void => {
|
|||||||
|
|
||||||
router.use(Middlewares.extractContext());
|
router.use(Middlewares.extractContext());
|
||||||
|
|
||||||
loginRouter(router);
|
|
||||||
mountAutoRoutes(router);
|
mountAutoRoutes(router);
|
||||||
};
|
};
|
||||||
|
@ -1,56 +0,0 @@
|
|||||||
import Router from "@koa/router";
|
|
||||||
import { hasFieldOfType, hasShape, predicates } from "@sealcode/ts-predicates";
|
|
||||||
import { Middlewares } from "sealious";
|
|
||||||
import html from "../../html";
|
|
||||||
import { LoginForm } from "./login.views";
|
|
||||||
import { formHasSomeFields } from "../common/form";
|
|
||||||
import { MyProfileURL } from "../routes";
|
|
||||||
|
|
||||||
export const loginRouter = (router: Router): void => {
|
|
||||||
router.get("/logowanie", Middlewares.extractContext(), async (ctx) => {
|
|
||||||
ctx.body = html(ctx, "Logowanie", LoginForm());
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/login",
|
|
||||||
Middlewares.extractContext(),
|
|
||||||
Middlewares.parseBody(),
|
|
||||||
async (ctx) => {
|
|
||||||
if (!formHasSomeFields(ctx, <const>["username", "password"], ctx.$body))
|
|
||||||
return;
|
|
||||||
if (
|
|
||||||
!hasShape(
|
|
||||||
{ username: predicates.string, password: predicates.string },
|
|
||||||
ctx.$body
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
ctx.body = "brakuje hasła lub loginu";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const session_id = await ctx.$app.collections.sessions.login(
|
|
||||||
ctx.$body.username,
|
|
||||||
ctx.$body.password
|
|
||||||
);
|
|
||||||
ctx.cookies.set("sealious-session", session_id, {
|
|
||||||
maxAge: 1000 * 60 * 60 * 24 * 7,
|
|
||||||
secure: ctx.request.protocol === "https",
|
|
||||||
overwrite: true,
|
|
||||||
});
|
|
||||||
ctx.redirect(MyProfileURL);
|
|
||||||
} catch (e) {
|
|
||||||
ctx.status = 422;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
if (!hasFieldOfType(e, "message", predicates.string)) {
|
|
||||||
console.error(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.body = html(
|
|
||||||
ctx,
|
|
||||||
"Logowanie",
|
|
||||||
LoginForm(ctx.$body.username, e.message)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,39 +0,0 @@
|
|||||||
import { withProdApp } from "../../test_utils/with-prod-app";
|
|
||||||
|
|
||||||
describe("login", () => {
|
|
||||||
it("displays login form", async () => {
|
|
||||||
return withProdApp(async ({ rest_api }) => {
|
|
||||||
const result = await rest_api.get("/logowanie");
|
|
||||||
const usernameStructure = `
|
|
||||||
<label for="username">Nazwa użytkownika:</label>
|
|
||||||
<input
|
|
||||||
id="username"
|
|
||||||
type="text"
|
|
||||||
name="username"
|
|
||||||
value=""
|
|
||||||
placeholder="text"
|
|
||||||
required />`;
|
|
||||||
const passwordStructure = `
|
|
||||||
<label for="password">Hasło:</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
value=""
|
|
||||||
placeholder="password"
|
|
||||||
required />`;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!(
|
|
||||||
result
|
|
||||||
.replace(/\s/g, "")
|
|
||||||
.includes(usernameStructure.replace(/\s/g, "")) &&
|
|
||||||
result
|
|
||||||
.replace(/\s/g, "")
|
|
||||||
.includes(passwordStructure.replace(/\s/g, ""))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
throw new Error("Bad html structure!");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,37 +0,0 @@
|
|||||||
import input from "../common/ui/input";
|
|
||||||
|
|
||||||
export function LoginForm(username = "", error_message = ""): string {
|
|
||||||
let error_username = "";
|
|
||||||
let error_password = "";
|
|
||||||
if (error_message === "Incorrect username!") error_username = error_message;
|
|
||||||
else error_password = error_message;
|
|
||||||
return /* HTML */ `
|
|
||||||
<turbo-frame id="login">
|
|
||||||
<h2>Zaloguj</h2>
|
|
||||||
<form method="POST" action="/login" data-turbo-frame="_top">
|
|
||||||
<label for="username">
|
|
||||||
${input({
|
|
||||||
name: "username",
|
|
||||||
id: "username",
|
|
||||||
value: username,
|
|
||||||
type: "text",
|
|
||||||
required: true,
|
|
||||||
label: "Nazwa użytkownika:",
|
|
||||||
error: error_username,
|
|
||||||
})}
|
|
||||||
</label>
|
|
||||||
<label for="password">
|
|
||||||
${input({
|
|
||||||
id: "password",
|
|
||||||
name: "password",
|
|
||||||
type: "password",
|
|
||||||
required: true,
|
|
||||||
label: "Hasło:",
|
|
||||||
error: error_password,
|
|
||||||
})}
|
|
||||||
</label>
|
|
||||||
<input type="submit" value="Zaloguj" />
|
|
||||||
</form>
|
|
||||||
</turbo-frame>
|
|
||||||
`;
|
|
||||||
}
|
|
@ -1,18 +1,36 @@
|
|||||||
// DO NOT EDIT! This file is generated automaticaly with npm run generate-routes
|
// DO NOT EDIT! This file is generated automaticaly with npm run generate-routes
|
||||||
|
|
||||||
|
import Koa, { Context } from "koa";
|
||||||
import Router from "@koa/router";
|
import Router from "@koa/router";
|
||||||
import { Middlewares } from "sealious";
|
import { Middlewares } from "sealious";
|
||||||
|
import { Mountable } from "@sealcode/sealgen";
|
||||||
|
import * as URLs from "./urls";
|
||||||
|
|
||||||
import { default as TestComplex } from "./test-complex.form";
|
import { default as Hello } from "./hello.page";
|
||||||
import { default as MyProfile } from "./users/me.page";
|
|
||||||
|
|
||||||
export const TestComplexURL = "/test-complex/";
|
async function handleHtmlPromise(ctx: Context, next: Koa.Next) {
|
||||||
export const MyProfileURL = "/users/me/";
|
await next();
|
||||||
|
if (ctx.body instanceof Promise) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
ctx.body = await ctx.body;
|
||||||
|
}
|
||||||
|
ctx.set("content-type", "text/html;charset=utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(router: Router, URL: string, mountable: Mountable) {
|
||||||
|
router.use(
|
||||||
|
URL,
|
||||||
|
Middlewares.extractContext(),
|
||||||
|
Middlewares.parseBody(),
|
||||||
|
handleHtmlPromise
|
||||||
|
);
|
||||||
|
mountable.init();
|
||||||
|
mountable.mount(router, URL);
|
||||||
|
// to automatically add trailing slashes:
|
||||||
|
router.get(URL.slice(0, -1), (ctx) => ctx.redirect(URL));
|
||||||
|
router.use(URL, (ctx) => ctx.set("content-type", "text/html;charset=utf-8"));
|
||||||
|
}
|
||||||
|
|
||||||
export default function mountAutoRoutes(router: Router) {
|
export default function mountAutoRoutes(router: Router) {
|
||||||
router.use(TestComplexURL, Middlewares.extractContext(), Middlewares.parseBody());
|
mount(router, URLs.HelloURL, Hello);
|
||||||
TestComplex.mount(router, TestComplexURL);
|
|
||||||
|
|
||||||
router.use(MyProfileURL, Middlewares.extractContext(), Middlewares.parseBody());
|
|
||||||
MyProfile.mount(router, MyProfileURL);
|
|
||||||
}
|
}
|
||||||
|
@ -1,57 +0,0 @@
|
|||||||
import { BaseContext } from "koa";
|
|
||||||
import { tempstream } from "tempstream";
|
|
||||||
import { FormFieldControl, SimpleInput } from "../forms/controls";
|
|
||||||
import { FormField } from "../forms/field";
|
|
||||||
import Form, { FormData } from "../forms/form";
|
|
||||||
import html from "../html";
|
|
||||||
|
|
||||||
export const actionName = "TestComplex";
|
|
||||||
|
|
||||||
class NumberSum<Field1 extends string, Field2 extends string> extends FormFieldControl {
|
|
||||||
constructor(public field1: Field1, public field2: Field2) {
|
|
||||||
super([field1, field2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
_render(_: BaseContext, __: FormField[], data: FormData<Field1 | Field2>) {
|
|
||||||
return tempstream/*HTML */ `<div>Suma liczb ${this.field1} i ${
|
|
||||||
this.field2
|
|
||||||
} to: <strong>${
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
parseInt(data.values[this.field1] as string) +
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
parseInt(data.values[this.field2] as string)
|
|
||||||
}</strong></div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new (class TestComplexForm extends Form {
|
|
||||||
defaultSuccessMessage = "Formularz wypełniony poprawnie";
|
|
||||||
|
|
||||||
fields = [
|
|
||||||
new FormField("A", true),
|
|
||||||
new FormField("B", true),
|
|
||||||
new FormField("C", true),
|
|
||||||
];
|
|
||||||
|
|
||||||
controls = [
|
|
||||||
new SimpleInput("A", { label: "A", type: "number" }),
|
|
||||||
new SimpleInput("B", { label: "B", type: "number" }),
|
|
||||||
new SimpleInput("C", { label: "B", type: "number" }),
|
|
||||||
new NumberSum("A", "B"),
|
|
||||||
new NumberSum("B", "C"),
|
|
||||||
];
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
async canAccess(_: BaseContext) {
|
|
||||||
return { canAccess: true, message: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
async onSubmit() {
|
|
||||||
//noop
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
async render(ctx: BaseContext, data: FormData, path: string) {
|
|
||||||
return html(ctx, "TestComplex", await super.render(ctx, data, path));
|
|
||||||
}
|
|
||||||
})();
|
|
1
src/back/routes/urls.ts
Normal file
1
src/back/routes/urls.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const HelloURL = "/hello/";
|
@ -1,25 +0,0 @@
|
|||||||
import { BaseContext } from "koa";
|
|
||||||
import { tempstream } from "tempstream";
|
|
||||||
import html from "../../html";
|
|
||||||
import { Page } from "../../page/page";
|
|
||||||
|
|
||||||
export const actionName = "MyProfile";
|
|
||||||
|
|
||||||
export default new (class MyProfilePage extends Page {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
async canAccess(_: BaseContext) {
|
|
||||||
return { canAccess: true, message: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
async render(ctx: BaseContext) {
|
|
||||||
const user = await ctx.$context.getUserData(ctx.$app);
|
|
||||||
if (!user) {
|
|
||||||
return "User not found";
|
|
||||||
}
|
|
||||||
return html(
|
|
||||||
ctx,
|
|
||||||
"Mój profil",
|
|
||||||
tempstream`<div>Welcome, ${user.get("username")}!</div>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})();
|
|
@ -1,17 +0,0 @@
|
|||||||
import { withProdApp } from "../../test_utils/with-prod-app";
|
|
||||||
import { LONG_TEST_TIMEOUT, webhintURL } from "../../test_utils/webhint";
|
|
||||||
import { MyProfileURL } from "../routes";
|
|
||||||
|
|
||||||
describe("MyProfile", () => {
|
|
||||||
it("doesn't crash", async function () {
|
|
||||||
this.timeout(LONG_TEST_TIMEOUT);
|
|
||||||
return withProdApp(async ({ base_url, rest_api }) => {
|
|
||||||
await rest_api.get(MyProfileURL);
|
|
||||||
await webhintURL(base_url + MyProfileURL);
|
|
||||||
// alternatively you can use webhintHTML for faster but less precise scans
|
|
||||||
// or for scanning responses of requests that use some form of authorization:
|
|
||||||
// const response = await rest_api.get(MyProfileURL);
|
|
||||||
// await webhintHTML(response);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -6,14 +6,6 @@ const locreq = _locreq(__dirname);
|
|||||||
import Sealious, { SMTPMailer } from "sealious";
|
import Sealious, { SMTPMailer } from "sealious";
|
||||||
import { TestUtils } from "sealious";
|
import { TestUtils } from "sealious";
|
||||||
|
|
||||||
declare module "koa" {
|
|
||||||
interface BaseContext {
|
|
||||||
$context: Sealious.Context;
|
|
||||||
$app: TheApp;
|
|
||||||
$body: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function withProdApp(
|
export async function withProdApp(
|
||||||
callback: (args: {
|
callback: (args: {
|
||||||
app: TheApp;
|
app: TheApp;
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
// DO NOT EDIT! This file is generated automaticaly with npx sealgen generate-scss-includes
|
// DO NOT EDIT! This file is generated automaticaly with npx sealgen generate-scss-includes
|
||||||
|
|
||||||
@import "back/forms/forms.scss";
|
|
||||||
@import "back/routes/common/ui/input.scss";
|
@import "back/routes/common/ui/input.scss";
|
||||||
|
Loading…
x
Reference in New Issue
Block a user