├── static ├── healthcheck.html └── favicon.ico ├── src ├── helper │ ├── response.ts │ ├── sleep.ts │ ├── random.ts │ ├── debounce.ts │ ├── submit.ts │ ├── mock │ │ ├── browser.ts │ │ └── handler.ts │ ├── global.ts │ └── cookiestore.ts ├── global.scss ├── component │ ├── reference │ │ ├── block.ts │ │ ├── block.stories.ts │ │ ├── controls.stories.ts │ │ └── flex.stories.tsx │ ├── simple-page.ts │ ├── input │ │ ├── input.ts │ │ └── input.stories.ts │ ├── simple-page.stories.ts │ ├── breadcrumb │ │ ├── breadcrumb.stories.ts │ │ └── breadcrumb.ts │ └── flash │ │ ├── flash.ts │ │ └── flash.stories.ts ├── layout │ ├── footer │ │ ├── footer.scss │ │ └── footer.ts │ ├── side-menu │ │ ├── side-menu.scss │ │ ├── side-menu.stories.ts │ │ └── side-menu.ts │ ├── nav │ │ ├── nav.stories.ts │ │ └── nav.ts │ ├── main.tsx │ ├── dashboard.ts │ └── main.stories.ts ├── page │ ├── home │ │ ├── home.stories.ts │ │ └── home.ts │ ├── about │ │ ├── about.stories.ts │ │ └── about.ts │ ├── error │ │ ├── error.stories.ts │ │ └── error.ts │ ├── login │ │ ├── loginstore.ts │ │ ├── login.stories.ts │ │ └── login.ts │ ├── register │ │ ├── registerstore.ts │ │ ├── register.stories.ts │ │ └── register.ts │ └── notepad │ │ ├── note.stories.ts │ │ ├── note.ts │ │ ├── notepad.stories.ts │ │ ├── notestore.ts │ │ └── notepad.ts ├── index.ts └── e2e.spec.ts ├── .vscode ├── extensions.json ├── typescript.code-snippets └── settings.json ├── .prettierrc ├── .storybook ├── manager.js ├── preview.js ├── main.js └── static │ └── mockServiceWorker.js ├── .cypress ├── fixtures │ └── example.json └── support │ ├── index.ts │ └── commands.ts ├── .gitignore ├── babel.config.json ├── .eslintignore ├── .stylelintrc ├── jsconfig.json ├── declaration.d.ts ├── cypress.json ├── tsconfig.json ├── LICENSE ├── .eslintrc.json ├── package.json ├── webpack.config.js └── README.md /static/healthcheck.html: -------------------------------------------------------------------------------- 1 | ok -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josephspurrier/mithril-template/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /src/helper/response.ts: -------------------------------------------------------------------------------- 1 | export interface GenericResponse { 2 | status: string; 3 | message?: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/global.scss: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | @import '~bulma/bulma'; 4 | @import '~@fortawesome/fontawesome-free/scss/fontawesome'; 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "stylelint.vscode-stylelint", 5 | ], 6 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/prettierrc", 3 | "singleQuote": true, 4 | "jsxSingleQuote": true, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /src/helper/sleep.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (milliseconds: number): Promise => { 2 | return new Promise((resolve) => setTimeout(resolve, milliseconds)); 3 | }; 4 | -------------------------------------------------------------------------------- /.storybook/manager.js: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/addons'; 2 | import { themes } from '@storybook/theming'; 3 | 4 | addons.setConfig({ 5 | theme: themes.normal, 6 | }); 7 | -------------------------------------------------------------------------------- /src/component/reference/block.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | 3 | export const Block = (): m.Component => { 4 | return { 5 | view: ({ children }) => m('div', children), 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /.cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /src/helper/random.ts: -------------------------------------------------------------------------------- 1 | export const randId = (): string => { 2 | const min = 1; 3 | const max = 99999999999999; 4 | const randomNum = Math.random() * (max - min) + min; 5 | return Math.floor(randomNum).toString(); 6 | }; 7 | -------------------------------------------------------------------------------- /src/layout/footer/footer.scss: -------------------------------------------------------------------------------- 1 | // Support footer. 2 | :local(.containerForFooter) { 3 | display: flex; 4 | min-height: 100vh; 5 | flex-direction: column; 6 | } 7 | 8 | :local(.beforeFooter) { 9 | flex: 1; 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS files. 2 | .DS_Store 3 | thumbs.db 4 | 5 | # Environment files. 6 | .envrc 7 | 8 | # Dependency and compiled directories. 9 | node_modules/ 10 | dist/ 11 | 12 | # Screenshots from Cypress. 13 | .cypress/screenshots -------------------------------------------------------------------------------- /src/layout/side-menu/side-menu.scss: -------------------------------------------------------------------------------- 1 | :local(.local) { 2 | // Formatting for sidebar with icons. 3 | .aside .icon:first-child { 4 | margin-right: 0.5em; 5 | } 6 | 7 | .aside a { 8 | align-items: center; 9 | display: flex; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ], 5 | "plugins": [ 6 | [ 7 | "@babel/plugin-transform-react-jsx", 8 | { 9 | "pragma": "m", 10 | "pragmaFrag": "'['" 11 | } 12 | ] 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/layout/nav/nav.stories.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { Nav } from '@/layout/nav/nav'; 3 | 4 | export default { 5 | title: 'Component/Nav', 6 | component: Nav, 7 | }; 8 | 9 | export const menu = (): m.Component => ({ 10 | view: () => m(Nav), 11 | }); 12 | -------------------------------------------------------------------------------- /src/page/home/home.stories.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { HomePage } from '@/page/home/home'; 3 | 4 | export default { 5 | title: 'View/Home', 6 | component: HomePage, 7 | }; 8 | 9 | export const home = (): m.Component => ({ 10 | view: () => m(HomePage), 11 | }); 12 | -------------------------------------------------------------------------------- /src/page/about/about.stories.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { AboutPage } from '@/page/about/about'; 3 | 4 | export default { 5 | title: 'View/About', 6 | component: AboutPage, 7 | }; 8 | 9 | export const about = (): m.Component => ({ 10 | view: () => m(AboutPage), 11 | }); 12 | -------------------------------------------------------------------------------- /src/page/error/error.stories.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { ErrorPage } from '@/page/error/error'; 3 | 4 | export default { 5 | title: 'View/Error', 6 | component: ErrorPage, 7 | }; 8 | 9 | export const error = (): m.Component => ({ 10 | view: () => m(ErrorPage), 11 | }); 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Include Cypress. 2 | !/.cypress 3 | 4 | # Include Storybook. 5 | # https://github.com/eslint/eslint/issues/8429 6 | !/.storybook 7 | 8 | # Skip Storybook generated files. 9 | .storybook/*-entry.js 10 | .storybook/static/mockServiceWorker.js 11 | 12 | # Skip generated files. 13 | dist -------------------------------------------------------------------------------- /src/helper/debounce.ts: -------------------------------------------------------------------------------- 1 | const m = new Map>(); 2 | 3 | export const debounce = ( 4 | id: string, 5 | func: () => void, 6 | timeout: number, 7 | ): void => { 8 | const timer = m.get(id); 9 | if (timer) { 10 | clearTimeout(timer); 11 | } 12 | m.set(id, setTimeout(func, timeout)); 13 | }; 14 | -------------------------------------------------------------------------------- /src/page/error/error.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { SimplePage } from '@/component/simple-page'; 3 | 4 | export const ErrorPage: m.ClosureComponent = () => { 5 | return { 6 | view: () => 7 | m(SimplePage, { 8 | title: 'Error', 9 | description: 'The page is not found.', 10 | }), 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/stylelintrc.json", 3 | "plugins": [ 4 | "stylelint-scss" 5 | ], 6 | "extends": [ 7 | "stylelint-config-standard", 8 | "stylelint-config-css-modules" 9 | ], 10 | "rules": { 11 | "at-rule-no-unknown": null, 12 | "scss/at-rule-no-unknown": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/helper/submit.ts: -------------------------------------------------------------------------------- 1 | let disabled = false; 2 | const submitText = 'Submitting...'; 3 | 4 | export const start = function (event: { preventDefault: () => void }): void { 5 | event.preventDefault(); 6 | disabled = true; 7 | }; 8 | 9 | export const finish = function (): void { 10 | disabled = false; 11 | }; 12 | 13 | export const text = function (s: string): string { 14 | return !disabled ? s : submitText; 15 | }; 16 | -------------------------------------------------------------------------------- /src/layout/side-menu/side-menu.stories.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { SideMenu } from '@/layout/side-menu/side-menu'; 3 | 4 | export default { 5 | title: 'Component/Side Menu', 6 | }; 7 | 8 | export const sideMenu = (): m.Component => ({ 9 | view: () => 10 | m('div', { class: 'columns mt-4 ml-4' }, [ 11 | m('div', { class: 'column is-one-third' }, m(SideMenu)), 12 | m('div', { class: 'column has-background-grey-lighter' }, 'Content'), 13 | ]), 14 | }); 15 | -------------------------------------------------------------------------------- /src/component/simple-page.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | 3 | interface Attrs { 4 | title?: string; 5 | description?: string; 6 | } 7 | 8 | export const SimplePage: m.Component = { 9 | view: ({ attrs, children }) => 10 | m('div', [ 11 | m('section', [ 12 | m('div', { class: 'container is-fluid mt-4' }, [ 13 | m('h1', { class: 'title' }, attrs.title), 14 | m('h2', { class: 'subtitle' }, attrs.description), 15 | children, 16 | ]), 17 | ]), 18 | ]), 19 | }; 20 | -------------------------------------------------------------------------------- /src/layout/main.tsx: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { Nav } from '@/layout/nav/nav'; 3 | import { Footer } from '@/layout/footer/footer'; 4 | import { Flash } from '@/component/flash/flash'; 5 | import style from '@/layout/footer/footer.scss'; 6 | 7 | export const MainLayout = (): m.Component => { 8 | return { 9 | view: ({ children }) => ( 10 |
11 | {m(Nav)} 12 |
{children}
13 | {m(Footer)} 14 | {m(Flash)} 15 |
16 | ), 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /src/helper/mock/browser.ts: -------------------------------------------------------------------------------- 1 | import { setupWorker } from 'msw'; 2 | import { handlers } from '@/helper/mock/handler'; 3 | import { Global } from '@/helper/global'; 4 | 5 | // This configures a Service Worker with the given request handlers. 6 | export const worker = setupWorker(...handlers); 7 | 8 | export const setup = (): void => { 9 | // Enable mock server if set. 10 | if (Global.mockServer) { 11 | // Start mocking when the application starts. 12 | worker.start().catch((err) => { 13 | console.log('Error starting the service worker:', err); 14 | }); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/jsconfig", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "CommonJS", 6 | "allowSyntheticDefaultImports": true, 7 | "baseUrl": "./", 8 | "paths": { 9 | "@/*": [ 10 | "src/*" 11 | ], 12 | "~/*": [ 13 | "*" 14 | ] 15 | }, 16 | "checkJs": true 17 | }, 18 | "include": [ 19 | "*", 20 | "src/**/*", 21 | ".storybook/**/*", 22 | ".cypress/**/*" 23 | ], 24 | "exclude": [ 25 | ".storybook/static/mockServiceWorker.js" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /declaration.d.ts: -------------------------------------------------------------------------------- 1 | // Support SCSS files in TypeScript. 2 | // https://medium.com/@thetrevorharmon/how-to-silence-false-sass-warnings-in-react-16d2a7158aff 3 | declare module '*.scss' { 4 | export const content: { [className: string]: string }; 5 | export default content; 6 | } 7 | 8 | // Support resetDB() command in Cypress files. 9 | /// 10 | declare namespace Cypress { 11 | interface Chainable { 12 | /** 13 | * Custom command to select DOM element by data-cy attribute. 14 | * @example cy.resetDB() 15 | */ 16 | resetDB(): Chainable; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/cypress-io/cypress/develop/cli/schema/cypress.schema.json", 3 | "baseUrl": "http://localhost:8080", 4 | "viewportWidth": 1280, 5 | "viewportHeight": 1024, 6 | "responseTimeout": 5000, 7 | "defaultCommandTimeout": 500, 8 | "video": false, 9 | "trashAssetsBeforeRuns": true, 10 | "testFiles": "**/*.spec.ts", 11 | "fixturesFolder": ".cypress/fixtures", 12 | "integrationFolder": "src", 13 | "pluginsFile": false, 14 | "screenshotsFolder": ".cypress/screenshots", 15 | "supportFile": ".cypress/support/index.ts", 16 | "videosFolder": ".cypress/videos" 17 | } 18 | -------------------------------------------------------------------------------- /src/layout/footer/footer.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | 3 | // https://bulma.io/documentation/layout/footer/ 4 | 5 | export const Footer: m.ClosureComponent = () => { 6 | return { 7 | view: () => 8 | m( 9 | 'footer', 10 | { 11 | class: 'footer', 12 | style: { 13 | color: '#999', 14 | background: '#404040', 15 | padding: '1rem 1rem 1rem', 16 | }, 17 | }, 18 | m( 19 | 'div', 20 | { class: 'content has-text-centered' }, 21 | m('p', 'Copyright © 2020 Your Company. All rights reserved.'), 22 | ), 23 | ), 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /.cypress/support/index.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js to make them available for use in test files. 17 | import '~/.cypress/support/commands'; 18 | -------------------------------------------------------------------------------- /src/helper/global.ts: -------------------------------------------------------------------------------- 1 | declare let __API_SCHEME__: string; 2 | declare let __API_HOST__: string; 3 | declare let __API_PORT__: number; 4 | declare let __PRODUCTION__: boolean; 5 | declare let __VERSION__: string; 6 | declare let __MOCK_SERVER__: boolean; 7 | //declare let STORYBOOK_ENV: string | undefined; 8 | 9 | export const Global = { 10 | //storybookMode: STORYBOOK_ENV ? STORYBOOK_ENV === "mithril" : false, 11 | apiScheme: __API_SCHEME__, 12 | apiHost: __API_HOST__, 13 | apiPort: __API_PORT__, 14 | version: __VERSION__, 15 | production: __PRODUCTION__, 16 | mockServer: __MOCK_SERVER__, 17 | }; 18 | 19 | export const apiServer = (): string => { 20 | return `${Global.apiScheme}://${Global.apiHost}:${Global.apiPort}`; 21 | }; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "CommonJS", 6 | "esModuleInterop": true, 7 | "baseUrl": "./", 8 | "strict": true, 9 | "paths": { 10 | "@/*": [ 11 | "src/*" 12 | ], 13 | "~/*": [ 14 | "*" 15 | ] 16 | }, 17 | "outDir": "./dist/", 18 | "jsx": "react", 19 | "jsxFactory": "m", 20 | "allowJs": true, 21 | "checkJs": true, 22 | "types": [ 23 | "cypress" 24 | ] 25 | }, 26 | "include": [ 27 | "*", 28 | "src/**/*", 29 | ".storybook/**/*", 30 | ".cypress/**/*" 31 | ], 32 | "exclude": [ 33 | ".storybook/static/mockServiceWorker.js" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /src/page/home/home.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | 3 | export const HomePage: m.ClosureComponent = () => { 4 | return { 5 | view: () => 6 | m('div', [ 7 | m('section', { class: 'hero is-primary' }, [ 8 | m('div', { class: 'hero-body' }, [ 9 | m('div', { class: 'container is-fluid' }, [ 10 | m('h1', { class: 'title' }, 'Welcome'), 11 | m('h2', { class: 'subtitle' }, 'Login was successful'), 12 | ]), 13 | ]), 14 | ]), 15 | m('section', m('div', { class: 'container is-fluid' }, content)), 16 | ]), 17 | }; 18 | }; 19 | 20 | const content = [ 21 | m('p', { class: 'mt-4' }, [ 22 | 'Check out your ', 23 | m(m.route.Link, { href: '/notepad' }, 'notepad'), 24 | ' when you get a chance!', 25 | ]), 26 | ]; 27 | -------------------------------------------------------------------------------- /src/component/input/input.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | 3 | interface Attrs { 4 | label: string; 5 | name: string; 6 | type?: string; 7 | required?: boolean; 8 | value: string; 9 | oninput: (e: { target: HTMLInputElement }) => void; 10 | } 11 | 12 | export const Input = (): m.Component => { 13 | return { 14 | view: ({ attrs }) => 15 | m('div', { class: 'field' }, [ 16 | m('label', { class: 'label' }, attrs.label), 17 | m('div', { class: 'control' }, [ 18 | m('input', { 19 | class: 'input', 20 | name: attrs.name, 21 | type: attrs.type, 22 | 'data-cy': attrs.name, 23 | required: attrs.required, 24 | oninput: attrs.oninput, 25 | value: attrs.value, 26 | }), 27 | ]), 28 | ]), 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /src/helper/cookiestore.ts: -------------------------------------------------------------------------------- 1 | import Cookie from 'js-cookie'; 2 | 3 | export interface Auth { 4 | accessToken: string; 5 | loggedIn: boolean; 6 | } 7 | 8 | const cookieName = 'auth'; 9 | 10 | export const save = (auth: Auth): void => { 11 | Cookie.set(cookieName, auth); 12 | }; 13 | 14 | export const clear = (): void => { 15 | Cookie.remove(cookieName); 16 | }; 17 | 18 | export const bearerToken = (): string => { 19 | const auth = Cookie.get(cookieName) as string; 20 | if (auth) { 21 | const at = JSON.parse(auth) as Auth; 22 | if (at) { 23 | return `Bearer ${at.accessToken}`; 24 | } 25 | } 26 | 27 | return ''; 28 | }; 29 | 30 | export const isLoggedIn = (): boolean => { 31 | try { 32 | const auth = Cookie.get(cookieName); 33 | return auth !== undefined; 34 | } catch (err) { 35 | console.log(err); 36 | } 37 | 38 | return false; 39 | }; 40 | -------------------------------------------------------------------------------- /src/layout/dashboard.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { Nav } from '@/layout/nav/nav'; 3 | import { SideMenu } from '@/layout/side-menu/side-menu'; 4 | import { Footer } from '@/layout/footer/footer'; 5 | import { Flash } from '@/component/flash/flash'; 6 | import style from '@/layout/footer/footer.scss'; 7 | 8 | export const DashboardLayout = (): m.Component => { 9 | return { 10 | view: ({ children }) => 11 | m( 12 | 'dashboard', 13 | { 14 | class: style.containerForFooter, 15 | }, 16 | [ 17 | m(Nav), 18 | m('div', { class: 'columns ' + style.beforeFooter }, [ 19 | m('div', { class: 'column is-2 is-hidden-mobile mt-4 ml-4' }, [ 20 | m(SideMenu), 21 | ]), 22 | m('div', { class: 'column' }, children), 23 | ]), 24 | m(Footer), 25 | m(Flash), 26 | ], 27 | ), 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/component/simple-page.stories.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { SimplePage } from '@/component/simple-page'; 3 | 4 | export default { 5 | title: 'Component/Simple Page', 6 | component: SimplePage, 7 | }; 8 | 9 | export const withContent = (args: { 10 | title: string; 11 | description: string; 12 | content: string; 13 | }): m.Component => ({ 14 | view: () => 15 | m( 16 | SimplePage, 17 | { 18 | title: args.title, 19 | description: args.description, 20 | }, 21 | args.content, 22 | ), 23 | }); 24 | withContent.args = { 25 | title: 'This is the title.', 26 | description: 'This is the description.', 27 | content: 'This is the content.', 28 | }; 29 | withContent.argTypes = { 30 | title: { name: 'Title', control: { type: 'text' } }, 31 | description: { name: 'Description', control: { type: 'text' } }, 32 | content: { name: 'Content', control: { type: 'text' } }, 33 | }; 34 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import '@storybook/addon-console'; 3 | import '~/node_modules/@fortawesome/fontawesome-free/js/all.js'; 4 | import '@/global.scss'; 5 | 6 | export const parameters = { 7 | controls: { expanded: false, hideNoControlsWarning: true }, 8 | }; 9 | 10 | // Storybook executes this module in both bootstap phase (Node) 11 | // and a story's runtime (browser). However, cannot call `setupWorker` 12 | // in Node environment, so need to check if we're in a browser. 13 | if (typeof global.process === 'undefined') { 14 | const { worker } = require('../src/helper/mock/browser'); 15 | 16 | // Start the mocking when each story is loaded. 17 | // Repetitive calls to the `.start()` method do not register a new worker, 18 | // but check whether there's an existing once, reusing it, if so. 19 | worker.start({ onUnhandledRequest: 'warn' }).catch((err) => { 20 | console.log('Error starting the service worker:', err); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/page/about/about.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { Breadcrumb } from '@/component/breadcrumb/breadcrumb'; 3 | import { SimplePage } from '@/component/simple-page'; 4 | 5 | export const AboutPage: m.ClosureComponent = () => { 6 | return { 7 | view: () => 8 | m('div', [ 9 | m(Breadcrumb, { 10 | levels: [ 11 | { icon: 'fa-home', name: 'Welcome', url: '/' }, 12 | { 13 | icon: 'fa-address-card', 14 | name: 'About', 15 | url: location.pathname, 16 | }, 17 | ], 18 | }), 19 | m( 20 | SimplePage, 21 | { 22 | title: 'About', 23 | }, 24 | [ 25 | m('div', [ 26 | 'This shows you how to build a website using ', 27 | m('strong', 'Mithril'), 28 | ' and ', 29 | m('strong', 'Bulma'), 30 | '.', 31 | ]), 32 | ], 33 | ), 34 | ]), 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /src/layout/main.stories.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { MainLayout } from '@/layout/main'; 3 | import { SimplePage } from '@/component/simple-page'; 4 | 5 | export default { 6 | title: 'Component/Layout Main', 7 | component: MainLayout, 8 | }; 9 | 10 | export const simplePage = (args: { 11 | title: string; 12 | description: string; 13 | content: string; 14 | }): m.Component => ({ 15 | view: () => { 16 | return m( 17 | MainLayout, 18 | m( 19 | SimplePage, 20 | { 21 | title: args.title, 22 | description: args.description, 23 | }, 24 | args.content, 25 | ), 26 | ); 27 | }, 28 | }); 29 | simplePage.args = { 30 | title: 'This is the title.', 31 | description: 'This is a subtitle or description.', 32 | content: 'This is the content.', 33 | }; 34 | simplePage.argTypes = { 35 | title: { name: 'Title', control: { type: 'text' } }, 36 | description: { name: 'Description', control: { type: 'text' } }, 37 | content: { name: 'Content', control: { type: 'text' } }, 38 | }; 39 | -------------------------------------------------------------------------------- /src/component/breadcrumb/breadcrumb.stories.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { Breadcrumb } from '@/component/breadcrumb/breadcrumb'; 3 | 4 | export default { 5 | title: 'Component/Breadcrumb', 6 | component: Breadcrumb, 7 | }; 8 | 9 | export const Level1 = (): m.Component => ({ 10 | view: () => 11 | m(Breadcrumb, { 12 | levels: [{ icon: 'fa-home', name: 'Welcome', url: location.pathname }], 13 | }), 14 | }); 15 | 16 | export const Level2 = (): m.Component => ({ 17 | view: () => 18 | m(Breadcrumb, { 19 | levels: [ 20 | { icon: 'fa-home', name: 'Welcome', url: '/' }, 21 | { icon: 'fa-sticky-note', name: 'Notepad', url: location.pathname }, 22 | ], 23 | }), 24 | }); 25 | 26 | export const Level3 = (): m.Component => ({ 27 | view: () => 28 | m(Breadcrumb, { 29 | levels: [ 30 | { icon: 'fa-home', name: 'Welcome', url: '/' }, 31 | { icon: 'fa-sticky-note', name: 'Notepad', url: '/notepad' }, 32 | { icon: 'fa-edit', name: 'Edit', url: location.pathname }, 33 | ], 34 | }), 35 | }); 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Joseph Spurrier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.vscode/typescript.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Mithril Closure": { 3 | "prefix": "mithril-closure", 4 | "body": "import m from 'mithril';\r\n\r\nexport const $1: m.ClosureComponent = () => {\r\n return {\r\n view: () => $2m('div', 'Context'),\r\n };\r\n};\r\n", 5 | "description": "Creates a closure component in Mithril." 6 | }, 7 | "Mithril Storybook": { 8 | "prefix": "mithril-storybook", 9 | "body": "import m from 'mithril';\r\nimport { $1 } from '@/component/$2';\r\n\r\nexport default {\r\n title: 'Component/$3',\r\n component: $1,\r\n};\r\n\r\nexport const $4: m.ClosureComponent = () => ({\r\n view: () => m($1),\r\n});\r\n", 10 | "description": "Creates a storybook component in Mithril." 11 | }, 12 | "Arrow Function": { 13 | "prefix": "arrow", 14 | "body": "($1) => { $2 }", 15 | "description": "Creates an arrow function." 16 | }, 17 | "On Click": { 18 | "prefix": "onclick", 19 | "body": "onclick: ($1) => { $2 }", 20 | "description": "Creates an onclick with an arrow function." 21 | }, 22 | "Log to Console": { 23 | "prefix": "log", 24 | "body": "console.log($1)", 25 | "description": "Creates a console.log() statement." 26 | } 27 | } -------------------------------------------------------------------------------- /src/component/breadcrumb/breadcrumb.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | 3 | // https://bulma.io/documentation/components/breadcrumb/ 4 | 5 | interface Level { 6 | icon: string; 7 | name: string; 8 | url: string; 9 | } 10 | 11 | interface Attrs { 12 | levels: Level[]; 13 | } 14 | 15 | export const Breadcrumb = (): m.Component => { 16 | return { 17 | view: ({ attrs }) => 18 | m( 19 | 'div', 20 | { class: 'container is-fluid mt-4 mb-4' }, 21 | m( 22 | 'nav', 23 | { class: 'breadcrumb', 'aria-label': 'breadcrumbs' }, 24 | m('ul', [ 25 | attrs.levels.map((v: Level) => 26 | m( 27 | 'li', 28 | { class: location.pathname === v.url ? 'is-active' : '' }, 29 | m(m.route.Link, { href: v.url }, [ 30 | m( 31 | 'span', 32 | { class: 'icon is-small' }, 33 | m('i', { class: 'fas ' + v.icon, 'aria-hidden': 'true' }), 34 | ), 35 | m('span', v.name), 36 | ]), 37 | ), 38 | ), 39 | ]), 40 | ), 41 | ), 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 4 | /* eslint-disable @typescript-eslint/no-var-requires */ 5 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 6 | const rootWebpack = require('../webpack.config.js'); 7 | 8 | module.exports = { 9 | stories: ['../src/**/*.stories.@(js|ts|jsx|tsx)'], 10 | addons: [ 11 | '@storybook/addon-storysource', 12 | '@storybook/addon-actions', 13 | '@storybook/addon-docs', 14 | '@storybook/addon-controls', 15 | ], 16 | // @ts-ignore 17 | webpackFinal: (config) => { 18 | return { 19 | ...config, 20 | plugins: [...config.plugins, ...rootWebpack.plugins], 21 | resolve: { 22 | extensions: [ 23 | ...config.resolve.extensions, 24 | ...rootWebpack.resolve.extensions, 25 | ], 26 | alias: { 27 | ...config.resolve.alias, 28 | ...rootWebpack.resolve.alias, 29 | }, 30 | }, 31 | module: { 32 | ...config.module, 33 | rules: [...config.module.rules, ...rootWebpack.module.rules], 34 | }, 35 | }; 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /src/component/input/input.stories.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { Input } from '@/component/input/input'; 3 | 4 | export default { 5 | title: 'Component/Input', 6 | component: Input, 7 | }; 8 | 9 | enum InputTypes { 10 | text = 'text', 11 | color = 'color', 12 | date = 'date', 13 | 'datetime-local' = 'datetime-local', 14 | email = 'email', 15 | hidden = 'hidden', 16 | month = 'month', 17 | number = 'number', 18 | password = 'password', 19 | range = 'range', 20 | search = 'search', 21 | time = 'time', 22 | week = 'week', 23 | } 24 | 25 | export const input = (args: { 26 | firstName: string; 27 | inputType: InputTypes; 28 | }): m.Component => ({ 29 | view: () => 30 | m(Input, { 31 | name: 'first_name', 32 | label: 'First Name', 33 | value: args.firstName, 34 | type: args.inputType, 35 | oninput: function (): void { 36 | console.log('changed'); 37 | }, 38 | }), 39 | }); 40 | input.args = { 41 | firstName: 'John', 42 | inputType: InputTypes.text, 43 | }; 44 | input.argTypes = { 45 | firstName: { name: 'Value', control: { type: 'text' } }, 46 | inputType: { 47 | name: 'Type', 48 | control: { type: 'select', options: InputTypes }, 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /.cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | 27 | import Path from 'path'; 28 | 29 | Cypress.Commands.add('resetDB', () => { 30 | return cy 31 | .exec('echo Custom commands can go here.', { 32 | env: { CYPRESS: Path.resolve(__dirname) }, 33 | timeout: 10000, // 10 seconds. 34 | }) 35 | .its('code') 36 | .should('eq', 0); 37 | }); 38 | -------------------------------------------------------------------------------- /src/page/login/loginstore.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { start, finish, text } from '@/helper/submit'; 3 | import { showFlash, MessageType } from '@/component/flash/flash'; 4 | import { save, Auth } from '@/helper/cookiestore'; 5 | import { apiServer } from '@/helper/global'; 6 | 7 | export interface User { 8 | email: string; 9 | password: string; 10 | } 11 | 12 | export interface LoginResponse { 13 | status: string; 14 | token: string; 15 | } 16 | 17 | interface ErrorResponse { 18 | status: string; 19 | message: string; 20 | } 21 | 22 | export const login = (body: User): Promise => { 23 | return m.request({ 24 | method: 'POST', 25 | url: apiServer() + '/api/v1/login', 26 | body, 27 | }); 28 | }; 29 | 30 | export const submitText = (s: string): string => { 31 | return text(s); 32 | }; 33 | 34 | export const submit = (e: InputEvent, u: User): Promise => { 35 | start(e); 36 | 37 | return login(u) 38 | .then((raw: unknown) => { 39 | finish(); 40 | 41 | const data = raw as LoginResponse; 42 | 43 | if (data && data.status === 'OK') { 44 | const auth: Auth = { 45 | accessToken: data.token, 46 | loggedIn: true, 47 | }; 48 | save(auth); 49 | 50 | showFlash('Login successful.', MessageType.success); 51 | 52 | m.route.set('/'); 53 | } else { 54 | showFlash('Data returned is not valid.', MessageType.failed); 55 | } 56 | }) 57 | .catch((err: XMLHttpRequest) => { 58 | finish(); 59 | showFlash((err.response as ErrorResponse).message, MessageType.warning); 60 | throw err; 61 | }); 62 | }; 63 | -------------------------------------------------------------------------------- /src/page/register/registerstore.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { start, finish, text } from '@/helper/submit'; 3 | import { showFlash, MessageType } from '@/component/flash/flash'; 4 | import { apiServer } from '@/helper/global'; 5 | 6 | export interface User { 7 | first_name: string; 8 | last_name: string; 9 | email: string; 10 | password: string; 11 | } 12 | 13 | export interface RegisterResponse { 14 | status: string; 15 | record_id: string; 16 | } 17 | 18 | export interface ErrorResponse { 19 | message: string; 20 | } 21 | 22 | export const register = (body: User): Promise => { 23 | return m.request({ 24 | method: 'POST', 25 | url: apiServer() + '/api/v1/register', 26 | body, 27 | }); 28 | }; 29 | 30 | export const submitText = (s: string): string => { 31 | return text(s); 32 | }; 33 | 34 | export const submit = (e: InputEvent, u: User): Promise => { 35 | start(e); 36 | 37 | return register(u) 38 | .then((raw: unknown) => { 39 | finish(); 40 | 41 | const data = raw as RegisterResponse; 42 | if (data && data.status == 'Created') { 43 | showFlash('User registered.', MessageType.success); 44 | m.route.set('/login'); 45 | } else { 46 | showFlash('Data returned is not valid.', MessageType.failed); 47 | } 48 | }) 49 | .catch((err: XMLHttpRequest) => { 50 | finish(); 51 | const response = err.response as ErrorResponse; 52 | if (response) { 53 | showFlash(response.message, MessageType.warning); 54 | } else { 55 | showFlash('An error occurred.', MessageType.warning); 56 | } 57 | throw err; 58 | }); 59 | }; 60 | -------------------------------------------------------------------------------- /src/page/login/login.stories.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { LoginPage } from '@/page/login/login'; 3 | import { Flash } from '@/component/flash/flash'; 4 | import { rest } from 'msw'; 5 | import { worker } from '@/helper/mock/browser'; 6 | import { apiServer } from '@/helper/global'; 7 | 8 | export default { 9 | title: 'View/Login', 10 | component: LoginPage, 11 | }; 12 | 13 | export const login = (args: { 14 | fail: boolean; 15 | email: string; 16 | password: string; 17 | }): m.Component => ({ 18 | oninit: () => { 19 | const shouldFail = args.fail; 20 | 21 | worker.use( 22 | ...[ 23 | rest.post(apiServer() + '/api/v1/login', (req, res, ctx) => { 24 | if (shouldFail) { 25 | return res( 26 | ctx.status(400), 27 | ctx.json({ 28 | message: 'There was an error.', 29 | }), 30 | ); 31 | } else { 32 | return res( 33 | ctx.status(200), 34 | ctx.json({ 35 | status: 'OK', 36 | }), 37 | ); 38 | } 39 | }), 40 | ], 41 | ); 42 | }, 43 | view: () => 44 | m('main', [ 45 | m(LoginPage, { 46 | email: args.email, 47 | password: args.password, 48 | }), 49 | m(Flash), 50 | ]), 51 | }); 52 | login.args = { 53 | fail: false, 54 | email: 'jsmith@example.com', 55 | password: 'password', 56 | }; 57 | login.argTypes = { 58 | fail: { name: 'Fail', control: { type: 'boolean' } }, 59 | email: { name: 'Email', control: { type: 'text' } }, 60 | password: { name: 'Password', control: { type: 'text' } }, 61 | }; 62 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { MainLayout } from '@/layout/main'; 3 | import { DashboardLayout } from '@/layout/dashboard'; 4 | import { AboutPage } from '@/page/about/about'; 5 | import { LoginPage } from '@/page/login/login'; 6 | import { RegisterPage } from '@/page/register/register'; 7 | import { HomePage } from '@/page/home/home'; 8 | import { NotepadPage } from '@/page/notepad/notepad'; 9 | import { ErrorPage } from '@/page/error/error'; 10 | import { isLoggedIn } from '@/helper/cookiestore'; 11 | import { setup } from '@/helper/mock/browser'; 12 | import '~/node_modules/@fortawesome/fontawesome-free/js/all.js'; 13 | import '@/global.scss'; 14 | 15 | m.route.prefix = ''; 16 | 17 | m.route(document.body, '/', { 18 | '/': { 19 | onmatch: () => { 20 | if (!isLoggedIn()) m.route.set('/login'); 21 | }, 22 | render: () => m(DashboardLayout, m(HomePage)), 23 | }, 24 | '/notepad': { 25 | onmatch: () => { 26 | if (!isLoggedIn()) m.route.set('/login'); 27 | }, 28 | render: () => m(DashboardLayout, m(NotepadPage)), 29 | }, 30 | '/login': { 31 | onmatch: () => { 32 | if (isLoggedIn()) m.route.set('/'); 33 | }, 34 | render: () => m(MainLayout, m(LoginPage)), 35 | }, 36 | '/register': { 37 | onmatch: () => { 38 | if (isLoggedIn()) m.route.set('/'); 39 | }, 40 | render: () => m(MainLayout, m(RegisterPage)), 41 | }, 42 | '/about': { 43 | render: () => { 44 | if (isLoggedIn()) return m(DashboardLayout, m(AboutPage)); 45 | return m(MainLayout, m(AboutPage)); 46 | }, 47 | }, 48 | '/404': { 49 | render: () => m(MainLayout, m(ErrorPage)), 50 | }, 51 | '/:404...': { 52 | onmatch: () => window.location.replace('/404'), 53 | }, 54 | }); 55 | 56 | // Setup the mock service worker if it's enabled. 57 | setup(); 58 | -------------------------------------------------------------------------------- /src/page/register/register.stories.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { RegisterPage } from '@/page/register/register'; 3 | import { Flash } from '@/component/flash/flash'; 4 | import { rest } from 'msw'; 5 | import { worker } from '@/helper/mock/browser'; 6 | import { apiServer } from '@/helper/global'; 7 | 8 | export default { 9 | title: 'View/Register', 10 | component: RegisterPage, 11 | }; 12 | 13 | export const register = (args: { 14 | fail: boolean; 15 | firstName: string; 16 | lastName: string; 17 | email: string; 18 | password: string; 19 | }): m.Component => ({ 20 | oninit: () => { 21 | const shouldFail = args.fail; 22 | 23 | worker.use( 24 | ...[ 25 | rest.post(apiServer() + '/api/v1/register', (req, res, ctx) => { 26 | if (shouldFail) { 27 | return res( 28 | ctx.status(400), 29 | ctx.json({ 30 | message: 'There was an error.', 31 | }), 32 | ); 33 | } else { 34 | return res( 35 | ctx.status(201), 36 | ctx.json({ 37 | status: 'Created', 38 | record_id: '1', 39 | }), 40 | ); 41 | } 42 | }), 43 | ], 44 | ); 45 | }, 46 | view: () => 47 | m('main', [ 48 | m(RegisterPage, { 49 | firstName: args.firstName, 50 | lastName: args.lastName, 51 | email: args.email, 52 | password: args.password, 53 | }), 54 | m(Flash), 55 | ]), 56 | }); 57 | register.args = { 58 | fail: false, 59 | firstName: 'John', 60 | lastName: 'Smith', 61 | email: 'jsmith@example.com', 62 | password: 'password', 63 | }; 64 | register.argTypes = { 65 | fail: { name: 'Fail', control: { type: 'boolean' } }, 66 | firstName: { name: 'First Name', control: { type: 'text' } }, 67 | lastName: { name: 'Last Name', control: { type: 'text' } }, 68 | email: { name: 'Email', control: { type: 'text' } }, 69 | password: { name: 'Password', control: { type: 'text' } }, 70 | }; 71 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Define the files types for known files. 3 | "files.associations": { 4 | ".eslintignore": "ignore", 5 | ".eslintrc.json": "jsonc", 6 | ".prettierrc": "jsonc", 7 | ".stylelintrc": "json", // Does not support comments in JSON: https://github.com/stylelint/stylelint/issues/982 8 | "babel.config.json": "jsonc", // Does not support $schema 9 | "cypress.json": "json", // Does not support comments in JSON: https://github.com/cypress-io/cypress/issues/5218. 10 | "jsconfig.json": "jsonc", 11 | "tsconfig.json": "jsonc", 12 | }, 13 | // Use the built-in formatter for files like JSON. 14 | "editor.formatOnSave": true, 15 | // When importing modules, don't use relative paths, use absolute paths. 16 | "javascript.preferences.importModuleSpecifier": "non-relative", 17 | "typescript.preferences.importModuleSpecifier": "non-relative", 18 | // On save, autofix with ESLint and stylelint. 19 | "editor.codeActionsOnSave": { 20 | "source.fixAll.eslint": true, 21 | "source.fixAll.stylelint": true, 22 | }, 23 | // Add newlines to the end of JSON files to align with how package.json behaves when using `npm install`. 24 | "[json]": { 25 | "files.insertFinalNewline": true, 26 | }, 27 | "[jsonc]": { 28 | "files.insertFinalNewline": true, 29 | }, 30 | // Disable the built-in validators. 31 | "css.validate": false, 32 | "scss.validate": false, 33 | "[javascript]": { 34 | "editor.formatOnSave": false, 35 | }, 36 | "[javascriptreact]": { 37 | "editor.formatOnSave": false, 38 | }, 39 | "[typescript]": { 40 | "editor.formatOnSave": false, 41 | }, 42 | "[typescriptreact]": { 43 | "editor.formatOnSave": false, 44 | }, 45 | // Enable stylelint on just CSS and SCSS. 46 | "stylelint.validate": [ 47 | "css", 48 | "scss", 49 | ], 50 | // Enable ESLint on just JS, JSX, TS, and TSX. 51 | "eslint.alwaysShowStatus": true, 52 | "eslint.workingDirectories": [ 53 | { 54 | "mode": "auto", 55 | }, 56 | ], 57 | "eslint.probe": [ 58 | "javascript", 59 | "javascriptreact", 60 | "typescript", 61 | "typescriptreact", 62 | ], 63 | } 64 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "root": true, 4 | "env": { 5 | "browser": true, 6 | "node": true, 7 | "es6": true 8 | }, 9 | "parser": "@typescript-eslint/parser", 10 | "parserOptions": { 11 | "project": "./tsconfig.json" 12 | }, 13 | "extends": [ 14 | "eslint:recommended", 15 | "plugin:@typescript-eslint/eslint-recommended", 16 | "plugin:@typescript-eslint/recommended", 17 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 18 | "plugin:mithril/recommended", 19 | "plugin:cypress/recommended", 20 | "plugin:prettier/recommended" 21 | ], 22 | "plugins": [ 23 | "prettier", 24 | "@typescript-eslint" 25 | ], 26 | "rules": { 27 | // Disable the rule for all files to allow for mixed js/ts. 28 | // https://github.com/typescript-eslint/typescript-eslint/blob/v3.9.1/packages/eslint-plugin/docs/rules/explicit-module-boundary-types.md 29 | "@typescript-eslint/explicit-module-boundary-types": "off", 30 | // Don't allow relative imports. VSCode will also use absolute imports by default. 31 | "no-restricted-imports": [ 32 | "error", 33 | { 34 | "patterns": [ 35 | "./*", 36 | "../*", 37 | "!./*.css", // Allow relative imports for CSS. 38 | "!../*.css", // Allow relative imports for CSS. 39 | "!./*.scss", // Allow relative imports for SCSS. 40 | "!../*.scss" // Allow relative imports for SCSS. 41 | ] 42 | } 43 | ] 44 | }, 45 | "overrides": [ 46 | { 47 | // Enable the rule specifically for TypeScript files to allow for mixed js/ts. 48 | "files": [ 49 | "*.ts", 50 | "*.tsx" 51 | ], 52 | "rules": { 53 | "@typescript-eslint/explicit-module-boundary-types": [ 54 | "error" 55 | ] 56 | } 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /src/page/notepad/note.stories.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { Note } from '@/page/notepad/note'; 3 | import { Flash } from '@/component/flash/flash'; 4 | import { rest } from 'msw'; 5 | import { worker } from '@/helper/mock/browser'; 6 | import { apiServer } from '@/helper/global'; 7 | 8 | export default { 9 | title: 'Component/Note', 10 | component: Note, 11 | }; 12 | 13 | export const noteView = (args: { fail: boolean }): m.Component => ({ 14 | oninit: () => { 15 | const shouldFail = args.fail; 16 | 17 | worker.use( 18 | ...[ 19 | rest.put(apiServer() + '/api/v1/note/1', (req, res, ctx) => { 20 | if (shouldFail) { 21 | return res( 22 | ctx.status(400), 23 | ctx.json({ 24 | message: 'There was an error.', 25 | }), 26 | ); 27 | } else { 28 | return res( 29 | ctx.status(200), 30 | ctx.json({ 31 | message: 'ok', 32 | }), 33 | ); 34 | } 35 | }), 36 | rest.delete(apiServer() + '/api/v1/note/1', (req, res, ctx) => { 37 | if (shouldFail) { 38 | return res( 39 | ctx.status(400), 40 | ctx.json({ 41 | message: 'There was an error.', 42 | }), 43 | ); 44 | } else { 45 | return res( 46 | ctx.status(200), 47 | ctx.json({ 48 | message: 'ok', 49 | }), 50 | ); 51 | } 52 | }), 53 | ], 54 | ); 55 | }, 56 | view: () => 57 | m('div', [ 58 | m('ul', [ 59 | m(Note, { 60 | id: '1', 61 | oninput: function (): void { 62 | console.log('changed'); 63 | }, 64 | removeNote: function (): void { 65 | console.log('removed'); 66 | }, 67 | }), 68 | ]), 69 | m(Flash), 70 | ]), 71 | }); 72 | 73 | noteView.storyName = 'Note'; 74 | noteView.args = { 75 | fail: false, 76 | }; 77 | noteView.argTypes = { 78 | fail: { name: 'Fail', control: { type: 'boolean' } }, 79 | }; 80 | -------------------------------------------------------------------------------- /src/component/reference/block.stories.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { action } from '@storybook/addon-actions'; 3 | import { Block } from '@/component/reference/block'; 4 | 5 | export default { 6 | title: 'Example/Block', 7 | component: Block, 8 | }; 9 | 10 | export const button = (args: { 11 | disabled: boolean; 12 | label: string; 13 | }): m.Component => ({ 14 | view: () => 15 | m( 16 | 'button', 17 | { 18 | disabled: args.disabled, 19 | onclick: function () { 20 | action('button-click'); 21 | console.log('Clicked!'); 22 | }, 23 | }, 24 | args.label, 25 | ), 26 | }); 27 | button.args = { 28 | disabled: false, 29 | label: 'Hello Storybook', 30 | }; 31 | button.argTypes = { 32 | disabled: { name: 'Toggle', control: { type: 'boolean' } }, 33 | label: { name: 'Text', control: { type: 'text' } }, 34 | }; 35 | 36 | export const dynamicText = (args: { 37 | name: string; 38 | age: number; 39 | }): m.Component => ({ 40 | view: () => { 41 | const name = args.name; 42 | const age = args.age; 43 | const content = `I am ${name} and I'm ${age} years old.`; 44 | 45 | return m('', content); 46 | }, 47 | }); 48 | dynamicText.args = { 49 | name: 'Joe', 50 | age: 32, 51 | }; 52 | dynamicText.argTypes = { 53 | name: { name: 'Name', control: { type: 'text' } }, 54 | age: { 55 | name: 'Age', 56 | control: { type: 'number', min: 0, max: 100, step: 1 }, 57 | }, 58 | }; 59 | 60 | export const long = (args: { text: string }): m.Component => { 61 | return { 62 | view: () => m(Block, args.text), 63 | }; 64 | }; 65 | long.args = { 66 | text: 'Long', 67 | }; 68 | long.argTypes = { 69 | text: { name: 'Text', control: { type: 'text' } }, 70 | }; 71 | 72 | export const short = (args: { text: string }): m.Component => ({ 73 | view: () => m(Block, args.text), 74 | }); 75 | short.args = { 76 | text: 'Short', 77 | }; 78 | short.argTypes = { 79 | text: { name: 'Text', control: { type: 'text' } }, 80 | }; 81 | 82 | export const emoji = (): m.Component => ({ 83 | view: () => 84 | m('block', [ 85 | m('form', [ 86 | m('span', { role: 'img', 'aria-label': 'so cool' }, '😀 😎 👍 💯'), 87 | ]), 88 | ]), 89 | }); 90 | -------------------------------------------------------------------------------- /src/layout/side-menu/side-menu.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import '@/layout/side-menu/side-menu.scss'; 3 | import style from '@/layout/side-menu/side-menu.scss'; 4 | 5 | // https://bulma.io/documentation/components/menu/ 6 | 7 | export const SideMenu: m.ClosureComponent = () => { 8 | return { 9 | view: () => 10 | m('div', { class: style.local }, [ 11 | m('aside', { class: 'menu aside' }, [ 12 | m('p', { class: 'menu-label' }, 'Authenticated'), 13 | m('ul', { class: 'menu-list' }, [ 14 | m( 15 | 'li', 16 | m( 17 | m.route.Link, 18 | { 19 | href: '/', 20 | 'data-cy': 'welcome-link', 21 | class: location.pathname === '/' ? 'is-active' : '', 22 | }, 23 | [ 24 | m( 25 | 'span', 26 | { class: 'icon is-small' }, 27 | m('i', { 28 | class: 'fas fa-home', 29 | 'aria-hidden': 'true', 30 | }), 31 | ), 32 | 'Welcome', 33 | ], 34 | ), 35 | ), 36 | m( 37 | 'li', 38 | m( 39 | m.route.Link, 40 | { 41 | href: '/notepad', 42 | 'data-cy': 'notepad-link', 43 | class: location.pathname === '/notepad' ? 'is-active' : '', 44 | }, 45 | [ 46 | m( 47 | 'span', 48 | { class: 'icon is-small' }, 49 | m('i', { 50 | class: 'fas fa-sticky-note', 51 | 'aria-hidden': 'true', 52 | }), 53 | ), 54 | 'Notepad', 55 | ], 56 | ), 57 | ), 58 | ]), 59 | m('p', { class: 'menu-label' }, 'Public'), 60 | m('ul', { class: 'menu-list' }, [ 61 | m( 62 | 'li', 63 | m( 64 | m.route.Link, 65 | { 66 | href: '/about', 67 | 'data-cy': 'about-link', 68 | class: location.pathname === '/about' ? 'is-active' : '', 69 | }, 70 | [ 71 | m( 72 | 'span', 73 | { class: 'icon is-small' }, 74 | m('i', { 75 | class: 'fas fa-address-card', 76 | 'aria-hidden': 'true', 77 | }), 78 | ), 79 | 'About', 80 | ], 81 | ), 82 | ), 83 | ]), 84 | ]), 85 | ]), 86 | }; 87 | }; 88 | -------------------------------------------------------------------------------- /src/page/notepad/note.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { debounce } from '@/helper/debounce'; 3 | import * as NoteStore from '@/page/notepad/notestore'; 4 | 5 | interface Attrs { 6 | id: string; 7 | message?: string; 8 | oninput: (e: { target: HTMLInputElement }) => void; 9 | removeNote: (id: string) => void; 10 | } 11 | 12 | interface State { 13 | saving: string; 14 | } 15 | 16 | export const Note = (): m.Component => { 17 | return { 18 | view: ({ attrs, state }) => 19 | m('li', { class: 'mt-2' }, [ 20 | m('div', { class: 'box' }, [ 21 | m('div', { class: 'content' }, [ 22 | m('div', { class: 'editable' }, [ 23 | m('input', { 24 | class: 'input individual-note', 25 | id: attrs.id, 26 | type: 'text', 27 | value: attrs.message, 28 | oninput: attrs.oninput, 29 | onkeyup: function (e: { target: HTMLInputElement }) { 30 | debounce( 31 | attrs.id, 32 | () => { 33 | NoteStore.runUpdate(attrs.id, e.target.value); 34 | state.saving = 'Saving...'; 35 | m.redraw(); 36 | setTimeout(() => { 37 | state.saving = ''; 38 | m.redraw(); 39 | }, 1000); 40 | }, 41 | 1000, 42 | ); 43 | }, 44 | }), 45 | ]), 46 | ]), 47 | m('nav', { class: 'level is-mobile' }, [ 48 | m('div', { class: 'level-left' }, [ 49 | m( 50 | 'a', 51 | { 52 | class: 'level-item', 53 | title: 'Delete note', 54 | onclick: function () { 55 | NoteStore.runDelete(attrs.id) 56 | .then(() => { 57 | attrs.removeNote(attrs.id); 58 | }) 59 | .catch(() => { 60 | console.log('Could not remove note.'); 61 | }); 62 | }, 63 | }, 64 | [ 65 | m('span', { class: 'icon is-small has-text-danger' }, [ 66 | m('i', { 67 | class: 'fas fa-trash', 68 | 'data-cy': 'delete-note-link', 69 | }), 70 | ]), 71 | ], 72 | ), 73 | ]), 74 | m( 75 | 'div', 76 | { class: 'level-right', style: { 'min-height': '1.2rem' } }, 77 | [m('span', { class: 'is-size-7 has-text-grey' }, state.saving)], 78 | ), 79 | ]), 80 | ]), 81 | ]), 82 | }; 83 | }; 84 | -------------------------------------------------------------------------------- /src/component/flash/flash.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { randId } from '@/helper/random'; 3 | 4 | // Create a flash message class with Bulma. 5 | // http://bulma.io/documentation/components/message/ 6 | 7 | // Types of flash message. 8 | export enum MessageType { 9 | success = 'is-success', 10 | failed = 'is-danger', 11 | warning = 'is-warning', 12 | primary = 'is-primary', 13 | link = 'is-link', 14 | info = 'is-info', 15 | dark = 'is-dark', 16 | } 17 | 18 | // Structure of a flash message. 19 | interface FlashMessage { 20 | message: string; 21 | style: MessageType; 22 | } 23 | 24 | const internalFlash = { 25 | list: [] as FlashMessage[], 26 | timeout: 4000, // milliseconds 27 | prepend: false, 28 | addFlash: (message: string, style: MessageType): void => { 29 | // Don't show a message if zero. 30 | if (internalFlash.timeout === 0) { 31 | return; 32 | } 33 | 34 | const msg: FlashMessage = { 35 | message: message, 36 | style: style, 37 | }; 38 | 39 | //Check if the messages should stack in reverse order. 40 | if (internalFlash.prepend === true) { 41 | internalFlash.list.unshift(msg); 42 | } else { 43 | internalFlash.list.push(msg); 44 | } 45 | 46 | m.redraw(); 47 | 48 | // Show forever if -1. 49 | if (internalFlash.timeout > 0) { 50 | setTimeout(() => { 51 | internalFlash.removeFlash(msg); 52 | m.redraw(); 53 | }, internalFlash.timeout); 54 | } 55 | }, 56 | removeFlash: (i: FlashMessage): void => { 57 | internalFlash.list = internalFlash.list.filter((v) => { 58 | return v !== i; 59 | }); 60 | }, 61 | }; 62 | 63 | export const showFlash = (message: string, style: MessageType): void => { 64 | internalFlash.addFlash(message, style); 65 | }; 66 | 67 | export const setFlashTimeout = (t: number): void => { 68 | internalFlash.timeout = t; 69 | }; 70 | 71 | export const clearFlash = (): void => { 72 | internalFlash.list = []; 73 | }; 74 | 75 | export const setPrepend = (b: boolean): void => { 76 | internalFlash.prepend = b; 77 | }; 78 | 79 | export const Flash: m.Component = { 80 | view: () => 81 | m( 82 | 'div', 83 | { 84 | style: { 85 | position: 'fixed', 86 | bottom: '1.5rem', 87 | right: '1.5rem', 88 | 'z-index': '100', 89 | margin: '0', 90 | }, 91 | }, 92 | [ 93 | internalFlash.list.map((i) => 94 | m('div', { class: `notification ${i.style}`, key: randId() }, [ 95 | i.message, 96 | m('button', { 97 | class: 'delete', 98 | onclick: function () { 99 | internalFlash.removeFlash(i); 100 | }, 101 | }), 102 | ]), 103 | ), 104 | ], 105 | ), 106 | }; 107 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mithril-template", 3 | "version": "1.0.0", 4 | "description": "A sample notepad application in Mithril and Bulma.", 5 | "main": "mithril-template", 6 | "scripts": { 7 | "start": "webpack serve --mode development --open", 8 | "watch": "webpack --mode development --watch", 9 | "build": "webpack --mode production", 10 | "lint": "eslint --ext .js,.jsx,.ts,.tsx .", 11 | "lint-fix": "eslint --fix --ext .js,.jsx,.ts,.tsx .", 12 | "stylelint": "stylelint 'src/**/*.{css,scss,json}'", 13 | "stylelint-fix": "stylelint --fix 'src/**/*.{css,scss}'", 14 | "test": "cypress run", 15 | "test-debug": "DEBUG=cypress:* cypress run", 16 | "cypress": "cypress open", 17 | "storybook": "start-storybook -p 9090 -s .storybook/static" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git://github.com/josephspurrier/mithril-template.git" 22 | }, 23 | "author": "Joseph Spurrier", 24 | "license": "MIT", 25 | "dependencies": { 26 | "@fortawesome/fontawesome-free": "^5.15.3", 27 | "@storybook/addon-a11y": "^6.2.9", 28 | "@storybook/addon-actions": "^6.2.9", 29 | "@storybook/addon-controls": "^6.2.9", 30 | "@storybook/addon-docs": "^6.2.9", 31 | "@types/copy-webpack-plugin": "^6.0.0", 32 | "@types/js-cookie": "^2.2.6", 33 | "@types/mini-css-extract-plugin": "^1.4.2", 34 | "@types/mithril": "^2.0.7", 35 | "bulma": "^0.9.2", 36 | "clean-webpack-plugin": "^3.0.0", 37 | "copy-webpack-plugin": "^6.4.1", 38 | "css-loader": "^4.2.2", 39 | "file-loader": "^6.2.0", 40 | "fork-ts-checker-webpack-plugin": "^6.2.4", 41 | "html-webpack-plugin": "^4.5.1", 42 | "js-cookie": "^2.2.1", 43 | "mini-css-extract-plugin": "^1.5.0", 44 | "mithril": "^2.0.4", 45 | "msw": "^0.28.2", 46 | "node-sass": "^5.0.0", 47 | "sass-loader": "^10.1.1", 48 | "source-map-loader": "^1.1.0", 49 | "ts-loader": "^8.2.0", 50 | "typescript": "^4.2.4", 51 | "webpack": "^4.46.0", 52 | "webpack-cli": "^4.6.0" 53 | }, 54 | "devDependencies": { 55 | "@storybook/addon-console": "^1.2.3", 56 | "@storybook/addon-storysource": "^6.2.9", 57 | "@storybook/addon-toolbars": "^6.2.9", 58 | "@storybook/mithril": "^6.2.9", 59 | "@typescript-eslint/eslint-plugin": "^4.22.0", 60 | "@typescript-eslint/parser": "^4.22.0", 61 | "babel-loader": "^8.2.2", 62 | "cypress": "^7.1.0", 63 | "eslint": "^7.25.0", 64 | "eslint-config-prettier": "^8.3.0", 65 | "eslint-loader": "^4.0.2", 66 | "eslint-plugin-cypress": "^2.11.2", 67 | "eslint-plugin-mithril": "^0.2.0", 68 | "eslint-plugin-prettier": "^3.4.0", 69 | "prettier": "^2.2.1", 70 | "stylelint": "^13.13.0", 71 | "stylelint-config-css-modules": "^2.2.0", 72 | "stylelint-config-standard": "^22.0.0", 73 | "stylelint-scss": "^3.19.0", 74 | "webpack-dev-server": "^3.11.2" 75 | }, 76 | "msw": { 77 | "workerDirectory": ".storybook/static" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/e2e.spec.ts: -------------------------------------------------------------------------------- 1 | describe('test the full application', () => { 2 | before(() => { 3 | cy.resetDB(); 4 | }); 5 | 6 | beforeEach(() => { 7 | Cypress.Cookies.preserveOnce('auth'); 8 | }); 9 | 10 | it('loads the home page', () => { 11 | cy.clearCookie('auth'); 12 | cy.visit('http://localhost:8080'); 13 | cy.contains('Login'); 14 | }); 15 | 16 | it('registers a new user', () => { 17 | cy.visit('/register'); 18 | 19 | cy.get('[data-cy=first_name]').type('John').should('have.value', 'John'); 20 | cy.get('[data-cy=last_name]').type('Smith').should('have.value', 'Smith'); 21 | cy.get('[data-cy=email]') 22 | .type('jsmith@example.com') 23 | .should('have.value', 'jsmith@example.com'); 24 | cy.get('[data-cy=password]') 25 | .type('password') 26 | .should('have.value', 'password'); 27 | cy.get('[data-cy=submit]').click(); 28 | }); 29 | 30 | it('login with the user', () => { 31 | cy.visit('/'); 32 | 33 | cy.contains('Login'); 34 | cy.get('[data-cy=email]') 35 | .type('jsmith@example.com') 36 | .should('have.value', 'jsmith@example.com'); 37 | cy.get('[data-cy=password]') 38 | .type('password') 39 | .should('have.value', 'password'); 40 | cy.get('[data-cy=submit]').click(); 41 | cy.contains('Login successful.'); 42 | }); 43 | 44 | it('navigate to note page', () => { 45 | cy.visit('/'); 46 | 47 | cy.contains('Welcome'); 48 | cy.url().should('include', '/'); 49 | cy.get('[data-cy=notepad-link]').click(); 50 | cy.url().should('include', '/notepad'); 51 | cy.contains('To Do'); 52 | }); 53 | 54 | it('add a note', () => { 55 | cy.visit('/notepad'); 56 | 57 | cy.get('[data-cy=note-text]') 58 | .type('hello world') 59 | .should('have.value', 'hello world') 60 | .type('{enter}'); 61 | 62 | cy.url().should('include', '/note'); 63 | 64 | cy.get('#listTodo').find('li').should('have.length', 1); 65 | }); 66 | 67 | it('add a 2nd note', () => { 68 | cy.get('[data-cy=note-text]') 69 | .type('hello universe') 70 | .should('have.value', 'hello universe') 71 | .type('{enter}'); 72 | 73 | cy.url().should('include', '/note'); 74 | 75 | cy.get('#listTodo').find('li').should('have.length', 2); 76 | 77 | cy.get('#listTodo>li') 78 | .eq(0) 79 | .find('input') 80 | .should('have.value', 'hello world'); 81 | 82 | cy.get('#listTodo>li') 83 | .eq(1) 84 | .find('input') 85 | .should('have.value', 'hello universe'); 86 | }); 87 | 88 | it('edit the 2nd note', () => { 89 | cy.get('#listTodo>li') 90 | .eq(1) 91 | .find('input') 92 | .type(' foo') 93 | .should('have.value', 'hello universe foo'); 94 | }); 95 | 96 | it('delete the 1st note', () => { 97 | cy.get('#listTodo>li').eq(1).find('[data-cy=delete-note-link]').click(); 98 | cy.get('#listTodo').find('li').should('have.length', 1); 99 | }); 100 | 101 | it('delete the last note', () => { 102 | cy.get('#listTodo>li').eq(0).find('[data-cy=delete-note-link]').click(); 103 | cy.get('#listTodo').find('li').should('have.length', 0); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/page/notepad/notepad.stories.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { NotepadPage } from '@/page/notepad/notepad'; 3 | import { Note } from '@/page/notepad/notestore'; 4 | import { randId } from '@/helper/random'; 5 | import { Flash } from '@/component/flash/flash'; 6 | import { rest } from 'msw'; 7 | import { worker } from '@/helper/mock/browser'; 8 | import { apiServer } from '@/helper/global'; 9 | 10 | export default { 11 | title: 'View/Notepad', 12 | component: NotepadPage, 13 | }; 14 | 15 | interface MessageResponse { 16 | message: string; 17 | } 18 | 19 | export const notepad = (args: { fail: boolean }): m.Component => ({ 20 | oninit: () => { 21 | const shouldFail = args.fail; 22 | 23 | const notes = [] as Note[]; 24 | 25 | worker.use( 26 | ...[ 27 | rest.get(apiServer() + '/api/v1/note', (req, res, ctx) => { 28 | if (shouldFail) { 29 | return res( 30 | ctx.status(400), 31 | ctx.json({ 32 | message: 'There was an error.', 33 | }), 34 | ); 35 | } else { 36 | return res( 37 | ctx.status(200), 38 | ctx.json({ 39 | notes: notes, 40 | }), 41 | ); 42 | } 43 | }), 44 | rest.delete(apiServer() + '/api/v1/note/:noteId', (req, res, ctx) => { 45 | if (shouldFail) { 46 | return res( 47 | ctx.status(400), 48 | ctx.json({ 49 | message: 'There was an error.', 50 | }), 51 | ); 52 | } else { 53 | const { noteId } = req.params; 54 | console.log('Found:', noteId); 55 | return res( 56 | ctx.status(200), 57 | ctx.json({ 58 | message: 'ok', 59 | }), 60 | ); 61 | } 62 | }), 63 | rest.post(apiServer() + '/api/v1/note', (req, res, ctx) => { 64 | if (shouldFail) { 65 | return res( 66 | ctx.status(400), 67 | ctx.json({ 68 | message: 'There was an error.', 69 | }), 70 | ); 71 | } else { 72 | const m = req.body as MessageResponse; 73 | const id = randId(); 74 | notes.push({ id: id, message: m.message }); 75 | return res( 76 | ctx.status(201), 77 | ctx.json({ 78 | message: 'ok', 79 | }), 80 | ); 81 | } 82 | }), 83 | rest.put(apiServer() + '/api/v1/note/:noteId', (req, res, ctx) => { 84 | if (shouldFail) { 85 | return res( 86 | ctx.status(400), 87 | ctx.json({ 88 | message: 'There was an error.', 89 | }), 90 | ); 91 | } else { 92 | const { noteId } = req.params; 93 | console.log('Found:', noteId); 94 | return res( 95 | ctx.status(200), 96 | ctx.json({ 97 | message: 'ok', 98 | }), 99 | ); 100 | } 101 | }), 102 | ], 103 | ); 104 | }, 105 | view: () => m('main', [m(NotepadPage), m(Flash)]), 106 | }); 107 | notepad.args = { 108 | fail: false, 109 | }; 110 | notepad.argTypes = { 111 | fail: { name: 'Fail', control: { type: 'boolean' } }, 112 | }; 113 | -------------------------------------------------------------------------------- /src/component/reference/controls.stories.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | 3 | export default { 4 | title: 'Example/Controls', 5 | }; 6 | 7 | interface Args { 8 | list: number[]; 9 | toggle: boolean; 10 | numberBox: number; 11 | numberSlider: number; 12 | jsonEditor: unknown; // This is an object. 13 | radio: RadioOptions; 14 | inlineRadio: RadioOptions; 15 | multiCheck: RadioOptions[]; 16 | inlineMultiCheck: RadioOptions[]; 17 | singleSelect: RadioOptions; 18 | multiSelect: RadioOptions[]; 19 | text: string; 20 | colorPicker: string; 21 | date: string; 22 | } 23 | 24 | export const controls = (args: Args): m.Component => { 25 | console.log(args); 26 | return { 27 | view: () => m('pre', JSON.stringify(args, undefined, 2)), 28 | }; 29 | }; 30 | 31 | // This is the output in a
:
 32 | // {
 33 | //   "list": [
 34 | //     1,
 35 | //     2,
 36 | //     3
 37 | //   ],
 38 | //   "toggle": true,
 39 | //   "numberBox": 3,
 40 | //   "numberSlider": 2,
 41 | //   "jsonEditor": {
 42 | //     "data": "foo"
 43 | //   },
 44 | //   "radio": "loading",
 45 | //   "inlineRadio": "error",
 46 | //   "multiCheck": [
 47 | //     "loading",
 48 | //     "ready"
 49 | //   ],
 50 | //   "inlineMultiCheck": [
 51 | //     "loading"
 52 | //   ],
 53 | //   "singleSelect": "ready",
 54 | //   "multiSelect": [
 55 | //     "loading",
 56 | //     "loading"
 57 | //   ],
 58 | //   "text": "Column",
 59 | //   "colorPicker": "blue",
 60 | //   "date": "2020-08-16 12:30"
 61 | // }
 62 | 
 63 | enum RadioOptions {
 64 |   Loading = 'loading',
 65 |   Error = 'error',
 66 |   Ready = 'ready',
 67 | }
 68 | 
 69 | // Annotations: https://storybook.js.org/docs/mithril/essentials/controls#annotation
 70 | 
 71 | controls.args = {
 72 |   list: [1, 2, 3],
 73 |   toggle: true,
 74 |   numberBox: 3,
 75 |   numberSlider: 2,
 76 |   jsonEditor: { data: 'foo' },
 77 |   radio: RadioOptions.Loading,
 78 |   inlineRadio: RadioOptions.Error,
 79 |   multiCheck: [RadioOptions.Loading, RadioOptions.Ready],
 80 |   inlineMultiCheck: [RadioOptions.Loading],
 81 |   singleSelect: RadioOptions.Ready,
 82 |   multiSelect: [RadioOptions.Loading, RadioOptions.Loading],
 83 |   text: 'Column',
 84 |   colorPicker: 'blue',
 85 |   date: '2020-08-16 12:30',
 86 | } as Args;
 87 | 
 88 | controls.argTypes = {
 89 |   list: { name: 'List', control: { type: 'array', separator: ',' } },
 90 |   toggle: { name: 'Toggle', control: { type: 'boolean' } },
 91 |   numberBox: {
 92 |     name: 'Number',
 93 |     control: { type: 'number', min: 0, max: 20, step: 1 },
 94 |   },
 95 |   numberSlider: {
 96 |     name: 'Number Slider',
 97 |     control: { type: 'range', min: 0, max: 20, step: 2 },
 98 |   },
 99 |   jsonEditor: { name: 'JSON Editor', control: { type: 'object' } },
100 |   radio: { name: 'Radio', control: { type: 'radio', options: RadioOptions } },
101 |   inlineRadio: {
102 |     name: 'Inline Radio',
103 |     control: { type: 'inline-radio', options: RadioOptions },
104 |   },
105 |   multiCheck: {
106 |     name: 'MultiCheck',
107 |     control: { type: 'check', options: RadioOptions },
108 |   },
109 |   inlineMultiCheck: {
110 |     name: 'Inline MultiCheck',
111 |     control: { type: 'inline-check', options: RadioOptions },
112 |   },
113 |   singleSelect: {
114 |     name: 'Single Select',
115 |     control: { type: 'select', options: RadioOptions },
116 |   },
117 |   multiSelect: {
118 |     name: 'Multi Select',
119 |     control: { type: 'multi-select', options: RadioOptions },
120 |   },
121 |   text: { name: 'Text', control: { type: 'text' } },
122 |   colorPicker: { name: 'Color Picker', control: { type: 'color' } },
123 |   date: { name: 'Date', control: { type: 'date' } },
124 | };
125 | 


--------------------------------------------------------------------------------
/src/page/notepad/notestore.ts:
--------------------------------------------------------------------------------
  1 | import m from 'mithril';
  2 | import { showFlash, MessageType } from '@/component/flash/flash';
  3 | import { bearerToken } from '@/helper/cookiestore';
  4 | import { apiServer } from '@/helper/global';
  5 | 
  6 | export interface Note {
  7 |   id: string;
  8 |   message: string;
  9 | }
 10 | 
 11 | export interface NoteListResponse {
 12 |   notes: Note[];
 13 | }
 14 | 
 15 | export interface NoteCreateRequest {
 16 |   message: string;
 17 | }
 18 | 
 19 | export interface NoteUpdateRequest {
 20 |   message: string;
 21 | }
 22 | 
 23 | export interface ErrorResponse {
 24 |   message: string;
 25 | }
 26 | 
 27 | export const submit = (n: NoteCreateRequest): Promise => {
 28 |   return create(n)
 29 |     .then(() => {
 30 |       showFlash('Note created.', MessageType.success);
 31 |     })
 32 |     .catch((err: XMLHttpRequest) => {
 33 |       const response = err.response as ErrorResponse;
 34 |       if (response) {
 35 |         showFlash(response.message, MessageType.warning);
 36 |       } else {
 37 |         showFlash('An error occurred.', MessageType.warning);
 38 |       }
 39 |       throw err;
 40 |     });
 41 | };
 42 | 
 43 | export const create = (body: NoteCreateRequest): Promise => {
 44 |   return m.request({
 45 |     method: 'POST',
 46 |     url: apiServer() + '/api/v1/note',
 47 |     headers: {
 48 |       Authorization: bearerToken(),
 49 |     },
 50 |     body,
 51 |   });
 52 | };
 53 | 
 54 | export const load = (): Promise => {
 55 |   return m
 56 |     .request({
 57 |       method: 'GET',
 58 |       url: apiServer() + '/api/v1/note',
 59 |       headers: {
 60 |         Authorization: bearerToken(),
 61 |       },
 62 |     })
 63 |     .then((raw: unknown) => {
 64 |       const result = raw as NoteListResponse;
 65 |       if (result) {
 66 |         return result.notes;
 67 |       }
 68 |       showFlash('Data returned is not valid.', MessageType.failed);
 69 |       return [] as Note[];
 70 |     })
 71 |     .catch((err: XMLHttpRequest) => {
 72 |       const response = err.response as ErrorResponse;
 73 |       if (response) {
 74 |         showFlash(response.message, MessageType.warning);
 75 |       } else {
 76 |         showFlash('An error occurred.', MessageType.warning);
 77 |       }
 78 |       throw err;
 79 |     });
 80 | };
 81 | 
 82 | export const runUpdate = (id: string, value: string): void => {
 83 |   update(id, value).catch((err: XMLHttpRequest) => {
 84 |     const response = err.response as ErrorResponse;
 85 |     if (response) {
 86 |       showFlash(
 87 |         `Could not update note: ${response.message}`,
 88 |         MessageType.warning,
 89 |       );
 90 |     } else {
 91 |       showFlash('An error occurred.', MessageType.warning);
 92 |     }
 93 |   });
 94 | };
 95 | 
 96 | export const update = (id: string, text: string): Promise => {
 97 |   return m.request({
 98 |     method: 'PUT',
 99 |     url: apiServer() + '/api/v1/note/' + id,
100 |     headers: {
101 |       Authorization: bearerToken(),
102 |     },
103 |     body: { message: text } as NoteUpdateRequest,
104 |   });
105 | };
106 | 
107 | export const runDelete = (id: string): Promise => {
108 |   return deleteNote(id)
109 |     .then(() => {
110 |       showFlash('Note deleted.', MessageType.success);
111 |     })
112 |     .catch((err: XMLHttpRequest) => {
113 |       const response = err.response as ErrorResponse;
114 |       if (response) {
115 |         showFlash(
116 |           `Could not delete note: ${response.message}`,
117 |           MessageType.warning,
118 |         );
119 |       } else {
120 |         showFlash('An error occurred.', MessageType.warning);
121 |       }
122 |     });
123 | };
124 | 
125 | export const deleteNote = (id: string): Promise => {
126 |   return m.request({
127 |     method: 'DELETE',
128 |     url: apiServer() + '/api/v1/note/' + id,
129 |     headers: {
130 |       Authorization: bearerToken(),
131 |     },
132 |   });
133 | };
134 | 


--------------------------------------------------------------------------------
/src/helper/mock/handler.ts:
--------------------------------------------------------------------------------
  1 | import { rest } from 'msw';
  2 | import { apiServer } from '@/helper/global';
  3 | import { AsyncResponseResolverReturnType, MockedResponse } from 'msw';
  4 | import { User as UserLogin, LoginResponse } from '@/page/login/loginstore';
  5 | import { RegisterResponse } from '@/page/register/registerstore';
  6 | import {
  7 |   Note,
  8 |   NoteListResponse,
  9 |   NoteUpdateRequest,
 10 |   NoteCreateRequest,
 11 | } from '@/page/notepad/notestore';
 12 | import { randId } from '@/helper/random';
 13 | import { GenericResponse } from '@/helper/response';
 14 | 
 15 | let notes = [] as Note[];
 16 | 
 17 | export const handlers = [
 18 |   // GET healthcheck.
 19 |   rest.get(apiServer() + '/api/v1', (req, res, ctx) => {
 20 |     return res(
 21 |       ctx.status(200),
 22 |       ctx.json({
 23 |         status: 'OK',
 24 |         message: 'ready',
 25 |       } as GenericResponse),
 26 |     );
 27 |   }),
 28 |   // POST login.
 29 |   rest.post(
 30 |     apiServer() + '/api/v1/login',
 31 |     (req, res, ctx): AsyncResponseResolverReturnType => {
 32 |       if (
 33 |         JSON.stringify(req.body) ===
 34 |         JSON.stringify({
 35 |           email: 'jsmith@example.com',
 36 |           password: 'password',
 37 |         } as UserLogin)
 38 |       ) {
 39 |         return res(
 40 |           ctx.status(200),
 41 |           ctx.json({
 42 |             status: 'OK',
 43 |             token: '1',
 44 |           } as LoginResponse),
 45 |         );
 46 |       } else {
 47 |         return res(
 48 |           ctx.status(400),
 49 |           ctx.json({
 50 |             status: 'Bad Request',
 51 |             message: 'Username and password does not match.',
 52 |           } as GenericResponse),
 53 |         );
 54 |       }
 55 |     },
 56 |   ),
 57 |   // POST register.
 58 |   rest.post(
 59 |     apiServer() + '/api/v1/register',
 60 |     (req, res, ctx): AsyncResponseResolverReturnType => {
 61 |       return res(
 62 |         ctx.status(201),
 63 |         ctx.json({
 64 |           status: 'Created',
 65 |           record_id: '1',
 66 |         } as RegisterResponse),
 67 |       );
 68 |     },
 69 |   ),
 70 |   // GET notes.
 71 |   rest.get(
 72 |     apiServer() + '/api/v1/note',
 73 |     (req, res, ctx): AsyncResponseResolverReturnType => {
 74 |       return res(
 75 |         ctx.status(200),
 76 |         ctx.json({
 77 |           notes: notes,
 78 |         } as NoteListResponse),
 79 |       );
 80 |     },
 81 |   ),
 82 |   // DELETE note.
 83 |   rest.delete(
 84 |     apiServer() + '/api/v1/note/:noteId',
 85 |     (req, res, ctx): AsyncResponseResolverReturnType => {
 86 |       const { noteId } = req.params;
 87 |       notes = notes.filter(function (v) {
 88 |         return v.id !== noteId;
 89 |       });
 90 |       return res(
 91 |         ctx.status(200),
 92 |         ctx.json({
 93 |           status: 'OK',
 94 |           message: 'Note deleted.',
 95 |         } as GenericResponse),
 96 |       );
 97 |     },
 98 |   ),
 99 |   // POST note.
100 |   rest.post(
101 |     apiServer() + '/api/v1/note',
102 |     (req, res, ctx): AsyncResponseResolverReturnType => {
103 |       const data = req.body as NoteCreateRequest;
104 |       const id = randId();
105 |       notes.push({ id: id, message: data.message });
106 |       return res(
107 |         ctx.status(201),
108 |         ctx.json({
109 |           status: 'OK',
110 |           message: 'Note created.',
111 |         } as GenericResponse),
112 |       );
113 |     },
114 |   ),
115 |   // PUT note.
116 |   rest.put(
117 |     apiServer() + '/api/v1/note/:noteId',
118 |     (req, res, ctx): AsyncResponseResolverReturnType => {
119 |       const { noteId } = req.params;
120 |       const data = req.body as NoteUpdateRequest;
121 |       for (let i = 0; i < notes.length; i++) {
122 |         if (notes[i].id === noteId) {
123 |           notes[i].message = data.message;
124 |           break;
125 |         }
126 |       }
127 |       return res(
128 |         ctx.status(200),
129 |         ctx.json({
130 |           status: 'OK',
131 |           message: 'Note updated.',
132 |         } as GenericResponse),
133 |       );
134 |     },
135 |   ),
136 | ];
137 | 


--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
  1 | /* eslint-disable @typescript-eslint/no-var-requires */
  2 | const webpack = require('webpack');
  3 | const path = require('path');
  4 | const { CleanWebpackPlugin } = require('clean-webpack-plugin');
  5 | const HtmlWebpackPlugin = require('html-webpack-plugin');
  6 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
  7 | const CopyWebpackPlugin = require('copy-webpack-plugin');
  8 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
  9 | 
 10 | // Try the environment variable, otherwise use root.
 11 | const ASSET_PATH = '/';
 12 | const DEV = process.env.NODE_ENV !== 'production';
 13 | 
 14 | module.exports = {
 15 |   entry: path.resolve(__dirname, 'src', 'index'),
 16 |   plugins: [
 17 |     new CleanWebpackPlugin({
 18 |       verbose: false,
 19 |       cleanStaleWebpackAssets: false,
 20 |     }),
 21 |     new HtmlWebpackPlugin({
 22 |       title: 'mithril-template',
 23 |       filename: path.resolve(__dirname, 'dist', 'index.html'),
 24 |       favicon: path.resolve(__dirname, 'static', 'favicon.ico'),
 25 |     }),
 26 |     new MiniCssExtractPlugin({
 27 |       filename: 'static/[name].[contenthash].css',
 28 |     }),
 29 |     new CopyWebpackPlugin({
 30 |       patterns: [
 31 |         {
 32 |           from: path.resolve(__dirname, 'static', 'healthcheck.html'),
 33 |           to: 'static/',
 34 |         },
 35 |         {
 36 |           from: path.resolve(
 37 |             __dirname,
 38 |             '.storybook',
 39 |             'static',
 40 |             'mockServiceWorker.js',
 41 |           ),
 42 |           to: '',
 43 |         },
 44 |       ],
 45 |     }),
 46 |     new webpack.DefinePlugin({
 47 |       __API_SCHEME__: JSON.stringify('http'),
 48 |       __API_HOST__: JSON.stringify('localhost'),
 49 |       __API_PORT__: JSON.stringify(8080),
 50 |       __PRODUCTION__: JSON.stringify(!DEV),
 51 |       __VERSION__: JSON.stringify('1.0.0'),
 52 |       __MOCK_SERVER__: JSON.stringify(true),
 53 |     }),
 54 |     new ForkTsCheckerWebpackPlugin({
 55 |       eslint: {
 56 |         files: './**/*.{ts,tsx,js,jsx}',
 57 |       },
 58 |     }),
 59 |   ],
 60 |   resolve: {
 61 |     extensions: ['.tsx', '.ts', '.jsx', '.js'],
 62 |     alias: {
 63 |       '@': path.resolve(__dirname, 'src'),
 64 |       '~': path.resolve(__dirname),
 65 |     },
 66 |   },
 67 |   output: {
 68 |     path: path.resolve(__dirname, 'dist'),
 69 |     filename: 'static/[name].[hash].js',
 70 |     sourceMapFilename: 'static/[name].[hash].js.map',
 71 |     publicPath: ASSET_PATH,
 72 |   },
 73 |   optimization: {
 74 |     splitChunks: {
 75 |       chunks: 'all',
 76 |     },
 77 |   },
 78 |   devtool: DEV ? 'eval-cheap-module-source-map' : 'source-map',
 79 |   devServer: {
 80 |     contentBase: path.resolve(__dirname, 'dist'),
 81 |     historyApiFallback: true,
 82 |     hot: true,
 83 |     port: 8080,
 84 |   },
 85 |   performance: {
 86 |     hints: false,
 87 |   },
 88 |   module: {
 89 |     rules: [
 90 |       {
 91 |         test: /\.(css|scss)$/,
 92 |         use: [
 93 |           MiniCssExtractPlugin.loader,
 94 |           {
 95 |             loader: 'css-loader',
 96 |             options: {
 97 |               modules: {
 98 |                 localIdentName: '[name]__[local]__[hash:base64:5]',
 99 |                 mode: 'global',
100 |               },
101 |               sourceMap: true,
102 |             },
103 |           },
104 |           {
105 |             loader: 'sass-loader',
106 |             options: {
107 |               sourceMap: true,
108 |             },
109 |           },
110 |         ],
111 |       },
112 |       {
113 |         test: /\.(js|jsx|ts|tsx)$/,
114 |         exclude: /node_modules/,
115 |         loader: 'babel-loader',
116 |         options: {
117 |           cacheDirectory: true,
118 |         },
119 |       },
120 |       {
121 |         test: /\.(ts|tsx)$/,
122 |         exclude: /node_modules/,
123 |         loader: 'ts-loader',
124 |         options: {
125 |           transpileOnly: true,
126 |           experimentalWatchApi: true,
127 |         },
128 |       },
129 |       {
130 |         enforce: 'pre',
131 |         test: /\.js$/,
132 |         exclude: /node_modules/,
133 |         loader: 'source-map-loader',
134 |       },
135 |     ],
136 |   },
137 | };
138 | 


--------------------------------------------------------------------------------
/src/page/notepad/notepad.ts:
--------------------------------------------------------------------------------
  1 | import m from 'mithril';
  2 | import { Breadcrumb } from '@/component/breadcrumb/breadcrumb';
  3 | import * as NoteStore from '@/page/notepad/notestore';
  4 | import { Note } from '@/page/notepad/note';
  5 | 
  6 | export const NotepadPage: m.ClosureComponent = () => {
  7 |   let list = [] as NoteStore.Note[];
  8 | 
  9 |   NoteStore.load()
 10 |     .then((arr: NoteStore.Note[]) => {
 11 |       list = arr;
 12 |     })
 13 |     // eslint-disable-next-line @typescript-eslint/no-empty-function
 14 |     .catch(() => {});
 15 | 
 16 |   let current: NoteStore.Note = {
 17 |     id: '',
 18 |     message: '',
 19 |   };
 20 | 
 21 |   const clear = (): void => {
 22 |     current = {
 23 |       id: '',
 24 |       message: '',
 25 |     };
 26 |   };
 27 | 
 28 |   return {
 29 |     view: () =>
 30 |       m('div', [
 31 |         m(Breadcrumb, {
 32 |           levels: [
 33 |             { icon: 'fa-home', name: 'Welcome', url: '/' },
 34 |             {
 35 |               icon: 'fa-sticky-note',
 36 |               name: 'Notepad',
 37 |               url: location.pathname,
 38 |             },
 39 |           ],
 40 |         }),
 41 |         m('section', { id: 'note-section' }, [
 42 |           m('div', { class: 'container is-fluid' }, [
 43 |             m('div', { class: 'box' }, [
 44 |               m('div', { class: 'field' }, [
 45 |                 m('label', { class: 'label' }, 'To Do'),
 46 |                 m('div', { class: 'control' }, [
 47 |                   m('input', {
 48 |                     class: 'input',
 49 |                     type: 'text',
 50 |                     placeholder: 'What would you like to do?',
 51 |                     name: 'note-add',
 52 |                     'data-cy': 'note-text',
 53 |                     onkeypress: function (e: KeyboardEvent) {
 54 |                       if (e.key !== 'Enter') {
 55 |                         return;
 56 |                       }
 57 |                       NoteStore.submit(current)
 58 |                         .then(() => {
 59 |                           // TODO: This could be optimized instead of reloading all.
 60 |                           NoteStore.load()
 61 |                             .then((arr: NoteStore.Note[]) => {
 62 |                               list = arr;
 63 |                             })
 64 |                             // eslint-disable-next-line @typescript-eslint/no-empty-function
 65 |                             .catch(() => {});
 66 |                           clear();
 67 |                         }) // eslint-disable-next-line @typescript-eslint/no-empty-function
 68 |                         .catch(() => {});
 69 |                     },
 70 |                     oninput: function (e: { target: HTMLInputElement }) {
 71 |                       current.message = e.target.value;
 72 |                     },
 73 |                     value: current.message,
 74 |                   }),
 75 |                 ]),
 76 |               ]),
 77 |               m('nav', { class: 'level is-mobile' }, [
 78 |                 m('div', { class: 'level-left' }, [
 79 |                   m(
 80 |                     'a',
 81 |                     {
 82 |                       class: 'level-item',
 83 |                       title: 'Add note',
 84 |                       onclick: '{NoteStore.submit}',
 85 |                     },
 86 |                     [
 87 |                       m('span', { class: 'icon is-small has-text-success' }, [
 88 |                         m('i', {
 89 |                           class: 'far fa-plus-square',
 90 |                           'data-cy': 'add-note-link',
 91 |                         }),
 92 |                       ]),
 93 |                     ],
 94 |                   ),
 95 |                 ]),
 96 |               ]),
 97 |             ]),
 98 |             m('div', [
 99 |               m('ul', { id: 'listTodo' }, [
100 |                 list.map((n: NoteStore.Note) =>
101 |                   m(Note, {
102 |                     key: n.id,
103 |                     id: n.id,
104 |                     message: n.message,
105 |                     oninput: function (e: { target: HTMLInputElement }) {
106 |                       n.message = e.target.value;
107 |                     },
108 |                     removeNote: function (id: string) {
109 |                       list = list.filter((i) => {
110 |                         return i.id !== id;
111 |                       });
112 |                     },
113 |                   }),
114 |                 ),
115 |               ]),
116 |             ]),
117 |           ]),
118 |         ]),
119 |       ]),
120 |   };
121 | };
122 | 


--------------------------------------------------------------------------------
/src/layout/nav/nav.ts:
--------------------------------------------------------------------------------
  1 | import m from 'mithril';
  2 | import { isLoggedIn, clear } from '@/helper/cookiestore';
  3 | 
  4 | export const Nav = (): m.Component => {
  5 |   const logout = () => {
  6 |     clear();
  7 |     m.route.set('/');
  8 |   };
  9 | 
 10 |   let navClass = '';
 11 |   let navMobileClass = '';
 12 | 
 13 |   const toggleNavClass = () => {
 14 |     navClass = navClass === '' ? 'is-active' : '';
 15 |   };
 16 |   const removeNavClass = () => {
 17 |     navClass = '';
 18 |   };
 19 | 
 20 |   const toggleMobileNavClass = () => {
 21 |     navMobileClass = navMobileClass === '' ? 'is-active' : '';
 22 |   };
 23 |   const removeMobileNavClass = () => {
 24 |     navMobileClass = '';
 25 |   };
 26 | 
 27 |   return {
 28 |     oncreate: () => {
 29 |       // Close the nav menus when an item is clicked.
 30 |       const links = document.querySelectorAll('.navbar-item');
 31 |       links.forEach((link) => {
 32 |         link.addEventListener('click', function () {
 33 |           removeNavClass();
 34 |           removeMobileNavClass();
 35 |         });
 36 |       });
 37 |     },
 38 |     view: () =>
 39 |       m(
 40 |         'nav',
 41 |         {
 42 |           class: 'navbar is-black',
 43 |           role: 'navigation',
 44 |           'aria-label': 'main navigation',
 45 |         },
 46 |         [
 47 |           m('div', { class: 'navbar-brand' }, [
 48 |             m(
 49 |               m.route.Link,
 50 |               { class: 'navbar-item', href: '/', 'data-cy': 'home-link' },
 51 |               m('strong', 'mithril-template'),
 52 |             ),
 53 |             m(
 54 |               'a',
 55 |               {
 56 |                 class:
 57 |                   'navbar-burger burger mobile-navbar-top ' + navMobileClass,
 58 |                 role: 'button',
 59 |                 'aria-label': 'menu',
 60 |                 'aria-expanded': 'false',
 61 |                 'data-target': 'navbar-top',
 62 |                 onclick: function () {
 63 |                   toggleNavClass();
 64 |                   toggleMobileNavClass();
 65 |                   return false;
 66 |                 },
 67 |               },
 68 |               [
 69 |                 m('span', { 'aria-hidden': 'true' }),
 70 |                 m('span', { 'aria-hidden': 'true' }),
 71 |                 m('span', { 'aria-hidden': 'true' }),
 72 |               ],
 73 |             ),
 74 |           ]),
 75 |           m(
 76 |             'div',
 77 |             {
 78 |               id: 'navbar-top',
 79 |               class: 'navbar-menu ' + navMobileClass,
 80 |               onmouseleave: () => {
 81 |                 removeNavClass();
 82 |                 removeMobileNavClass();
 83 |               },
 84 |             },
 85 |             m(
 86 |               'div',
 87 |               { class: 'navbar-end' },
 88 |               m('div', { class: 'navbar-item has-dropdown ' + navClass }, [
 89 |                 m(
 90 |                   'a',
 91 |                   {
 92 |                     class: 'navbar-link',
 93 |                     onclick: () => {
 94 |                       toggleNavClass();
 95 |                       toggleMobileNavClass();
 96 |                       return false;
 97 |                     },
 98 |                   },
 99 |                   'Menu',
100 |                 ),
101 |                 m('div', { class: 'navbar-dropdown is-right' }, [
102 |                   !isLoggedIn() &&
103 |                     m(
104 |                       m.route.Link,
105 |                       { class: 'navbar-item', href: '/login' },
106 |                       ' Login ',
107 |                     ),
108 |                   m(
109 |                     m.route.Link,
110 |                     {
111 |                       class:
112 |                         'navbar-item ' +
113 |                         (location.pathname === '/about' ? 'is-active' : ''),
114 |                       href: '/about',
115 |                     },
116 |                     'About',
117 |                   ),
118 |                   m('hr', { class: 'navbar-divider' }),
119 |                   isLoggedIn() &&
120 |                     m(
121 |                       'a',
122 |                       {
123 |                         class: 'dropdown-item',
124 |                         onclick: function () {
125 |                           logout();
126 |                         },
127 |                       },
128 |                       'Logout',
129 |                     ),
130 |                   m('div', { class: 'navbar-item' }, 'v1.0.0'),
131 |                 ]),
132 |               ]),
133 |             ),
134 |           ),
135 |         ],
136 |       ),
137 |   };
138 | };
139 | 


--------------------------------------------------------------------------------
/src/page/login/login.ts:
--------------------------------------------------------------------------------
  1 | import m from 'mithril';
  2 | import { submit, submitText, User } from '@/page/login/loginstore';
  3 | import { Input } from '@/component/input/input';
  4 | 
  5 | interface Attrs {
  6 |   email?: string;
  7 |   password?: string;
  8 | }
  9 | 
 10 | export const LoginPage: m.ClosureComponent = ({ attrs }) => {
 11 |   let user: User = {
 12 |     email: '',
 13 |     password: '',
 14 |   };
 15 | 
 16 |   const clear = () => {
 17 |     user = {
 18 |       email: '',
 19 |       password: '',
 20 |     };
 21 |   };
 22 | 
 23 |   // Prefill the fields.
 24 |   user.email = attrs.email || '';
 25 |   user.password = attrs.password || '';
 26 | 
 27 |   return {
 28 |     view: () =>
 29 |       m(
 30 |         'div',
 31 |         {
 32 |           style: {
 33 |             display: 'flex',
 34 |             alignItems: 'center',
 35 |             justifyContent: 'center',
 36 |             // TODO: Change this so it's more dynamic via CSS if possible.
 37 |             height: `${window.innerHeight - (52 + 56)}px`,
 38 |             minHeight: '380px',
 39 |           },
 40 |         },
 41 |         [
 42 |           m('div', { class: 'card' }, [
 43 |             m('section', { class: 'card-content' }, [
 44 |               m('div', { class: 'container' }, [
 45 |                 m('h1', { class: 'title' }, 'Login'),
 46 |                 m(
 47 |                   'h2',
 48 |                   { class: 'subtitle' },
 49 |                   'Enter your login information below.',
 50 |                 ),
 51 |               ]),
 52 |               m('div', { class: 'container mt-4' }, [
 53 |                 m(
 54 |                   'form',
 55 |                   {
 56 |                     name: 'login',
 57 |                     onsubmit: function (e: InputEvent) {
 58 |                       submit(e, user)
 59 |                         .then(() => {
 60 |                           clear();
 61 |                         })
 62 |                         // eslint-disable-next-line @typescript-eslint/no-empty-function, prettier/prettier
 63 |                         .catch(() => { });
 64 |                     },
 65 |                   },
 66 |                   [
 67 |                     m(Input, {
 68 |                       label: 'Email',
 69 |                       name: 'email',
 70 |                       required: true,
 71 |                       oninput: function (e: { target: HTMLInputElement }) {
 72 |                         user.email = e.target.value;
 73 |                       },
 74 |                       value: user.email,
 75 |                     }),
 76 |                     m(Input, {
 77 |                       label: 'Password',
 78 |                       name: 'password',
 79 |                       required: true,
 80 |                       type: 'password',
 81 |                       oninput: function (e: { target: HTMLInputElement }) {
 82 |                         user.password = e.target.value;
 83 |                       },
 84 |                       value: user.password,
 85 |                     }),
 86 |                     m('div', { class: 'field is-grouped' }, [
 87 |                       m('p', { class: 'control' }, [
 88 |                         m(
 89 |                           'button',
 90 |                           {
 91 |                             class: 'button is-primary',
 92 |                             id: 'submit',
 93 |                             type: 'submit',
 94 |                             'data-cy': 'submit',
 95 |                           },
 96 |                           submitText('Submit'),
 97 |                         ),
 98 |                       ]),
 99 |                       m('p', { class: 'control' }, [
100 |                         m(
101 |                           'button',
102 |                           {
103 |                             class: 'button is-light',
104 |                             type: 'button',
105 |                             onclick: function () {
106 |                               clear();
107 |                             },
108 |                           },
109 |                           'Clear',
110 |                         ),
111 |                       ]),
112 |                       m('p', { class: 'control' }, [
113 |                         m(
114 |                           m.route.Link,
115 |                           { class: 'button is-light', href: '/register' },
116 |                           'Register',
117 |                         ),
118 |                       ]),
119 |                     ]),
120 |                   ],
121 |                 ),
122 |               ]),
123 |             ]),
124 |           ]),
125 |         ],
126 |       ),
127 |   };
128 | };
129 | 


--------------------------------------------------------------------------------
/src/component/flash/flash.stories.ts:
--------------------------------------------------------------------------------
  1 | import m from 'mithril';
  2 | import {
  3 |   Flash,
  4 |   showFlash,
  5 |   setFlashTimeout,
  6 |   setPrepend,
  7 |   clearFlash,
  8 |   MessageType,
  9 | } from '@/component/flash/flash';
 10 | 
 11 | export default {
 12 |   title: 'Component/Flash',
 13 |   component: Flash,
 14 | };
 15 | 
 16 | export const success = (args: { text: string }): m.Component => ({
 17 |   oninit: () => {
 18 |     setFlashTimeout(-1);
 19 |     showFlash(args.text, MessageType.success);
 20 |   },
 21 |   onremove: () => {
 22 |     clearFlash();
 23 |   },
 24 |   view: () => m(Flash),
 25 | });
 26 | success.args = {
 27 |   text: 'This is a success message.',
 28 | };
 29 | success.argTypes = {
 30 |   text: { name: 'Text', control: { type: 'text' } },
 31 | };
 32 | 
 33 | export const failed = (args: { text: string }): m.Component => ({
 34 |   oninit: () => {
 35 |     setFlashTimeout(-1);
 36 |     showFlash(args.text, MessageType.failed);
 37 |   },
 38 |   onremove: () => {
 39 |     clearFlash();
 40 |   },
 41 |   view: () => m(Flash),
 42 | });
 43 | failed.args = {
 44 |   text: 'This is a failed message.',
 45 | };
 46 | failed.argTypes = {
 47 |   text: { name: 'Text', control: { type: 'text' } },
 48 | };
 49 | 
 50 | export const warning = (args: { text: string }): m.Component => ({
 51 |   oninit: () => {
 52 |     setFlashTimeout(-1);
 53 |     showFlash(args.text, MessageType.warning);
 54 |   },
 55 |   onremove: () => {
 56 |     clearFlash();
 57 |   },
 58 |   view: () => m(Flash),
 59 | });
 60 | warning.args = {
 61 |   text: 'This is a warning message.',
 62 | };
 63 | warning.argTypes = {
 64 |   text: { name: 'Text', control: { type: 'text' } },
 65 | };
 66 | 
 67 | export const primary = (args: { text: string }): m.Component => ({
 68 |   oninit: () => {
 69 |     setFlashTimeout(-1);
 70 |     showFlash(args.text, MessageType.primary);
 71 |   },
 72 |   onremove: () => {
 73 |     clearFlash();
 74 |   },
 75 |   view: () => m(Flash),
 76 | });
 77 | primary.args = {
 78 |   text: 'This is a primary message.',
 79 | };
 80 | primary.argTypes = {
 81 |   text: { name: 'Text', control: { type: 'text' } },
 82 | };
 83 | 
 84 | export const link = (args: { text: string }): m.Component => ({
 85 |   oninit: () => {
 86 |     setFlashTimeout(-1);
 87 |     showFlash(args.text, MessageType.link);
 88 |   },
 89 |   onremove: () => {
 90 |     clearFlash();
 91 |   },
 92 |   view: () => m(Flash),
 93 | });
 94 | link.args = {
 95 |   text: 'This is a link message.',
 96 | };
 97 | link.argTypes = {
 98 |   text: { name: 'Text', control: { type: 'text' } },
 99 | };
100 | 
101 | export const info = (args: { text: string }): m.Component => ({
102 |   oninit: () => {
103 |     setFlashTimeout(-1);
104 |     showFlash(args.text, MessageType.info);
105 |   },
106 |   onremove: () => {
107 |     clearFlash();
108 |   },
109 |   view: () => m(Flash),
110 | });
111 | info.args = {
112 |   text: 'This is an info message.',
113 | };
114 | info.argTypes = {
115 |   text: { name: 'Text', control: { type: 'text' } },
116 | };
117 | 
118 | export const dark = (args: { text: string }): m.Component => ({
119 |   oninit: () => {
120 |     setFlashTimeout(-1);
121 |     showFlash(args.text, MessageType.dark);
122 |   },
123 |   onremove: () => {
124 |     clearFlash();
125 |   },
126 |   view: () => m(Flash),
127 | });
128 | dark.args = {
129 |   text: 'This is an dark message.',
130 | };
131 | dark.argTypes = {
132 |   text: { name: 'Text', control: { type: 'text' } },
133 | };
134 | 
135 | export const action = (args: {
136 |   text: string;
137 |   numberSlider: number;
138 |   prepend: boolean;
139 |   messageType: MessageType;
140 | }): m.Component => ({
141 |   oninit: () => {
142 |     setFlashTimeout(args.numberSlider);
143 |     setPrepend(args.prepend);
144 |   },
145 |   onremove: () => {
146 |     clearFlash();
147 |   },
148 |   view: () =>
149 |     m('div', [
150 |       m(
151 |         'button',
152 |         {
153 |           onclick: () => {
154 |             showFlash(args.text, args.messageType);
155 |           },
156 |         },
157 |         'Show Flash',
158 |       ),
159 |       m(Flash),
160 |     ]),
161 | });
162 | action.args = {
163 |   text: 'This is a flash message',
164 |   numberSlider: 2000,
165 |   prepend: false,
166 |   messageType: MessageType.success,
167 | };
168 | action.argTypes = {
169 |   text: { name: 'Text', control: { type: 'text' } },
170 |   numberSlider: {
171 |     name: 'Timeout (milliseconds)',
172 |     control: { type: 'range', min: 0, max: 10000, step: 1000 },
173 |   },
174 |   prepend: { name: 'Toggle', control: { type: 'boolean' } },
175 |   messageType: {
176 |     name: 'Type',
177 |     control: { type: 'select', options: MessageType },
178 |   },
179 | };
180 | 


--------------------------------------------------------------------------------
/src/page/register/register.ts:
--------------------------------------------------------------------------------
  1 | import m from 'mithril';
  2 | import { submit, submitText, User } from '@/page/register/registerstore';
  3 | 
  4 | interface Attrs {
  5 |   firstName?: string;
  6 |   lastName?: string;
  7 |   email?: string;
  8 |   password?: string;
  9 | }
 10 | 
 11 | export const RegisterPage: m.ClosureComponent = ({ attrs }) => {
 12 |   let user: User = {
 13 |     first_name: '',
 14 |     last_name: '',
 15 |     email: '',
 16 |     password: '',
 17 |   };
 18 | 
 19 |   const clear = () => {
 20 |     user = {
 21 |       first_name: '',
 22 |       last_name: '',
 23 |       email: '',
 24 |       password: '',
 25 |     };
 26 |   };
 27 | 
 28 |   // Prefill the fields.
 29 |   user.first_name = attrs.firstName || '';
 30 |   user.last_name = attrs.lastName || '';
 31 |   user.email = attrs.email || '';
 32 |   user.password = attrs.password || '';
 33 | 
 34 |   return {
 35 |     view: () =>
 36 |       m('div', [
 37 |         m('section', { class: 'section' }, [
 38 |           m('div', { class: 'container' }, [
 39 |             m('h1', { class: 'title' }, 'Register'),
 40 |             m('h2', { class: 'subtitle' }, 'Enter your information below.'),
 41 |           ]),
 42 |           m('div', { class: 'container mt-4' }, [
 43 |             m(
 44 |               'form',
 45 |               {
 46 |                 name: 'register',
 47 |                 onsubmit: function (e: InputEvent) {
 48 |                   submit(e, user)
 49 |                     .then(() => {
 50 |                       clear();
 51 |                     }) // eslint-disable-next-line @typescript-eslint/no-empty-function
 52 |                     .catch(() => {});
 53 |                 },
 54 |               },
 55 |               [
 56 |                 m('div', { class: 'field' }, [
 57 |                   m('label', { class: 'label' }, 'First Name'),
 58 |                   m('div', { class: 'control' }, [
 59 |                     m('input', {
 60 |                       class: 'input',
 61 |                       label: 'first_name',
 62 |                       name: 'first_name',
 63 |                       type: 'text',
 64 |                       'data-cy': 'first_name',
 65 |                       required: true,
 66 |                       oninput: function (e: { target: HTMLInputElement }) {
 67 |                         user.first_name = e.target.value;
 68 |                       },
 69 |                       value: user.first_name,
 70 |                     }),
 71 |                   ]),
 72 |                 ]),
 73 |                 m('div', { class: 'field' }, [
 74 |                   m('label', { class: 'label' }, 'Last Name'),
 75 |                   m('div', { class: 'control' }, [
 76 |                     m('input', {
 77 |                       class: 'input',
 78 |                       label: 'last_name',
 79 |                       name: 'last_name',
 80 |                       type: 'text',
 81 |                       'data-cy': 'last_name',
 82 |                       required: true,
 83 |                       oninput: function (e: { target: HTMLInputElement }) {
 84 |                         user.last_name = e.target.value;
 85 |                       },
 86 |                       value: user.last_name,
 87 |                     }),
 88 |                   ]),
 89 |                 ]),
 90 |                 m('div', { class: 'field' }, [
 91 |                   m('label', { class: 'label' }, 'Email'),
 92 |                   m('div', { class: 'control' }, [
 93 |                     m('input', {
 94 |                       class: 'input',
 95 |                       label: 'Email',
 96 |                       name: 'email',
 97 |                       type: 'text',
 98 |                       'data-cy': 'email',
 99 |                       required: true,
100 |                       oninput: function (e: { target: HTMLInputElement }) {
101 |                         user.email = e.target.value;
102 |                       },
103 |                       value: user.email,
104 |                     }),
105 |                   ]),
106 |                 ]),
107 |                 m('div', { class: 'field' }, [
108 |                   m('label', { class: 'label' }, 'Password'),
109 |                   m('div', { class: 'control' }, [
110 |                     m('input', {
111 |                       class: 'input',
112 |                       label: 'Password',
113 |                       name: 'password',
114 |                       type: 'password',
115 |                       'data-cy': 'password',
116 |                       required: true,
117 |                       oninput: function (e: { target: HTMLInputElement }) {
118 |                         user.password = e.target.value;
119 |                       },
120 |                       value: user.password,
121 |                     }),
122 |                   ]),
123 |                 ]),
124 |                 m('div', { class: 'field is-grouped' }, [
125 |                   m('p', { class: 'control' }, [
126 |                     m(
127 |                       'button',
128 |                       {
129 |                         class: 'button is-primary',
130 |                         id: 'submit',
131 |                         type: 'submit',
132 |                         'data-cy': 'submit',
133 |                       },
134 |                       submitText('Create Account'),
135 |                     ),
136 |                   ]),
137 |                   m('p', { class: 'control' }, [
138 |                     m(
139 |                       'button',
140 |                       {
141 |                         class: 'button is-light',
142 |                         type: 'button',
143 |                         onclick: function () {
144 |                           clear();
145 |                         },
146 |                       },
147 |                       'Clear',
148 |                     ),
149 |                   ]),
150 |                 ]),
151 |               ],
152 |             ),
153 |           ]),
154 |         ]),
155 |       ]),
156 |   };
157 | };
158 | 


--------------------------------------------------------------------------------
/src/component/reference/flex.stories.tsx:
--------------------------------------------------------------------------------
  1 | import m from 'mithril';
  2 | 
  3 | // Source: https://css-tricks.com/snippets/css/a-guide-to-flexbox/
  4 | 
  5 | export default {
  6 |   title: 'Example/Flexbox',
  7 | };
  8 | 
  9 | enum FlexDisplay {
 10 |   'flex' = 'flex',
 11 |   'inline-flex' = 'inline-flex',
 12 | }
 13 | 
 14 | enum FlexDirection {
 15 |   'row' = 'row',
 16 |   'row-reverse' = 'row-reverse',
 17 |   'column' = 'column',
 18 |   'column-reverse' = 'column-reverse',
 19 | }
 20 | 
 21 | enum FlexWrap {
 22 |   'nowrap' = 'nowrap',
 23 |   'wrap' = 'wrap',
 24 |   'wrap-reverse' = 'wrap-reverse',
 25 | }
 26 | 
 27 | enum JustifyContent {
 28 |   'flex-start' = 'flex-start',
 29 |   'flex-end' = 'flex-end',
 30 |   'center' = 'center',
 31 |   'space-between' = 'space-between',
 32 |   'space-around' = 'space-around',
 33 |   'space-evenly' = 'space-evenly',
 34 |   'start' = 'start',
 35 |   'end' = 'end',
 36 |   'left' = 'left',
 37 |   'right' = 'right',
 38 |   'safe' = 'safe',
 39 |   'unsafe' = 'unsafe',
 40 | }
 41 | 
 42 | enum AlignItems {
 43 |   'stretch' = 'stretch',
 44 |   'flex-start' = 'flex-start',
 45 |   'flex-end' = 'flex-end',
 46 |   'center' = 'center',
 47 |   'baseline' = 'baseline',
 48 |   'first baseline' = 'first baseline',
 49 |   'last baseline' = 'last baseline',
 50 |   'start' = 'start',
 51 |   'end' = 'end',
 52 |   'self-start' = 'self-start',
 53 |   'self-end' = 'self-end',
 54 |   'safe' = 'safe',
 55 |   'unsafe' = 'unsafe',
 56 | }
 57 | 
 58 | enum AlignContent {
 59 |   'flex-start' = 'flex-start',
 60 |   'flex-end' = 'flex-end',
 61 |   'center' = 'center',
 62 |   'space-between' = 'space-between',
 63 |   'space-around' = 'space-around',
 64 |   'space-evenly' = 'space-evenly',
 65 |   'stretch' = 'stretch',
 66 |   'start' = 'start',
 67 |   'end' = 'end',
 68 |   'baseline' = 'baseline',
 69 |   'first baseline' = 'first baseline',
 70 |   'last-baseline' = 'last-baseline',
 71 |   'safe' = 'safe',
 72 |   'unsafe' = 'unsafe',
 73 | }
 74 | 
 75 | enum AlignSelf {
 76 |   'auto' = 'auto',
 77 |   'flex-start' = 'flex-start',
 78 |   'flex-end' = 'flex-end',
 79 |   'center' = 'center',
 80 |   'baseline' = 'baseline',
 81 |   'stretch' = 'stretch',
 82 | }
 83 | 
 84 | interface Args {
 85 |   flexDisplay: FlexDisplay;
 86 |   flexDirection: FlexDirection;
 87 |   flexWrap: FlexWrap;
 88 |   justifyContent: JustifyContent;
 89 |   alignItems: AlignItems;
 90 |   alignContent: AlignContent;
 91 |   childMinHeight: number[];
 92 |   order: number[];
 93 |   flexGrow: number[];
 94 |   flexShrink: number[];
 95 |   flexBasis: string[];
 96 |   alignSelf: AlignSelf[];
 97 | }
 98 | 
 99 | export const flex = (args: Args): m.Component => {
100 |   const childStyle = { border: 'red 4px solid' };
101 | 
102 |   return {
103 |     view: () =>
104 |       m(
105 |         'div',
106 |         {
107 |           style: {
108 |             display: args.flexDisplay,
109 |             flexDirection: args.flexDirection,
110 |             flexWrap: args.flexWrap,
111 |             justifyContent: args.justifyContent,
112 |             alignItems: args.alignItems,
113 |             alignContent: args.alignContent,
114 |             border: 'blue 4px solid',
115 |           },
116 |         },
117 |         [
118 |           m(
119 |             'div',
120 |             {
121 |               style: {
122 |                 ...childStyle,
123 |                 order: args.order[0],
124 |                 flexGrow: args.flexGrow[0],
125 |                 flexShrink: args.flexShrink[0],
126 |                 flexBasis: args.flexBasis[0],
127 |                 alignSelf: args.alignSelf[0],
128 |                 minHeight: `${args.childMinHeight[0]}px`,
129 |               },
130 |             },
131 |             'Col 1 - 25px',
132 |           ),
133 |           m(
134 |             'div',
135 |             {
136 |               style: {
137 |                 ...childStyle,
138 |                 order: args.order[1],
139 |                 flexGrow: args.flexGrow[1],
140 |                 flexShrink: args.flexShrink[1],
141 |                 flexBasis: args.flexBasis[1],
142 |                 alignSelf: args.alignSelf[1],
143 |                 minHeight: `${args.childMinHeight[1]}px`,
144 |               },
145 |             },
146 |             'Col 2 - 100px',
147 |           ),
148 |           m(
149 |             'div',
150 |             {
151 |               style: {
152 |                 ...childStyle,
153 |                 order: args.order[2],
154 |                 flexGrow: args.flexGrow[2],
155 |                 flexShrink: args.flexShrink[2],
156 |                 flexBasis: args.flexBasis[2],
157 |                 alignSelf: args.alignSelf[2],
158 |                 minHeight: `${args.childMinHeight[2]}px`,
159 |               },
160 |             },
161 |             'Col 3 - 75px',
162 |           ),
163 |         ],
164 |       ),
165 |   };
166 | };
167 | 
168 | flex.args = {
169 |   flexDisplay: FlexDisplay.flex,
170 |   flexDirection: FlexDirection.row,
171 |   flexWrap: FlexWrap.nowrap,
172 |   justifyContent: JustifyContent['flex-start'],
173 |   alignItems: AlignItems.stretch,
174 |   alignContent: AlignContent['flex-start'],
175 |   childMinHeight: [25, 100, 75],
176 |   order: [0, 0, 0],
177 |   flexGrow: [0, 0, 0],
178 |   flexShrink: [1, 1, 1],
179 |   flexBasis: ['auto', 'auto', 'auto'],
180 |   alignSelf: [AlignSelf.auto, AlignSelf.auto, AlignSelf.auto],
181 | };
182 | 
183 | flex.argTypes = {
184 |   flexDisplay: {
185 |     name: 'Display',
186 |     control: { type: 'select', options: FlexDisplay },
187 |   },
188 |   flexDirection: {
189 |     name: 'Direction',
190 |     control: { type: 'select', options: FlexDirection },
191 |   },
192 |   flexWrap: {
193 |     name: 'Wrap',
194 |     control: { type: 'select', options: FlexWrap },
195 |   },
196 |   justifyContent: {
197 |     name: 'Justify Content',
198 |     control: { type: 'select', options: JustifyContent },
199 |   },
200 |   alignItems: {
201 |     name: 'Align Items',
202 |     control: { type: 'select', options: AlignItems },
203 |   },
204 |   alignContent: {
205 |     name: 'Align Content',
206 |     control: { type: 'select', options: AlignContent },
207 |   },
208 |   childMinHeight: {
209 |     name: 'Child Minimum Height',
210 |     control: { type: 'array', separator: ',' },
211 |   },
212 |   order: {
213 |     name: 'Child Order',
214 |     control: { type: 'array', separator: ',' },
215 |   },
216 |   flexGrow: {
217 |     name: 'Child Flex Grow',
218 |     control: { type: 'array', separator: ',' },
219 |   },
220 |   flexShrink: {
221 |     name: 'Child Flex Shrink',
222 |     control: { type: 'array', separator: ',' },
223 |   },
224 |   flexBasis: {
225 |     name: 'Child Flex Basis',
226 |     control: { type: 'array', separator: ',' },
227 |   },
228 |   alignSelf: {
229 |     name: 'Child Align Self',
230 |     control: { type: 'array', separator: ',' },
231 |   },
232 | };
233 | 
234 | export const flexTabs: m.ClosureComponent = () => ({
235 |   view: () => (
236 |     
237 |
238 |
Content1
239 |
Content2
240 |
241 |
242 |
Content3
243 |
Content4
244 |
245 |
246 |
Content3
247 |
Content4
248 |
249 |
250 | ), 251 | }); 252 | -------------------------------------------------------------------------------- /.storybook/static/mockServiceWorker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Mock Service Worker. 3 | * @see https://github.com/mswjs/msw 4 | * - Please do NOT modify this file. 5 | * - Please do NOT serve this file on production. 6 | */ 7 | /* eslint-disable */ 8 | /* tslint:disable */ 9 | 10 | const INTEGRITY_CHECKSUM = '82ef9b96d8393b6da34527d1d6e19187' 11 | const bypassHeaderName = 'x-msw-bypass' 12 | const activeClientIds = new Set() 13 | 14 | self.addEventListener('install', function () { 15 | return self.skipWaiting() 16 | }) 17 | 18 | self.addEventListener('activate', async function (event) { 19 | return self.clients.claim() 20 | }) 21 | 22 | self.addEventListener('message', async function (event) { 23 | const clientId = event.source.id 24 | 25 | if (!clientId || !self.clients) { 26 | return 27 | } 28 | 29 | const client = await self.clients.get(clientId) 30 | 31 | if (!client) { 32 | return 33 | } 34 | 35 | const allClients = await self.clients.matchAll() 36 | 37 | switch (event.data) { 38 | case 'KEEPALIVE_REQUEST': { 39 | sendToClient(client, { 40 | type: 'KEEPALIVE_RESPONSE', 41 | }) 42 | break 43 | } 44 | 45 | case 'INTEGRITY_CHECK_REQUEST': { 46 | sendToClient(client, { 47 | type: 'INTEGRITY_CHECK_RESPONSE', 48 | payload: INTEGRITY_CHECKSUM, 49 | }) 50 | break 51 | } 52 | 53 | case 'MOCK_ACTIVATE': { 54 | activeClientIds.add(clientId) 55 | 56 | sendToClient(client, { 57 | type: 'MOCKING_ENABLED', 58 | payload: true, 59 | }) 60 | break 61 | } 62 | 63 | case 'MOCK_DEACTIVATE': { 64 | activeClientIds.delete(clientId) 65 | break 66 | } 67 | 68 | case 'CLIENT_CLOSED': { 69 | activeClientIds.delete(clientId) 70 | 71 | const remainingClients = allClients.filter((client) => { 72 | return client.id !== clientId 73 | }) 74 | 75 | // Unregister itself when there are no more clients 76 | if (remainingClients.length === 0) { 77 | self.registration.unregister() 78 | } 79 | 80 | break 81 | } 82 | } 83 | }) 84 | 85 | // Resolve the "master" client for the given event. 86 | // Client that issues a request doesn't necessarily equal the client 87 | // that registered the worker. It's with the latter the worker should 88 | // communicate with during the response resolving phase. 89 | async function resolveMasterClient(event) { 90 | const client = await self.clients.get(event.clientId) 91 | 92 | if (client.frameType === 'top-level') { 93 | return client 94 | } 95 | 96 | const allClients = await self.clients.matchAll() 97 | 98 | return allClients 99 | .filter((client) => { 100 | // Get only those clients that are currently visible. 101 | return client.visibilityState === 'visible' 102 | }) 103 | .find((client) => { 104 | // Find the client ID that's recorded in the 105 | // set of clients that have registered the worker. 106 | return activeClientIds.has(client.id) 107 | }) 108 | } 109 | 110 | async function handleRequest(event, requestId) { 111 | const client = await resolveMasterClient(event) 112 | const response = await getResponse(event, client, requestId) 113 | 114 | // Send back the response clone for the "response:*" life-cycle events. 115 | // Ensure MSW is active and ready to handle the message, otherwise 116 | // this message will pend indefinitely. 117 | if (client && activeClientIds.has(client.id)) { 118 | ;(async function () { 119 | const clonedResponse = response.clone() 120 | sendToClient(client, { 121 | type: 'RESPONSE', 122 | payload: { 123 | requestId, 124 | type: clonedResponse.type, 125 | ok: clonedResponse.ok, 126 | status: clonedResponse.status, 127 | statusText: clonedResponse.statusText, 128 | body: 129 | clonedResponse.body === null ? null : await clonedResponse.text(), 130 | headers: serializeHeaders(clonedResponse.headers), 131 | redirected: clonedResponse.redirected, 132 | }, 133 | }) 134 | })() 135 | } 136 | 137 | return response 138 | } 139 | 140 | async function getResponse(event, client, requestId) { 141 | const { request } = event 142 | const requestClone = request.clone() 143 | const getOriginalResponse = () => fetch(requestClone) 144 | 145 | // Bypass mocking when the request client is not active. 146 | if (!client) { 147 | return getOriginalResponse() 148 | } 149 | 150 | // Bypass initial page load requests (i.e. static assets). 151 | // The absence of the immediate/parent client in the map of the active clients 152 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet 153 | // and is not ready to handle requests. 154 | if (!activeClientIds.has(client.id)) { 155 | return await getOriginalResponse() 156 | } 157 | 158 | // Bypass requests with the explicit bypass header 159 | if (requestClone.headers.get(bypassHeaderName) === 'true') { 160 | const cleanRequestHeaders = serializeHeaders(requestClone.headers) 161 | 162 | // Remove the bypass header to comply with the CORS preflight check. 163 | delete cleanRequestHeaders[bypassHeaderName] 164 | 165 | const originalRequest = new Request(requestClone, { 166 | headers: new Headers(cleanRequestHeaders), 167 | }) 168 | 169 | return fetch(originalRequest) 170 | } 171 | 172 | // Send the request to the client-side MSW. 173 | const reqHeaders = serializeHeaders(request.headers) 174 | const body = await request.text() 175 | 176 | const clientMessage = await sendToClient(client, { 177 | type: 'REQUEST', 178 | payload: { 179 | id: requestId, 180 | url: request.url, 181 | method: request.method, 182 | headers: reqHeaders, 183 | cache: request.cache, 184 | mode: request.mode, 185 | credentials: request.credentials, 186 | destination: request.destination, 187 | integrity: request.integrity, 188 | redirect: request.redirect, 189 | referrer: request.referrer, 190 | referrerPolicy: request.referrerPolicy, 191 | body, 192 | bodyUsed: request.bodyUsed, 193 | keepalive: request.keepalive, 194 | }, 195 | }) 196 | 197 | switch (clientMessage.type) { 198 | case 'MOCK_SUCCESS': { 199 | return delayPromise( 200 | () => respondWithMock(clientMessage), 201 | clientMessage.payload.delay, 202 | ) 203 | } 204 | 205 | case 'MOCK_NOT_FOUND': { 206 | return getOriginalResponse() 207 | } 208 | 209 | case 'NETWORK_ERROR': { 210 | const { name, message } = clientMessage.payload 211 | const networkError = new Error(message) 212 | networkError.name = name 213 | 214 | // Rejecting a request Promise emulates a network error. 215 | throw networkError 216 | } 217 | 218 | case 'INTERNAL_ERROR': { 219 | const parsedBody = JSON.parse(clientMessage.payload.body) 220 | 221 | console.error( 222 | `\ 223 | [MSW] Request handler function for "%s %s" has thrown the following exception: 224 | 225 | ${parsedBody.errorType}: ${parsedBody.message} 226 | (see more detailed error stack trace in the mocked response body) 227 | 228 | This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error. 229 | If you wish to mock an error response, please refer to this guide: https://mswjs.io/docs/recipes/mocking-error-responses\ 230 | `, 231 | request.method, 232 | request.url, 233 | ) 234 | 235 | return respondWithMock(clientMessage) 236 | } 237 | } 238 | 239 | return getOriginalResponse() 240 | } 241 | 242 | self.addEventListener('fetch', function (event) { 243 | const { request } = event 244 | 245 | // Bypass navigation requests. 246 | if (request.mode === 'navigate') { 247 | return 248 | } 249 | 250 | // Opening the DevTools triggers the "only-if-cached" request 251 | // that cannot be handled by the worker. Bypass such requests. 252 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { 253 | return 254 | } 255 | 256 | // Bypass all requests when there are no active clients. 257 | // Prevents the self-unregistered worked from handling requests 258 | // after it's been deleted (still remains active until the next reload). 259 | if (activeClientIds.size === 0) { 260 | return 261 | } 262 | 263 | const requestId = uuidv4() 264 | 265 | return event.respondWith( 266 | handleRequest(event, requestId).catch((error) => { 267 | console.error( 268 | '[MSW] Failed to mock a "%s" request to "%s": %s', 269 | request.method, 270 | request.url, 271 | error, 272 | ) 273 | }), 274 | ) 275 | }) 276 | 277 | function serializeHeaders(headers) { 278 | const reqHeaders = {} 279 | headers.forEach((value, name) => { 280 | reqHeaders[name] = reqHeaders[name] 281 | ? [].concat(reqHeaders[name]).concat(value) 282 | : value 283 | }) 284 | return reqHeaders 285 | } 286 | 287 | function sendToClient(client, message) { 288 | return new Promise((resolve, reject) => { 289 | const channel = new MessageChannel() 290 | 291 | channel.port1.onmessage = (event) => { 292 | if (event.data && event.data.error) { 293 | return reject(event.data.error) 294 | } 295 | 296 | resolve(event.data) 297 | } 298 | 299 | client.postMessage(JSON.stringify(message), [channel.port2]) 300 | }) 301 | } 302 | 303 | function delayPromise(cb, duration) { 304 | return new Promise((resolve) => { 305 | setTimeout(() => resolve(cb()), duration) 306 | }) 307 | } 308 | 309 | function respondWithMock(clientMessage) { 310 | return new Response(clientMessage.payload.body, { 311 | ...clientMessage.payload, 312 | headers: clientMessage.payload.headers, 313 | }) 314 | } 315 | 316 | function uuidv4() { 317 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 318 | const r = (Math.random() * 16) | 0 319 | const v = c == 'x' ? r : (r & 0x3) | 0x8 320 | return v.toString(16) 321 | }) 322 | } 323 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mithril-template ![Logo](https://user-images.githubusercontent.com/2394539/91363092-d0751c00-e7c9-11ea-87da-e34cb58af223.png) 2 | 3 | **This repository is a template so you can fork it to create your own applications from it.** 4 | 5 | This is a sample notepad application that uses Mithril with TypeScript. It does also support JSX (.jsx or .tsx file extensions) if you want to use it. This project is designed to show how modern, front-end development tools integrate. It takes a while to piece together your own tools for linting, building, testing, etc. so you can reference this to see how to get all these different tools set up and integrated. 6 | 7 | You don't need a back-end to test this application because [Mock Service Worker (MSW)](https://mswjs.io/) intercepts requests and returns data. 8 | 9 | This projects uses/supports: 10 | 11 | - [Babel](https://babeljs.io/) 12 | - [Bulma](https://bulma.io/) 13 | - [Cypress](https://www.cypress.io/) 14 | - [ESLint](https://eslint.org/) 15 | - [JSX](https://www.typescriptlang.org/docs/handbook/jsx.html) 16 | - [Mithril](https://mithril.js.org/) 17 | - [Mock Service Worker (MSW)](https://mswjs.io/) 18 | - [npm](https://www.npmjs.com/) 19 | - [Sass](https://sass-lang.com/libsass) 20 | - [Storybook](https://storybook.js.org/) 21 | - [Prettier](https://prettier.io/) 22 | - [TypeScript](https://www.typescriptlang.org/) 23 | - [Visual Studio Code (VS Code)](https://code.visualstudio.com/) 24 | - [webpack](https://webpack.js.org/) 25 | - [webpack DevServer](https://webpack.js.org/configuration/dev-server/) 26 | 27 | # Quick Start 28 | 29 | Below are the instructions to test the application quickly. 30 | 31 | ```bash 32 | # Clone the repo. 33 | git clone git@github.com:josephspurrier/mithril-template.git 34 | 35 | # Change to the directory. 36 | cd mithril-template 37 | 38 | # Install the dependencies. 39 | npm install 40 | 41 | # Start the web server. Your browser will open to: http://locahost:8080. 42 | npm start 43 | 44 | # You don't need to use the Register page before logging in. To login, use: 45 | # Username: jsmith@example.com 46 | # Password: password 47 | 48 | # Run Cypress tests in the CLI. 49 | npm test 50 | 51 | # Run Cypress tests in the UI. 52 | npm run cypress 53 | 54 | # Start the Storybook UI. 55 | npm run storybook 56 | 57 | # Lint the js/jsx/ts/tsx code using ESLint/Prettier. 58 | npm run lint 59 | 60 | # Fix the js/jsx/ts/tsx code using ESLint/Prettier. 61 | npm run lint-fix 62 | 63 | # Lint the css/scss code using stylelint. 64 | npm run stylelint 65 | 66 | # Fix the css/scss code using stylelint. 67 | npm run stylelint-fix 68 | 69 | # Generate a new mockServiceWorker.js file when you upgrade msw. 70 | npx msw init .storybook/static/ 71 | ``` 72 | 73 | # Features 74 | 75 | ## Babel 76 | 77 | Babel will transform your code using the [@babel/preset-env](https://babeljs.io/docs/en/babel-preset-env) and will also convert your JSX through webpack (webpack.config.js and .babelrc). 78 | 79 | ## Bulma 80 | 81 | Bulma is a front-end framework that provides you with styled components and CSS helpers out of the box. This template also uses [Font Awesome](https://fontawesome.com/). 82 | 83 | ## SASS 84 | 85 | [SASS](https://sass-lang.com/documentation/syntax) with the extension (.scss) is supported for both globally scoped (affects entire application) and locally scoped (namespaced for just the web component). The plugin, [MiniCssExtractPlugin](https://webpack.js.org/plugins/mini-css-extract-plugin/), is used with [css-loader](https://webpack.js.org/loaders/css-loader/) and [sass-loader](https://webpack.js.org/loaders/sass-loader/). The css-loader is configured to use [CSS Modules](https://github.com/css-modules/css-modules). 86 | 87 | Any `.scss` files will be treated as global and applied to the entire application like standard CSS. You can reference it like this from a web component: `import '@/file.scss';`. It's recommended to use the `:local(.className)` designation on top level classes and then nest all the other CSS so your styles are locally scoped to your web component. You can then reference it like this from a web component: `import style from '@/layout/side-menu/side-menu.scss';`. Any class names wrapped in `:local()` will be converted to this format: `[name]__[local]__[hash:base64:5]`. You can see how they are referenced in [side-menu.ts](/src/layout/side-menu/side-menu.ts). You must reference the `:local` class names in your TypeScript files using an import or the styles won't apply properly. You can see how this is done here: [side-menu.scss](src/layout/side-menu/side-menu.scss). It's recommended to use camelCase for the local class names because dashes make it a little more difficult to reference. You can read more about global vs local scope [here](https://webpack.js.org/loaders/css-loader/#scope). If you have any trouble using it, you can easily view the CSS output to see if names are namespaced or not. 88 | 89 | To allow referencing CSS class names in TypeScript, there is a declaration.d.ts file that allows any class name to be used. It's in the `include` section of tsconfig.json file. 90 | 91 | ## Cypress 92 | 93 | Cypress provides an easy-to-use end to end testing framework that launches a browser to do testing on your application. It can run from the CLI, or you can open up the UI and watch the tests live. It makes it really easy to debug tests that are not working properly. The config is in the cypress.json file, the support files are in the .cypress/support folder, and the main spec is here: src/e2e.spec.ts. 94 | 95 | ## ESLint, stylelint, Prettier 96 | 97 | After testing a few combinations of tools, we decided to use the ESLint and stylelint VSCode extensions without using the Prettier VSCode extension. The interesting part is ESLint will still use Prettier to do auto-formatting which is why it's included in the package.json file. ESLint and Prettier will work together to autoformat your code on save and suggest where you can improve your code (.estlintignore, .estlintrc.json, .prettierrc). You will get a notification in VSCode when you first open the project asking if you want to allow the ESLint application to run from the node_modules folder - you should allow it so it can run properly. Stylelint is used for linting and auto-formatting any CSS and SCSS files. 98 | 99 | ## Favicon 100 | 101 | The favicon was generated from the gracious [favicon.io](https://favicon.io/favicon-generator/?t=m&ff=Leckerli+One&fs=110&fc=%23FFF&b=rounded&bc=%2300d1b2). 102 | 103 | ## Mithril 104 | 105 | Mithril is a fast and small framework that is easy to learn and provides routing and XHR utilities out of the box. You can use either HyperScript or JSX in this template. 106 | 107 | ## Mock Service Worker 108 | 109 | This service worker will intercept your API calls so you don't need a back-end while developing. The mockServiceWorker.js file is copied to the root of your application by the webpack.config.js file. The src/helper/mock/browser.ts file is called by the /src/index.ts file when the application starts to turn on the service worker if the `__MOCK_SERVER__` variable is set to `true` in the webpack.config.js file. The /src/helper/mock/handler.ts file has all the API calls mocked when the application is running or being tested by Cypress. The Storybook files themselves each set their own responses if needed. 110 | 111 | ## Storybook 112 | 113 | Storybook is a great way to build and test UI components in isolation: 114 | 115 | - **.storybook/main.js** - references setting from your main webpack.config.js file, sets the type of file that is considered stories (any file that ends in .stories.js - it can also end in ts, jsx, or tsx extension). 116 | - **.storybook/preview.js** - enables the console addon, includes the fontawesome icons, and includes the index.scss so you don't have to include it in every file. It also turns on the Mock Service Worker so all API calls can be intercepted and then returned data. 117 | 118 | There are a lot of storybooks already included so take a look at how you can use the Controls addon with Mithril in the new Storybook v6 release. 119 | 120 | ## TypeScript 121 | 122 | Many JavaScript projects now use TypeScript because it reduces code that you would have to write in JavaScript to validate the data you're passing in is of a certain type. The tsconfig.json and jsconfig.json tell your IDE and build tools which options you have set on your project. This project uses [ts-loader](https://github.com/TypeStrong/ts-loader) for webpack. 123 | 124 | ## Visual Studio Code 125 | 126 | If you open this project in Visual Studio Code, you will get: 127 | 128 | - extension recommendations for ESLint (.vscode/extensions.json) 129 | - settings configured for ESLint linting and prettier auto-corrections (.vscode/settings.json) 130 | - TypeScript code snippets for Mithril and arrow functions (.vscode/typescript.code-snippets) 131 | 132 | These code snippets are included - just start typing and they should be in the auto-complete menu. A few of them support tabbing through the various fields: 133 | 134 | - **mithril-closure** - Creates a closure component in Mithril. 135 | - **mithril-storybook** - Creates a storybook component in Mithril. 136 | - **arrow** - Creates an arrow function. 137 | - **onclick** - Creates an onclick with an arrow function. 138 | - **log** - Creates a console.log() statement. 139 | 140 | ## webpack 141 | 142 | When you run `npm start`, webpack will provide linting via ESLint and live reloading (webpack.config.js). To compile faster, this template uses the [fork-ts-checker-webpack-plugin](https://github.com/TypeStrong/fork-ts-checker-webpack-plugin) that runs the TypeScript type checker on a separate process. ESLint is also run on a separate process. You'll notice the `transpileOnly: true` option is set on the `ts-loader` in the webpack.config.js and the `ForkTsCheckerWebpackPlugin` is a plugin that handles the type checking and ESLint. 143 | 144 | # Screenshots 145 | 146 | Login screen. 147 | 148 | ![Login](https://user-images.githubusercontent.com/2394539/91362446-817ab700-e7c8-11ea-9078-c54879dcfe00.png) 149 | 150 | Welcome screen. 151 | 152 | ![Welcome](https://user-images.githubusercontent.com/2394539/91362572-c69ee900-e7c8-11ea-80fc-2b8e993b449f.png) 153 | 154 | Notepad screen. 155 | 156 | ![Notepad](https://user-images.githubusercontent.com/2394539/91362680-fea62c00-e7c8-11ea-9697-b291eb9c478e.png) --------------------------------------------------------------------------------