Set up frontend dev tooling (#6)

* Add eslint for linting js (https://github.com/ikreymer/browsertrix-cloud/pull/7)
* Add prettier for formatting frontend files (https://github.com/ikreymer/browsertrix-cloud/pull/9)
* Add frontend testing framework (https://github.com/ikreymer/browsertrix-cloud/pull/10)

closes #4, closes #5
This commit is contained in:
sua yoo 2021-11-18 17:26:10 -08:00 committed by GitHub
parent 57a4b6b46f
commit 0f97724ad0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 2140 additions and 453 deletions

27
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
## editors
/.idea
/.vscode
## system files
.DS_Store
## npm
/node_modules/
/npm-debug.log
## testing
/coverage/
## temp folders
/.tmp/
/tmp/
# build
/dist/
# dotenv
.env.local
.env.*.local
storybook-static
custom-elements.json

42
frontend/README.md Normal file
View File

@ -0,0 +1,42 @@
# Browsertrix Cloud frontend
## Quickstart
Install dependencies:
```sh
yarn
```
Start the dev server:
```sh
yarn start-dev
```
This will open `localhost:9870` in a new tab in your default browser.
## Scripts
| `yarn <name>` | |
| ------------- | ------------------------------------------------------------------- |
| `start-dev` | runs app in development server, reloading on file changes |
| `test` | runs tests in chromium with playwright |
| `build-dev` | bundles app and outputs it in `dist` directory |
| `build` | bundles app app, optimized for production, and outputs it to `dist` |
| `lint` | find and fix auto-fixable javascript errors |
| `format` | formats js, html and css files |
## Testing
Tests assertions are written in [Chai](https://www.chaijs.com/api/bdd/).
To watch for file changes while running tests:
```sh
yarn test --watch
```
To run tests in multiple browsers:
```sh
yarn test --browsers chromium firefox webkit
```

258
frontend/dist/main.js vendored

File diff suppressed because one or more lines are too long

View File

@ -6,6 +6,8 @@
<script src="/main.js"></script>
</head>
<body class="min-w-screen min-h-screen">
<browsertrix-app class="flex flex-col min-h-screen bg-blue-400"></browsertrix-app>
<browsertrix-app
class="flex flex-col min-h-screen bg-blue-400"
></browsertrix-app>
</body>
</html>

View File

@ -3,6 +3,7 @@
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"private": true,
"dependencies": {
"axios": "^0.22.0",
"daisyui": "^1.14.2",
@ -12,18 +13,55 @@
"tailwindcss": "^2.2.16"
},
"scripts": {
"test": "web-test-runner \"src/**/*.test.js\" --node-resolve --playwright --browsers chromium",
"build": "webpack --mode production",
"build-dev": "webpack --mode development",
"start-dev": "webpack serve --mode=development"
"start-dev": "webpack serve --mode=development",
"lint": "eslint --fix \"src/**/*.js\"",
"format": "prettier --write \"**/*.{js,html,css}\""
},
"devDependencies": {
"@esm-bundle/chai": "^4.3.4-fix.0",
"@web/dev-server-import-maps": "^0.0.6",
"@web/test-runner": "^0.13.22",
"@web/test-runner-playwright": "^0.8.8",
"autoprefixer": "^10.3.6",
"css-loader": "^6.3.0",
"eslint": "^8.2.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-lit": "^1.6.1",
"eslint-plugin-wc": "^1.3.2",
"eslint-webpack-plugin": "^3.1.1",
"postcss": "^8.3.8",
"postcss-loader": "^6.1.1",
"prettier": "^2.4.1",
"style-loader": "^3.3.0",
"webpack": "^5.56.0",
"webpack-cli": "^4.8.0",
"webpack-dev-server": "^4.3.0"
}
},
"eslintConfig": {
"env": {
"browser": true,
"commonjs": true,
"es2017": true
},
"extends": [
"plugin:wc/recommended",
"plugin:lit/recommended",
"prettier"
],
"plugins": [
"lit"
],
"rules": {
"no-restricted-globals": [
2,
"event",
"error"
],
"no-unused-vars": "warn"
}
},
"prettier": {}
}

View File

@ -1,6 +1,3 @@
module.exports = {
plugins: [
require('tailwindcss'),
require('autoprefixer'),
]
plugins: [require("tailwindcss"), require("autoprefixer")],
};

View File

@ -0,0 +1,10 @@
/**
* Use to mock css files in tests.
*
* Usage in web-test-runner.config.mjs:
* importMap: {
* imports: {
* 'styles.css': '/src/__mocks__/css.js'
* },
* },
*/

View File

@ -1,9 +1,7 @@
import { LiteElement, APIRouter, html } from "./utils";
// ===========================================================================
class App extends LiteElement
{
export class App extends LiteElement {
constructor() {
super();
this.authState = null;
@ -14,11 +12,11 @@ class App extends LiteElement
}
this.router = new APIRouter({
"home": "/",
"login": "/log-in",
home: "/",
login: "/log-in",
"my-account": "/my-account",
"archive-info": "/archive/:aid",
"archive-info-tab": "/archive/:aid/:tab"
"archive-info-tab": "/archive/:aid/:tab",
});
this.viewState = this.router.match(window.location.pathname);
@ -27,8 +25,8 @@ class App extends LiteElement
static get properties() {
return {
viewState: { type: Object },
authState: { type: Object }
}
authState: { type: Object },
};
}
firstUpdated() {
@ -50,7 +48,7 @@ class App extends LiteElement
if (this.viewState._route === "login") {
this.clearAuthState();
}
//console.log(this.view._route, window.location.href);
//console.log(this.view._route, window.location.href);
window.history.pushState(this.viewState, "", this.viewState._path);
}
@ -61,45 +59,72 @@ class App extends LiteElement
render() {
return html`
${this.renderNavBar()}
<div class="w-full h-full px-12 py-12">
${this.renderPage()}
</div>
${this.renderNavBar()}
<div class="w-full h-full px-12 py-12">${this.renderPage()}</div>
`;
}
renderNavBar() {
return html`
<div class="navbar shadow-lg bg-neutral text-neutral-content">
<div class="flex-1 px-2 mx-2">
<a href="/" class="link link-hover text-lg font-bold" @click="${this.navLink}">Browsertrix Cloud</a>
</div>
<div class="flex-none">
${this.authState ? html`
<a class="link link-hover font-bold px-4" href="/my-account" @click="${this.navLink}">My Account</a>
<button class="btn btn-error" @click="${this.onLogOut}">Log Out</button>`
: html`
<button class="btn ${this.viewState._route !== "login" ? "btn-primary" : "btn-ghost"}" @click="${this.onNeedLogin}">Log In</button>
`}
<div class="navbar shadow-lg bg-neutral text-neutral-content">
<div class="flex-1 px-2 mx-2">
<a
href="/"
class="link link-hover text-lg font-bold"
@click="${this.navLink}"
>Browsertrix Cloud</a
>
</div>
<div class="flex-none">
${this.authState
? html` <a
class="link link-hover font-bold px-4"
href="/my-account"
@click="${this.navLink}"
>My Account</a
>
<button class="btn btn-error" @click="${this.onLogOut}">
Log Out
</button>`
: html`
<button
class="btn ${this.viewState._route !== "login"
? "btn-primary"
: "btn-ghost"}"
@click="${this.onNeedLogin}"
>
Log In
</button>
`}
</div>
</div>
</div>
`;
}
renderPage() {
switch (this.viewState._route) {
case "login":
return html`<log-in @logged-in="${this.onLoggedIn}">`;
return html`<log-in @logged-in="${this.onLoggedIn}"></log-in>`;
case "home":
return html`<div>Home</div>`;
case "my-account":
return html`<my-account @navigate="${this.onNavigateTo}" @need-login="${this.onNeedLogin}" .authState="${this.authState}"></my-account>`;
return html`<my-account
@navigate="${this.onNavigateTo}"
@need-login="${this.onNeedLogin}"
.authState="${this.authState}"
></my-account>`;
case "archive-info":
case "archive-info-tab":
return html`<btrix-archive @navigate="${this.onNavigateTo}" .authState="${this.authState}" .viewState="${this.viewState}" aid="${this.viewState.aid}" tab="${this.viewState.tab || "running"}"></btrix-archive>`;
return html`<btrix-archive
@navigate="${this.onNavigateTo}"
.authState="${this.authState}"
.viewState="${this.viewState}"
aid="${this.viewState.aid}"
tab="${this.viewState.tab || "running"}"
></btrix-archive>`;
default:
return html`<div>Not Found!</div>`;
@ -114,7 +139,7 @@ class App extends LiteElement
onLoggedIn(event) {
this.authState = {
username: event.detail.username,
headers: {"Authorization": event.detail.auth}
headers: { Authorization: event.detail.auth },
};
window.localStorage.setItem("authState", JSON.stringify(this.authState));
this.navigate("/my-account");
@ -135,10 +160,8 @@ class App extends LiteElement
}
}
// ===========================================================================
class LogIn extends LiteElement
{
class LogIn extends LiteElement {
constructor() {
super();
this.loginError = "";
@ -146,40 +169,50 @@ class LogIn extends LiteElement
static get properties() {
return {
loginError: { type: String }
}
loginError: { type: String },
};
}
render() {
return html`
<div class="hero min-h-screen bg-blue-400">
<div class="text-center hero-content bg-base-200 shadow-2xl rounded-xl px-16 py-8">
<div class="max-w-md">
<form action="" @submit="${this.onSubmit}">
<div class="form-control">
<label class="label">
<span class="label-text">User</span>
</label>
<input id="username" name="username" type="text" placeholder="Username" class="input input-bordered">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Password</span>
</label>
<input id="password" name="password" type="password" placeholder="Password" class="input input-bordered">
</div>
<div class="form-control py-4">
<button class="btn btn-primary" type="submit">Log In</button>
</div>
</form>
<div id="login-error" class="text-red-600">
${this.loginError}
<div class="hero min-h-screen bg-blue-400">
<div
class="text-center hero-content bg-base-200 shadow-2xl rounded-xl px-16 py-8"
>
<div class="max-w-md">
<form action="" @submit="${this.onSubmit}">
<div class="form-control">
<label class="label">
<span class="label-text">User</span>
</label>
<input
id="username"
name="username"
type="text"
placeholder="Username"
class="input input-bordered"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Password</span>
</label>
<input
id="password"
name="password"
type="password"
placeholder="Password"
class="input input-bordered"
/>
</div>
<div class="form-control py-4">
<button class="btn btn-primary" type="submit">Log In</button>
</div>
</form>
<div id="login-error" class="text-red-600">${this.loginError}</div>
</div>
</div>
</div>
</div>
`;
}
@ -193,9 +226,13 @@ class LogIn extends LiteElement
params.set("username", username);
params.set("password", this.querySelector("#password").value);
const headers = {"Content-Type": "application/x-www-form-urlencoded"}
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
const resp = await fetch("/api/auth/jwt/login", {headers, method: "POST", body: params.toString()});
const resp = await fetch("/api/auth/jwt/login", {
headers,
method: "POST",
body: params.toString(),
});
if (resp.status !== 200) {
this.loginError = "Sorry, invalid credentials";
return;
@ -205,12 +242,11 @@ class LogIn extends LiteElement
const data = await resp.json();
if (data.token_type === "bearer" && data.access_token) {
const auth = "Bearer " + data.access_token;
const detail = {auth, username};
this.dispatchEvent(new CustomEvent("logged-in", {detail}));
const detail = { auth, username };
this.dispatchEvent(new CustomEvent("logged-in", { detail }));
}
} catch(e) {
} catch (e) {
console.error(e);
}
if (!this.auth) {
@ -219,10 +255,8 @@ class LogIn extends LiteElement
}
}
// ===========================================================================
class MyAccount extends LiteElement
{
class MyAccount extends LiteElement {
constructor() {
super();
this.archiveList = [];
@ -232,8 +266,8 @@ class MyAccount extends LiteElement
return {
authState: { type: Object },
archiveList: { type: Array },
id: { type: String }
}
id: { type: String },
};
}
async firstUpdated() {
@ -251,22 +285,31 @@ class MyAccount extends LiteElement
render() {
return html`
<div class="container bg-base-200 m-auto border rounded-lg px-8 py-8">
<h2 class="text-2xl font-bold">Your Archives</h2>
${this.archiveList.map(archive => html`
<div class="card mt-6 ml-6 border rounded-none border-gray-600 hover:bg-gray-300">
<div class="card-body">
<div class="card-title">
<span class="mr-4">${archive.name}</span>${this.getAccessValue(archive)}
</div>
<div class="card-actions">
<a class="btn btn-primary" href="/archive/${archive.id}" @click="${this.navLink}">View Archive</a>
</div>
</div>
</div>
`)}
</div>
<div class="container bg-base-200 m-auto border rounded-lg px-8 py-8">
<h2 class="text-2xl font-bold">Your Archives</h2>
${this.archiveList.map(
(archive) => html`
<div
class="card mt-6 ml-6 border rounded-none border-gray-600 hover:bg-gray-300"
>
<div class="card-body">
<div class="card-title">
<span class="mr-4">${archive.name}</span
>${this.getAccessValue(archive)}
</div>
<div class="card-actions">
<a
class="btn btn-primary"
href="/archive/${archive.id}"
@click="${this.navLink}"
>View Archive</a
>
</div>
</div>
</div>
`
)}
</div>
`;
}
@ -283,56 +326,78 @@ class MyAccount extends LiteElement
}
// ===========================================================================
class Archive extends LiteElement
{
class Archive extends LiteElement {
static get properties() {
return {
authState: { type: Object },
aid: {type: String},
tab: {type: String},
viewState: { type: Object }
}
aid: { type: String },
tab: { type: String },
viewState: { type: Object },
};
}
render() {
const aid = this.aid;
const tab = this.tab || "running";
return html`
<div class="container bg-base-200 m-auto border shadow-xl rounded-lg px-8 py-8">
<div class="tabs tabs-boxed">
<a href="/archive/${aid}/running" class="tab ${tab === "running" ? 'tab-active' : ''}" @click="${this.navLink}">Crawls Running</a>
<a href="/archive/${aid}/finished" class="tab ${tab === "finished" ? 'tab-active' : ''}" @click="${this.navLink}">Finished</a>
<a href="/archive/${aid}/configs" class="tab ${tab === "configs" ? 'tab-active' : ''}" @click="${this.navLink}">Crawl Configs</a>
<div
class="container bg-base-200 m-auto border shadow-xl rounded-lg px-8 py-8"
>
<div class="tabs tabs-boxed">
<a
href="/archive/${aid}/running"
class="tab ${tab === "running" ? "tab-active" : ""}"
@click="${this.navLink}"
>Crawls Running</a
>
<a
href="/archive/${aid}/finished"
class="tab ${tab === "finished" ? "tab-active" : ""}"
@click="${this.navLink}"
>Finished</a
>
<a
href="/archive/${aid}/configs"
class="tab ${tab === "configs" ? "tab-active" : ""}"
@click="${this.navLink}"
>Crawl Configs</a
>
</div>
${tab === "configs"
? html`<btrix-archive-configs
.archive=${this}
></btrix-archive-configs>`
: ""}
</div>
${tab === "configs" ?
html`<btrix-archive-configs .archive=${this}></btrix-archive-configs>` : ""}
</div>
`;
}
}
// ===========================================================================
class ArchiveConfigs extends LiteElement
{
class ArchiveConfigs extends LiteElement {
static get properties() {
return {
archive: { type: Object },
configs: { type: Array }
}
configs: { type: Array },
};
}
async firstUpdated() {
const res = await this.apiFetch(`/archives/${this.archive.aid}/crawlconfigs`, this.archive.authState);
const res = await this.apiFetch(
`/archives/${this.archive.aid}/crawlconfigs`,
this.archive.authState
);
this.configs = res.crawl_configs;
}
render() {
return html`<div>Archive Configs!</div>
${this.configs && this.configs.map((config) => html`
<div>${config.crawlCount} ${config.config.seeds}</div>
`)}
`;
${this.configs &&
this.configs.map(
(config) => html`
<div>${config.crawlCount} ${config.config.seeds}</div>
`
)} `;
}
}
@ -342,4 +407,3 @@ customElements.define("log-in", LogIn);
customElements.define("my-account", MyAccount);
customElements.define("btrix-archive", Archive);
customElements.define("btrix-archive-configs", ArchiveConfigs);

View File

@ -0,0 +1,9 @@
import { expect } from "@esm-bundle/chai";
import { App } from "./index.js";
describe("App", () => {
it("should exist", () => {
expect(App).to.exist;
});
});

View File

@ -3,25 +3,29 @@ import "tailwindcss/tailwind.css";
import { LitElement, html } from "lit";
import { Path } from "path-parser";
// ===========================================================================
export class LiteElement extends LitElement
{
export class LiteElement extends LitElement {
createRenderRoot() {
return this;
}
navTo(url) {
this.dispatchEvent(new CustomEvent("navigate", {detail: url}));
this.dispatchEvent(new CustomEvent("navigate", { detail: url }));
}
navLink(event) {
event.preventDefault();
this.dispatchEvent(new CustomEvent("navigate", {detail: event.currentTarget.href, bubbles: true, composed: true}));
this.dispatchEvent(
new CustomEvent("navigate", {
detail: event.currentTarget.href,
bubbles: true,
composed: true,
})
);
}
async apiFetch(path, auth) {
const resp = await fetch("/api" + path, {headers: auth.headers});
const resp = await fetch("/api" + path, { headers: auth.headers });
if (resp.status !== 200) {
this.navTo("/log-in");
throw new Error("logged out");
@ -30,7 +34,6 @@ export class LiteElement extends LitElement
}
}
// ===========================================================================
export class APIRouter {
constructor(paths) {
@ -54,8 +57,8 @@ export class APIRouter {
return res;
}
}
return {_route: null, _path: path};
return { _route: null, _path: path };
}
}

View File

@ -1,18 +1,14 @@
module.exports = {
mode: 'jit',
mode: "jit",
purge: {
content: ['./*.html', './src/*.js'],
content: ["./*.html", "./src/*.js"],
options: {
safelist: [
/data-theme$/,
]
safelist: [/data-theme$/],
},
},
plugins: [
require('daisyui')
],
plugins: [require("daisyui")],
extract: {
include: ['./src/*.js'],
include: ["./src/*.js"],
},
};

View File

@ -0,0 +1,15 @@
import { importMapsPlugin } from '@web/dev-server-import-maps';
export default {
plugins: [
importMapsPlugin({
inject: {
importMap: {
imports: {
'tailwindcss/tailwind.css': '/src/__mocks__/css.js',
},
},
},
}),
],
};

View File

@ -1,6 +1,6 @@
// webpack.config.js
const path = require("path")
const path = require("path");
const ESLintPlugin = require("eslint-webpack-plugin");
const backendUrl = new URL("http://btrix.cloud/");
@ -9,7 +9,7 @@ module.exports = {
output: {
path: path.resolve(__dirname, "dist"),
filename: "main.js",
publicPath: "/"
publicPath: "/",
},
module: {
@ -24,6 +24,7 @@ module.exports = {
},
],
},
devServer: {
watchFiles: ["src/*.js"],
open: true,
@ -32,18 +33,28 @@ module.exports = {
static: {
directory: path.join(__dirname),
//publicPath: "/",
watch: true
watch: true,
},
historyApiFallback: true,
proxy: {
'/api': {
"/api": {
target: backendUrl.href,
headers: {
'Host': backendUrl.host
},
pathRewrite: { '^/api': '' },
Host: backendUrl.host,
},
pathRewrite: { "^/api": "" },
},
},
port: 9870
port: 9870,
},
}
plugins: [
// Lint js files
new ESLintPlugin({
// lint only changed files:
lintDirtyModulesOnly: true,
// enable to auto-fix source files:
// fix: true
}),
],
};

File diff suppressed because it is too large Load Diff