diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..fd54d60 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,7 @@ +*.json +*.yml +*.sqlite3 +*.md +**/public +LICENSE +Dockerfile \ No newline at end of file diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 0000000..9911bd8 --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,15 @@ +env: + browser: true + es2021: true + jest: true +extends: + - plugin:react/recommended + - airbnb +overrides: [] +parserOptions: + ecmaVersion: latest + sourceType: module +plugins: + - react + - jest +rules: { 'indent': ['error', 4, { 'SwitchCase': 1 }], 'comma-dangle': 'off' } diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index aff98fb..0b596b3 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -10,7 +10,7 @@ on: workflow_dispatch: jobs: - test: + test-build: environment: test runs-on: ubuntu-latest @@ -28,3 +28,17 @@ jobs: run: npm ci - name: Test build run: npm run test + run-eslint: + environment: test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js 16.x + uses: actions/setup-node@v2 + with: + node-version: 16.x + - name: Install dependencies + run: npm ci + - name: Test formmating + run: npm run eslint:all diff --git a/.gitignore b/.gitignore index 49a3626..efc721b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,9 @@ - -# Created by https://www.toptal.com/developers/gitignore/api/node,react,git,visualstudiocode -# Edit at https://www.toptal.com/developers/gitignore?templates=node,react,git,visualstudiocode - -**/dist.tar.gz - -### Express ### -**/public/ - -### TODO ### -TODO - -### Sqlite ### +server/**/public *.sqlite3 +*.sqlite3-journal + +# Created by https://www.toptal.com/developers/gitignore/api/node,react,visualstudiocode,git +# Edit at https://www.toptal.com/developers/gitignore?templates=node,react,visualstudiocode,git ### Git ### # Created by git for backups. To disable backups in Git: @@ -200,6 +192,4 @@ sketch .history .ionide -# Support for Project snippet scope - -# End of https://www.toptal.com/developers/gitignore/api/node,react,git,visualstudiocode +# End of https://www.toptal.com/developers/gitignore/api/node,react,visualstudiocode,git diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/client/.storybook/main.js b/client/.storybook/main.js new file mode 100644 index 0000000..1839745 --- /dev/null +++ b/client/.storybook/main.js @@ -0,0 +1,13 @@ +module.exports = { + stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: [ + '@storybook/addon-links', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@storybook/preset-create-react-app', + ], + framework: '@storybook/react', + core: { + builder: '@storybook/builder-webpack5', + }, +}; diff --git a/client/.storybook/preview.js b/client/.storybook/preview.js new file mode 100644 index 0000000..056c20e --- /dev/null +++ b/client/.storybook/preview.js @@ -0,0 +1,100 @@ +import { useMemo } from 'react'; +import { MemoryRouter } from 'react-router-dom'; + +import { CssBaseline } from '@mui/material'; +import { ThemeProvider } from '@mui/material/styles'; +import '@storybook/addon-console'; +import { initialize, mswDecorator } from 'msw-storybook-addon'; +import { cookieDecorator } from 'storybook-addon-cookie'; + +import { darkTheme, lightTheme } from '../src/themes'; + +export const parameters = { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + options: { + storySort: { + method: 'alphabetical', + }, + }, + layout: 'fullscreen', + viewport: { + viewports: { + laptop: { + name: 'Laptop', + styles: { + width: '1024px', + height: '576px', + }, + type: 'desktop', + }, + tablet: { + name: 'Tablet', + styles: { + width: '768px', + height: '1024px', + }, + type: 'Tablet', + }, + mobile: { + name: 'Mobile', + styles: { + width: '360px', + height: '740px', + }, + type: 'mobile', + }, + }, + }, +}; + +export const globalTypes = { + theme: { + name: 'Theme', + title: 'Theme', + defaultValue: 'light', + toolbar: { + icon: 'paintbrush', + dynamicTitle: true, + items: [ + { value: 'light', title: 'Light Mode', left: '🌞' }, + { value: 'dark', title: 'Dark Mode', left: '🌙' }, + ], + }, + }, +}; + +const THEMES = { + light: lightTheme, + dark: darkTheme, +}; + +export const withMuiTheme = (Story, context) => { + const { theme: themeKey } = context.globals; + + const theme = useMemo(() => THEMES[themeKey] || THEMES['light'], [themeKey]); + + return ( + + + + + ); +}; + +export const withBrowserRouter = (Story) => { + return ( + + + + ); +}; + +initialize(); + +export const decorators = [mswDecorator, cookieDecorator, withBrowserRouter, withMuiTheme]; diff --git a/client/package.json b/client/package.json index 170ebb2..412168f 100644 --- a/client/package.json +++ b/client/package.json @@ -26,12 +26,24 @@ "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", - "eject": "react-scripts eject" + "eject": "react-scripts eject", + "storybook": "start-storybook -p 6006 -s public", + "build-storybook": "build-storybook -s public" }, "eslintConfig": { "extends": [ "react-app", "react-app/jest" + ], + "overrides": [ + { + "files": [ + "**/*.stories.*" + ], + "rules": { + "import/no-anonymous-default-export": "off" + } + } ] }, "browserslist": { @@ -45,5 +57,27 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "@storybook/addon-actions": "^6.5.15", + "@storybook/addon-console": "^1.2.3", + "@storybook/addon-essentials": "^6.5.15", + "@storybook/addon-interactions": "^6.5.15", + "@storybook/addon-links": "^6.5.15", + "@storybook/builder-webpack5": "^6.5.15", + "@storybook/manager-webpack5": "^6.5.15", + "@storybook/node-logger": "^6.5.15", + "@storybook/preset-create-react-app": "^4.1.2", + "@storybook/react": "^6.5.15", + "@storybook/testing-library": "^0.0.13", + "babel-plugin-named-exports-order": "^0.0.2", + "msw": "^0.49.2", + "msw-storybook-addon": "^1.6.3", + "prop-types": "^15.8.1", + "storybook-addon-cookie": "^1.0.6", + "webpack": "^5.75.0" + }, + "msw": { + "workerDirectory": "public" } } diff --git a/client/public/mockServiceWorker.js b/client/public/mockServiceWorker.js new file mode 100644 index 0000000..70f0a2b --- /dev/null +++ b/client/public/mockServiceWorker.js @@ -0,0 +1,303 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (0.49.2). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70' +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' + + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return + } + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2) + + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}`, + ) + }), + ) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const clonedResponse = response.clone() + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: Object.fromEntries(clonedResponse.headers.entries()), + redirected: clonedResponse.redirected, + }, + }) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + const clonedRequest = request.clone() + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const headers = Object.fromEntries(clonedRequest.headers.entries()) + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers['x-msw-bypass'] + + return fetch(clonedRequest, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get('x-msw-bypass') === 'true') { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.text(), + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'MOCK_NOT_FOUND': { + return passthrough() + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.data + const networkError = new Error(message) + networkError.name = name + + // Rejecting a "respondWith" promise emulates a network error. + throw networkError + } + } + + return passthrough() +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [channel.port2]) + }) +} + +function sleep(timeMs) { + return new Promise((resolve) => { + setTimeout(resolve, timeMs) + }) +} + +async function respondWithMock(response) { + await sleep(response.delay) + return new Response(response.body, response) +} diff --git a/client/src/components/EditUserForm.jsx b/client/src/components/EditUserForm.jsx index 9881b4a..71a10de 100644 --- a/client/src/components/EditUserForm.jsx +++ b/client/src/components/EditUserForm.jsx @@ -1,16 +1,14 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { Box, Typography } from '@mui/material'; import { Form } from '.'; -import { RecordMp3 } from '../controllers'; import { FormSubmit, FormTextField } from './StyledElements'; function EditUserForm({ form, error, handleSubmit, handleChange }) { const linkStyle = { textDecoration: 'none', color: 'inherit' }; return ( -
+ - {error && ( - - - {error} - - - )} Update Details @@ -56,7 +47,6 @@ function EditUserForm({ form, error, handleSubmit, handleChange }) { - ); } diff --git a/client/src/components/MobileNavbar.jsx b/client/src/components/MobileNavbar.jsx index ba230d0..4645877 100644 --- a/client/src/components/MobileNavbar.jsx +++ b/client/src/components/MobileNavbar.jsx @@ -3,8 +3,6 @@ import DarkModeIcon from '@mui/icons-material/DarkMode'; import LightModeIcon from '@mui/icons-material/LightMode'; import LoginIcon from '@mui/icons-material/Login'; import LogoutIcon from '@mui/icons-material/Logout'; -import MailIcon from '@mui/icons-material/Mail'; -import SearchIcon from '@mui/icons-material/Search'; import MenuIcon from '@mui/icons-material/Menu'; import { AppBar, @@ -25,7 +23,7 @@ import { Link } from 'react-router-dom'; export default function MobileNavbar({ logout, userData, open, setOpen, mode, toggleMode }) { function ControlButtons() { - return userData.username ? ( + return userData?.email ? ( diff --git a/client/src/components/Navbar.jsx b/client/src/components/Navbar.jsx index c201808..3c1713f 100644 --- a/client/src/components/Navbar.jsx +++ b/client/src/components/Navbar.jsx @@ -6,7 +6,7 @@ import { Link } from 'react-router-dom'; export default function Navbar({ logout, userData, mode, toggleMode }) { function NavbarControls() { - return userData.email ? ( + return userData?.email ? ( <>