=> {
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 
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 | 
149 |
150 | Welcome screen.
151 |
152 | 
153 |
154 | Notepad screen.
155 |
156 | 
--------------------------------------------------------------------------------