├── .nvmrc ├── .npmrc ├── .browserslistrc ├── .env.local ├── __mocks__ ├── styleMock.js ├── fetch.js └── fileMock.js ├── src ├── styles │ ├── vendor.css │ ├── themes │ │ ├── dark.css │ │ └── default.css │ ├── breakpoints.ts │ └── global.css ├── components │ ├── ui-kit │ │ ├── Loading │ │ │ ├── index.ts │ │ │ └── Loading.tsx │ │ ├── NavBar │ │ │ ├── index.ts │ │ │ ├── NavBar.css │ │ │ ├── NavBar.stories.tsx │ │ │ ├── NavBar.spec.tsx │ │ │ └── NavBar.tsx │ │ ├── Input │ │ │ ├── index.ts │ │ │ ├── Input.css │ │ │ ├── Input.stories.tsx │ │ │ └── Input.tsx │ │ ├── Button │ │ │ ├── index.ts │ │ │ ├── Button.spec.tsx │ │ │ ├── Button.stories.tsx │ │ │ ├── Button.css │ │ │ └── Button.tsx │ │ ├── RadioGroup │ │ │ ├── index.ts │ │ │ ├── RadioGroup.css │ │ │ ├── RadioGroup.spec.tsx │ │ │ ├── RadioGroup.stories.tsx │ │ │ └── RadioGroup.tsx │ │ └── Typography │ │ │ ├── index.ts │ │ │ ├── Typography.css │ │ │ ├── Typography.stories.tsx │ │ │ └── Typography.tsx │ └── TimeDisplay │ │ ├── index.ts │ │ ├── TimeDisplay.container.tsx │ │ ├── TimeDisplay.tsx │ │ └── TimeDisplay.stories.tsx ├── config │ ├── env.ts │ ├── routes.ts │ ├── themes.ts │ └── locales.ts ├── pages │ ├── NotFound │ │ ├── index.ts │ │ ├── 404-dog.jpg │ │ ├── NotFound.css │ │ ├── NotFound.stories.tsx │ │ └── NotFound.tsx │ ├── Chat │ │ ├── index.ts │ │ ├── components │ │ │ ├── ChatInput │ │ │ │ ├── index.ts │ │ │ │ ├── ChatInput.css │ │ │ │ ├── ChatInput.container.tsx │ │ │ │ ├── ChatInput.stories.tsx │ │ │ │ ├── ChatInput.spec.tsx │ │ │ │ └── ChatInput.tsx │ │ │ └── ChatMessage │ │ │ │ ├── index.ts │ │ │ │ ├── ChatMessage.css │ │ │ │ ├── ChatMessage.stories.tsx │ │ │ │ ├── ChatMessage.tsx │ │ │ │ ├── ChatMessage.container.tsx │ │ │ │ └── ChatMessage.container.spec.tsx │ │ ├── ChatPage.css │ │ ├── ChatPage.stories.tsx │ │ ├── ChatPage.container.spec.tsx │ │ ├── ChatPage.container.tsx │ │ └── ChatPage.tsx │ ├── Settings │ │ ├── index.ts │ │ ├── SettingsPage.css │ │ ├── SettingsPage.stories.tsx │ │ ├── SettingsPage.container.tsx │ │ ├── SettingsPage.container.spec.tsx │ │ └── SettingsPage.tsx │ └── index.tsx ├── wrappers │ ├── AppWrapper │ │ ├── index.ts │ │ └── AppWrapper.tsx │ ├── StorybookSharedWrapper │ │ ├── index.ts │ │ └── StorybookSharedWrapper.tsx │ └── BodyWrapper │ │ ├── BodyWrapper.css │ │ └── BodyWrapper.tsx ├── utils │ ├── gstate │ │ ├── index.ts │ │ ├── gstate.ts │ │ └── gstate.spec.ts │ ├── locales.spec.ts │ └── locales.ts ├── contexts │ ├── ChatContext │ │ ├── index.ts │ │ ├── ChatContext.spec.tsx │ │ └── ChatContext.tsx │ ├── SettingsContext │ │ ├── index.ts │ │ ├── SettingsContext.tsx │ │ └── SettingContext.spec.tsx │ └── createContextHOC.tsx ├── services │ └── ChatService │ │ ├── __mocks__ │ │ └── index.ts │ │ ├── __fixtures__ │ │ └── index.ts │ │ ├── index.ts │ │ ├── README.md │ │ ├── ChatAdapter.ts │ │ ├── ChatService.ts │ │ ├── ChatAdapter.spec.ts │ │ └── ChatService.spec.ts ├── index.tsx ├── index.ejs └── i18n │ ├── template.pot │ ├── he.po │ ├── en-GB.po │ └── ru.po ├── .env ├── commitlint.config.js ├── .gitignore ├── public ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png └── site.webmanifest ├── .prettierrc ├── .huskyrc ├── @types ├── css │ └── index.d.ts ├── images │ └── index.d.ts └── fonts │ └── index.d.ts ├── config └── webpack │ ├── config.webpack.production.js │ ├── config.webpack.development.js │ ├── config.webpack.storybook.js │ └── config.webpack.common.js ├── Dockerfile.development ├── .stylelintrc.js ├── netlify.toml ├── .vscode ├── extensions.json ├── tasks.json ├── settings.json ├── launch.json └── components.code-snippets ├── docker-compose.yml ├── .lintstagedrc ├── postcss.config.js ├── jest.config.js ├── .github └── workflows │ ├── linters.yml │ └── tests.yml ├── tsconfig.json ├── .storybook ├── preview.js ├── main.js └── contexts.js ├── __utils__ ├── render.tsx └── renderWithRouter.tsx ├── .eslintrc.js └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 12.18 -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | defaults 2 | ie 11 -------------------------------------------------------------------------------- /.env.local: -------------------------------------------------------------------------------- 1 | CHAT_BACKEND_URL=http://localhost:8090 -------------------------------------------------------------------------------- /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /src/styles/vendor.css: -------------------------------------------------------------------------------- 1 | @import "sanitize"; 2 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | CHAT_BACKEND_URL=https://sample-chat.api.goooseman.ru -------------------------------------------------------------------------------- /__mocks__/fetch.js: -------------------------------------------------------------------------------- 1 | window.fetch = require("node-fetch"); 2 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = "test-file-stub"; 2 | -------------------------------------------------------------------------------- /src/components/ui-kit/Loading/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Loading"; 2 | -------------------------------------------------------------------------------- /src/components/ui-kit/NavBar/index.ts: -------------------------------------------------------------------------------- 1 | export { NavBar, NavBarItem } from "./NavBar"; 2 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ["@commitlint/config-conventional"] }; 2 | -------------------------------------------------------------------------------- /src/config/env.ts: -------------------------------------------------------------------------------- 1 | export const CHAT_BACKEND_URL = process.env.CHAT_BACKEND_URL || ""; 2 | -------------------------------------------------------------------------------- /src/components/ui-kit/Input/index.ts: -------------------------------------------------------------------------------- 1 | import Input from "./Input"; 2 | export default Input; 3 | -------------------------------------------------------------------------------- /src/pages/NotFound/index.ts: -------------------------------------------------------------------------------- 1 | import NotFound from "./NotFound"; 2 | export default NotFound; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | /storybook-static 4 | /src/i18n/translations.json 5 | /coverage -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomePC/react-chat-frontend/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/components/ui-kit/Button/index.ts: -------------------------------------------------------------------------------- 1 | import Button from "./Button"; 2 | export default Button; 3 | -------------------------------------------------------------------------------- /src/wrappers/AppWrapper/index.ts: -------------------------------------------------------------------------------- 1 | import AppWrapper from "./AppWrapper"; 2 | export default AppWrapper; 3 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomePC/react-chat-frontend/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomePC/react-chat-frontend/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /src/components/ui-kit/RadioGroup/index.ts: -------------------------------------------------------------------------------- 1 | import RadioGroup from "./RadioGroup"; 2 | export default RadioGroup; 3 | -------------------------------------------------------------------------------- /src/components/ui-kit/Typography/index.ts: -------------------------------------------------------------------------------- 1 | import Typography from "./Typography"; 2 | export default Typography; 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": false 6 | } 7 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomePC/react-chat-frontend/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /src/components/TimeDisplay/index.ts: -------------------------------------------------------------------------------- 1 | import TimeDisplay from "./TimeDisplay.container"; 2 | export default TimeDisplay; 3 | -------------------------------------------------------------------------------- /src/pages/Chat/index.ts: -------------------------------------------------------------------------------- 1 | import ChatPageContainer from "./ChatPage.container"; 2 | export default ChatPageContainer; 3 | -------------------------------------------------------------------------------- /src/pages/Chat/components/ChatInput/index.ts: -------------------------------------------------------------------------------- 1 | import ChatInput from "./ChatInput.container"; 2 | export default ChatInput; 3 | -------------------------------------------------------------------------------- /src/pages/NotFound/404-dog.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomePC/react-chat-frontend/HEAD/src/pages/NotFound/404-dog.jpg -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomePC/react-chat-frontend/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomePC/react-chat-frontend/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged", 4 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /@types/css/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.css" { 2 | const classes: { [key: string]: string }; 3 | export default classes; 4 | } 5 | -------------------------------------------------------------------------------- /src/pages/Settings/index.ts: -------------------------------------------------------------------------------- 1 | import SettingsPageContainer from "./SettingsPage.container"; 2 | export default SettingsPageContainer; 3 | -------------------------------------------------------------------------------- /src/utils/gstate/index.ts: -------------------------------------------------------------------------------- 1 | export { createMachine } from "./gstate"; 2 | export type { GMachine, MachineOptions, EmptyEvent } from "./gstate"; 3 | -------------------------------------------------------------------------------- /src/pages/Chat/components/ChatMessage/index.ts: -------------------------------------------------------------------------------- 1 | import ChatMessageContainer from "./ChatMessage.container"; 2 | export default ChatMessageContainer; 3 | -------------------------------------------------------------------------------- /src/wrappers/StorybookSharedWrapper/index.ts: -------------------------------------------------------------------------------- 1 | import StorybookSharedWrapper from "./StorybookSharedWrapper"; 2 | export default StorybookSharedWrapper; 3 | -------------------------------------------------------------------------------- /src/contexts/ChatContext/index.ts: -------------------------------------------------------------------------------- 1 | export { ChatContextProvider, withChat } from "./ChatContext"; 2 | export type { WithChat, SearchResult } from "./ChatContext"; 3 | -------------------------------------------------------------------------------- /src/config/routes.ts: -------------------------------------------------------------------------------- 1 | export const routes = { 2 | home: "/", 3 | settings: "/settings", 4 | } as const; 5 | 6 | export type Route = typeof routes[keyof typeof routes]; 7 | -------------------------------------------------------------------------------- /src/contexts/SettingsContext/index.ts: -------------------------------------------------------------------------------- 1 | export { SettingsContextProvider, withSettings } from "./SettingsContext"; 2 | export type { WithSettings } from "./SettingsContext"; 3 | -------------------------------------------------------------------------------- /src/components/ui-kit/RadioGroup/RadioGroup.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100%; 3 | } 4 | 5 | .radios-container { 6 | display: flex; 7 | justify-content: space-between; 8 | } 9 | -------------------------------------------------------------------------------- /src/config/themes.ts: -------------------------------------------------------------------------------- 1 | export const themes = ["default", "dark"] as const; 2 | 3 | export const defaultThemeName: ThemeName = "default"; 4 | 5 | export type ThemeName = typeof themes[number]; 6 | -------------------------------------------------------------------------------- /src/wrappers/BodyWrapper/BodyWrapper.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | width: 100%; 4 | flex-direction: column; 5 | align-self: stretch; 6 | } 7 | 8 | .container > main { 9 | flex-grow: 1; 10 | } 11 | -------------------------------------------------------------------------------- /config/webpack/config.webpack.production.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const commonConfig = require("./config.webpack.common"); 3 | 4 | module.exports = { 5 | ...commonConfig, 6 | mode: "production", 7 | }; 8 | -------------------------------------------------------------------------------- /Dockerfile.development: -------------------------------------------------------------------------------- 1 | FROM node:12.14 2 | 3 | RUN mkdir /app 4 | WORKDIR /app 5 | COPY package.json package-lock.json ./ 6 | RUN npm install --production 7 | COPY . ./ 8 | 9 | EXPOSE 8080 10 | 11 | CMD npm run start:local -- --host 0.0.0.0 -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | "stylelint-config-standard", 4 | "stylelint-config-css-modules", 5 | "stylelint-config-idiomatic-order", 6 | "stylelint-config-prettier", 7 | ], 8 | ignoreFiles: ["!**/*.css"], 9 | }; 10 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | # https://docs.netlify.com/configure-builds/file-based-configuration/#sample-file 2 | 3 | # The following redirect is intended for use with most SPAs that handle 4 | # routing internally. 5 | [[redirects]] 6 | from = "/*" 7 | to = "/index.html" 8 | status = 200 -------------------------------------------------------------------------------- /src/pages/Settings/SettingsPage.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | width: 100%; 4 | flex-direction: column; 5 | } 6 | 7 | .fields { 8 | max-width: 400px; 9 | flex-grow: 1; 10 | padding: 1rem 1.4rem; 11 | } 12 | 13 | .input { 14 | margin-bottom: 1rem; 15 | } 16 | -------------------------------------------------------------------------------- /config/webpack/config.webpack.development.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const commonConfig = require("./config.webpack.common"); 3 | 4 | module.exports = { 5 | ...commonConfig, 6 | mode: "development", 7 | devtool: "eval-cheap-module-source-map", 8 | }; 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "orta.vscode-jest", 6 | "wangtao0101.vscode-perfect-css-modules", 7 | "stylelint.vscode-stylelint" 8 | ], 9 | "unwantedRecommendations": [] 10 | } 11 | -------------------------------------------------------------------------------- /src/pages/NotFound/NotFound.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | width: 100%; 4 | align-items: center; 5 | justify-content: center; 6 | } 7 | 8 | .inner-container { 9 | display: flex; 10 | } 11 | 12 | .text-container { 13 | margin-right: 3rem; 14 | margin-left: 3rem; 15 | } 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.4" # for 'target' 2 | services: 3 | backend: 4 | image: goooseman/sample-chat-backend:latest 5 | ports: 6 | - "8090:8090" 7 | frontend: 8 | build: 9 | context: . 10 | dockerfile: Dockerfile.development 11 | ports: 12 | - "8080:8080" 13 | -------------------------------------------------------------------------------- /src/services/ChatService/__mocks__/index.ts: -------------------------------------------------------------------------------- 1 | export const mockedService = { 2 | connect: jest.fn(), 3 | disconnect: jest.fn(), 4 | sendMessage: jest.fn(), 5 | onMessagesListChange: jest.fn(), 6 | }; 7 | 8 | export const getChatService = jest.fn().mockImplementation(() => { 9 | return mockedService; 10 | }); 11 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.(js|jsx|ts|tsx)": [ 3 | "npm run lint:prettier:write", 4 | "npm run lint:eslint:write", 5 | "npm run intl:update-po" 6 | ], 7 | "*.json": [ 8 | "npm run lint:prettier:write" 9 | ], 10 | "*.css": [ 11 | "npm run lint:prettier:write", 12 | "npm run lint:stylelint:write" 13 | ] 14 | } -------------------------------------------------------------------------------- /src/pages/NotFound/NotFound.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import NotFound from "./NotFound"; 3 | import { withRouter } from "react-router-dom"; 4 | 5 | export default { title: "pages/NotFound", component: NotFound }; 6 | 7 | const Container = withRouter(NotFound); 8 | 9 | export const Default = (): React.ReactNode => ; 10 | -------------------------------------------------------------------------------- /@types/images/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.jpg" { 2 | const path: string; 3 | export = path; 4 | } 5 | 6 | declare module "*.svg" { 7 | const path: string; 8 | export = path; 9 | } 10 | 11 | declare module "*.gif" { 12 | const path: string; 13 | export = path; 14 | } 15 | 16 | declare module "*.png" { 17 | const path: string; 18 | export = path; 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Run test for a current open .spec file", 8 | "type": "shell", 9 | "command": "npm run test -- ${file}", 10 | "group": "test" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "react-dom"; 3 | import AppWrapper from "src/wrappers/AppWrapper"; 4 | import Pages from "./pages"; 5 | import BodyWrapper from "src/wrappers/BodyWrapper/BodyWrapper"; 6 | 7 | render( 8 | 9 | 10 | 11 | 12 | , 13 | document.getElementById("root") as HTMLElement 14 | ); 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.formatOnSave": true, 4 | "[javascript]": { 5 | "editor.defaultFormatter": "esbenp.prettier-vscode" 6 | }, 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll": true 9 | }, 10 | "typescript.tsdk": "node_modules/typescript/lib", 11 | "css.validate": false // https://marketplace.visualstudio.com/items?itemName=stylelint.vscode-stylelint 12 | } 13 | -------------------------------------------------------------------------------- /src/pages/Chat/components/ChatInput/ChatInput.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | width: 100%; 4 | justify-content: space-between; 5 | padding: 0.6rem; 6 | box-shadow: 0 3px 3px -2px rgba(0, 0, 0, 0.2), 0 3px 4px 0 rgba(0, 0, 0, 0.14), 7 | 0 1px 8px 0 rgba(0, 0, 0, 0.12); 8 | } 9 | 10 | .container .textarea { 11 | border: 0; 12 | } 13 | 14 | .container .textarea::-webkit-resizer { 15 | display: none; 16 | } 17 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-normalize": {}, 4 | "postcss-custom-properties": { 5 | preserve: true, 6 | importFrom: "src/styles/themes/default.css", 7 | }, 8 | "postcss-custom-media": { 9 | preserve: false, 10 | importFrom: "src/styles/breakpoints.ts", 11 | }, 12 | autoprefixer: {}, 13 | cssnano: { 14 | preset: "default", 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/config/locales.ts: -------------------------------------------------------------------------------- 1 | export const locales = [ 2 | { 3 | key: "en-GB", 4 | internationalName: "English (GB)", 5 | localName: "English (GB)", 6 | }, 7 | { 8 | key: "ru", 9 | internationalName: "Russian", 10 | localName: "Русский", 11 | }, 12 | { 13 | key: "he", 14 | internationalName: "Hebrew", 15 | localName: "עברית", 16 | }, 17 | ] as const; 18 | 19 | export type Locale = typeof locales[number]["key"]; 20 | -------------------------------------------------------------------------------- /@types/fonts/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.woff" { 2 | const path: string; 3 | export = path; 4 | } 5 | 6 | declare module "*.woff2" { 7 | const path: string; 8 | export = path; 9 | } 10 | 11 | declare module "*.eot" { 12 | const path: string; 13 | export = path; 14 | } 15 | 16 | declare module "*.ttf" { 17 | const path: string; 18 | export = path; 19 | } 20 | 21 | declare module "*.otf" { 22 | const path: string; 23 | export = path; 24 | } 25 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Application", 3 | "short_name": "App", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /src/pages/Chat/components/ChatInput/ChatInput.container.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ChatInput, { ChatInputPureProps } from "./ChatInput"; 3 | import { withSettings, WithSettings } from "src/contexts/SettingsContext"; 4 | 5 | interface ContainerProps extends WithSettings, ChatInputPureProps {} 6 | 7 | export default withSettings((props: ContainerProps) => ( 8 | 12 | )); 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "jsdom", 4 | moduleNameMapper: { 5 | "\\.(png|svg|jpg|gif|woff|woff2|eot|ttf|otf)$": 6 | "/__mocks__/fileMock.js", 7 | "\\.(css|less)$": "identity-obj-proxy", 8 | }, 9 | setupFiles: ["/__mocks__/fetch.js"], 10 | modulePaths: ["/"], 11 | collectCoverageFrom: [ 12 | "src/**/*.ts?(x)", 13 | "!src/**/*.stories.tsx", 14 | "!src/**/index.ts?(x)", 15 | ], 16 | globals: { 17 | "ts-jest": { 18 | diagnostics: false, 19 | }, 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /src/pages/Chat/components/ChatInput/ChatInput.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ChatInput from "./ChatInput"; 3 | import { action } from "@storybook/addon-actions"; 4 | 5 | export default { 6 | title: "pages/Chat/components/ChatInput", 7 | component: ChatInput, 8 | }; 9 | 10 | const handleSubmit = action("onSubmit"); 11 | 12 | export const withCtrlPlusEnterToSend = (): React.ReactNode => ( 13 | 14 | ); 15 | 16 | export const withoutCtrlPlusEnterToSend = (): React.ReactNode => ( 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /src/components/TimeDisplay/TimeDisplay.container.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import TimeDisplay, { TimeDisplayProps } from "./TimeDisplay"; 3 | import { withSettings, WithSettings } from "src/contexts/SettingsContext"; 4 | import { WithLocaleStateful, withLocale } from "react-targem"; 5 | 6 | interface ContainerProps 7 | extends WithSettings, 8 | WithLocaleStateful, 9 | TimeDisplayProps {} 10 | 11 | export default withLocale( 12 | withSettings((props: ContainerProps) => ( 13 | 18 | )) 19 | ); 20 | -------------------------------------------------------------------------------- /src/components/ui-kit/Loading/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { faSync } from "@fortawesome/free-solid-svg-icons"; 4 | import { useLocale } from "react-targem"; 5 | 6 | interface LoadingProps { 7 | label?: string; 8 | } 9 | 10 | const Loading: React.FC = ({ label }: LoadingProps) => { 11 | const { t } = useLocale(); 12 | return ( 13 | 19 | ); 20 | }; 21 | 22 | export default Loading; 23 | -------------------------------------------------------------------------------- /.github/workflows/linters.yml: -------------------------------------------------------------------------------- 1 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 2 | 3 | name: Linters 4 | 5 | on: 6 | - push 7 | - pull_request 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: ["12.18"] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: npm ci 24 | - run: npm run lint:check 25 | -------------------------------------------------------------------------------- /src/styles/themes/dark.css: -------------------------------------------------------------------------------- 1 | :global .theme-dark { 2 | /* colors */ 3 | --color-primary-lighter: #9fa8da; /* 200 */ 4 | --color-primary: #3f51b5; /* 500 */ 5 | --color-primary-darker: #283593; /* 800 */ 6 | --color-secondary-lighter: #e1bee7; /* 100 */ 7 | --color-secondary: #9c27b0; /* 500 */ 8 | --color-secondary-darker: #6a1b9a; /* 800 */ 9 | --color-text: #fff; 10 | --color-text-muted: #adadad; 11 | --color-text-contrast: #fff; 12 | --color-text-link: #0277bd; 13 | --color-text-link-visited: #6a1b9a; 14 | --color-text-link-active: #e57373; 15 | --color-border: #fff; 16 | --color-background: #000; 17 | --color-danger: #b71c1c; 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: ["12.18"] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm ci 22 | - run: npm test -- --coverage 23 | - name: Coveralls 24 | uses: coverallsapp/github-action@master 25 | with: 26 | github-token: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "importHelpers": true, 4 | "noImplicitAny": true, 5 | "noUnusedLocals": true, 6 | "noImplicitReturns": true, 7 | "noImplicitThis": true, 8 | "strictNullChecks": true, 9 | "sourceMap": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "esModuleInterop": true, 13 | "strict": true, 14 | "jsx": "react", 15 | "lib": ["es2015", "es2016", "es2017", "dom"], 16 | "baseUrl": ".", 17 | "typeRoots": ["node_modules/@types", "./@types"], 18 | "isolatedModules": true, 19 | "target": "es5", 20 | "resolveJsonModule": true 21 | }, 22 | "include": ["src/**/*"] 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug jest test", 9 | "type": "node", 10 | "request": "launch", 11 | "runtimeArgs": [ 12 | "--inspect-brk", 13 | "${workspaceRoot}/node_modules/.bin/jest", 14 | "--runInBand", 15 | "${file}" 16 | ], 17 | "console": "integratedTerminal", 18 | "internalConsoleOptions": "neverOpen", 19 | "port": 9229 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import { Switch, Route } from "react-router-dom"; 3 | import { routes } from "src/config/routes"; 4 | import ChatPage from "./Chat"; 5 | import NotFoundPage from "./NotFound"; 6 | import SettingsPage from "./Settings"; 7 | 8 | interface PagesProps {} 9 | 10 | class Pages extends PureComponent { 11 | render(): React.ReactNode { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | } 21 | 22 | export default Pages; 23 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { addDecorator, addParameters } from "@storybook/react"; 3 | import StorybookSharedWrapper from "../src/wrappers/StorybookSharedWrapper"; 4 | import { INITIAL_VIEWPORTS } from "@storybook/addon-viewport"; 5 | import { withA11y } from "@storybook/addon-a11y"; 6 | import "@storybook/addon-console"; 7 | import { withContexts } from "@storybook/addon-contexts/react"; 8 | import { contexts } from "./contexts"; 9 | 10 | addDecorator(withA11y); 11 | 12 | addDecorator((story) => 13 | React.createElement(StorybookSharedWrapper, {}, story()) 14 | ); 15 | 16 | addParameters({ 17 | viewport: { 18 | viewports: INITIAL_VIEWPORTS, 19 | }, 20 | }); 21 | 22 | addDecorator(withContexts(contexts)); 23 | -------------------------------------------------------------------------------- /src/services/ChatService/__fixtures__/index.ts: -------------------------------------------------------------------------------- 1 | import { ChatAdapterMessage } from "../ChatAdapter"; 2 | import { ChatMessage } from "../ChatService"; 3 | 4 | export const fakeIncomingMessage: ChatAdapterMessage = { 5 | id: "1", 6 | userId: "not-me", 7 | username: "user-goland", 8 | text: "A very informative message!", 9 | createdAt: "2018-12-26T15:00:00.000Z", 10 | status: "receivedByServer", 11 | }; 12 | 13 | export const userId = "a3829b4f-b2f3-4df0-8e04-f25b17791e29"; 14 | 15 | export const fakeTransformedMessage: ChatMessage = { 16 | id: "1", 17 | username: "user-goland", 18 | type: "inbox", 19 | text: "A very informative message!", 20 | createdAt: new Date("2018-12-26T15:00:00.000Z"), 21 | status: "receivedByServer", 22 | }; 23 | -------------------------------------------------------------------------------- /src/styles/breakpoints.ts: -------------------------------------------------------------------------------- 1 | export const breakpoints = { 2 | xs: 0, 3 | sm: 600, 4 | md: 960, 5 | lg: 1280, 6 | xl: 1920, 7 | }; 8 | 9 | export const customMedia = { 10 | "--viewport-xs-max": `(max-width: ${breakpoints.sm - 1}px)`, 11 | "--viewport-xs-min": `(min-width: ${breakpoints.xs}px)`, 12 | "--viewport-sm-max": `(max-width: ${breakpoints.md - 1}px)`, 13 | "--viewport-sm-min": `(min-width: ${breakpoints.sm}px)`, 14 | "--viewport-md-max": `(max-width: ${breakpoints.lg - 1}px)`, 15 | "--viewport-md-min": `(min-width: ${breakpoints.md}px)`, 16 | "--viewport-lg-max": `(max-width: ${breakpoints.xl - 1}px)`, 17 | "--viewport-lg-min": `(min-width: ${breakpoints.lg}px)`, 18 | "--viewport-xl-min": `(min-width: ${breakpoints.xl}px)`, 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/ui-kit/Button/Button.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Button from "./Button"; 3 | import { render, fireEvent } from "__utils__/render"; 4 | 5 | // This test is just an example of using jest-dom matchers 6 | // In real world scenario this test is useless, except being created to fix an existing bug and to prevent regressions 7 | it("should contain passed text", () => { 8 | const { getByText } = render(); 9 | expect(getByText("Foo")).toBeInTheDocument(); 10 | }); 11 | 12 | it("should fire onClick handler", () => { 13 | const onClickSpy = jest.fn(); 14 | const { getByText } = render(); 15 | fireEvent.click(getByText("Foo")); 16 | expect(onClickSpy).toBeCalledTimes(1); 17 | }); 18 | -------------------------------------------------------------------------------- /src/utils/locales.spec.ts: -------------------------------------------------------------------------------- 1 | import { findLocale } from "./locales"; 2 | import { Locale } from "src/config/locales"; 3 | 4 | const supportedLocales = ["en-GB", "en-AU"] as Locale[]; 5 | 6 | it("should return en-GB for en-GB", () => { 7 | expect(findLocale(supportedLocales, "en-GB")).toBe("en-GB"); 8 | }); 9 | 10 | it("should return en-AU for en-AU", () => { 11 | expect(findLocale(supportedLocales, "en-AU")).toBe("en-AU"); 12 | }); 13 | 14 | it("should return en-GB for en", () => { 15 | expect(findLocale(supportedLocales, "en")).toBe("en-GB"); 16 | }); 17 | 18 | it("should return en-GB for en-US ", () => { 19 | expect(findLocale(supportedLocales, "en-US")).toBe("en-GB"); 20 | }); 21 | 22 | it("Should return undefined if not found", () => { 23 | expect(findLocale(supportedLocales, "foo")).toBeUndefined(); 24 | }); 25 | -------------------------------------------------------------------------------- /src/styles/global.css: -------------------------------------------------------------------------------- 1 | @import "./vendor.css"; 2 | @import "./themes/default.css"; 3 | @import "./themes/dark.css"; 4 | 5 | html { 6 | font-family: var(--font-family-primary); 7 | font-size: var(--font-size-base); 8 | letter-spacing: var(--letter-spacing-base); 9 | line-height: var(--line-height-base); 10 | } 11 | 12 | @media screen and (--viewport-sm-max) { 13 | html { 14 | font-size: 14px; 15 | } 16 | } 17 | 18 | :global [class^="theme"] { 19 | display: flex; 20 | overflow: hidden; 21 | min-height: 100vh; 22 | flex-direction: row; 23 | align-items: flex-start; 24 | background-color: var(--color-background); 25 | } 26 | 27 | :global [class^="theme"] > main { 28 | /* pages should have a minimum height of 100% to have an ability to vertically center the content */ 29 | 30 | align-self: stretch; 31 | } 32 | -------------------------------------------------------------------------------- /src/services/ChatService/index.ts: -------------------------------------------------------------------------------- 1 | import socketIoClient from "socket.io-client"; 2 | import ChatAdapter from "./ChatAdapter"; 3 | import ChatService, { ChatMessage, SearchResult } from "./ChatService"; 4 | import { CHAT_BACKEND_URL } from "src/config/env"; 5 | 6 | export const getChatService = (userId: string): ChatService => { 7 | const socket = socketIoClient(CHAT_BACKEND_URL, { 8 | path: "/chat", 9 | autoConnect: false, 10 | reconnection: true, 11 | reconnectionAttempts: Infinity, 12 | reconnectionDelay: 3 * 1000, 13 | forceNew: false, 14 | multiplex: false, 15 | transports: ["polling", "websocket"], 16 | }); 17 | 18 | const chatAdapter = new ChatAdapter(socket, userId); 19 | const chatService = new ChatService(chatAdapter); 20 | return chatService; 21 | }; 22 | 23 | export { ChatService }; 24 | export type { ChatMessage, SearchResult }; 25 | -------------------------------------------------------------------------------- /src/components/ui-kit/Button/Button.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Button from "./Button"; 3 | import { action } from "@storybook/addon-actions"; 4 | 5 | export default { title: "components/ui-kit/Button", component: Button }; 6 | 7 | const handleClick = action("onClick"); 8 | 9 | export const withText = (): React.ReactNode => ( 10 | 11 | ); 12 | 13 | export const withoutBorderRadius = (): React.ReactNode => ( 14 | 17 | ); 18 | 19 | export const withEmoji = (): React.ReactNode => ( 20 | 25 | ); 26 | 27 | export const largeWithText = (): React.ReactNode => ( 28 | 31 | ); 32 | -------------------------------------------------------------------------------- /src/wrappers/StorybookSharedWrapper/StorybookSharedWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import { WithSettings, withSettings } from "src/contexts/SettingsContext"; 3 | import cn from "clsx"; 4 | import "src/styles/global.css"; 5 | import { withLocale, WithLocale } from "react-targem"; 6 | import { BrowserRouter } from "react-router-dom"; 7 | 8 | interface StorybookSharedWrapperProps extends WithSettings, WithLocale { 9 | children: React.ReactChild; 10 | } 11 | 12 | class StorybookSharedWrapper extends PureComponent< 13 | StorybookSharedWrapperProps 14 | > { 15 | render() { 16 | const { direction, theme, children } = this.props; 17 | 18 | return ( 19 | 20 | 21 |
22 | {children} 23 |
24 |
25 |
26 | ); 27 | } 28 | } 29 | 30 | export default withLocale(withSettings(StorybookSharedWrapper)); 31 | -------------------------------------------------------------------------------- /src/contexts/createContextHOC.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export type ContextHOC =

( 4 | Component: React.ComponentType

5 | ) => React.ComponentType & Partial>; 6 | 7 | const createContextHOC = ( 8 | Consumer: React.Consumer 9 | ) => { 10 | const withContext =

( 11 | Component: React.ComponentType

& { defaultProps?: DP } 12 | ) => { 13 | return class WithContextHOC extends React.Component< 14 | Omit & Partial 15 | > { 16 | public render() { 17 | return {this.renderComponent}; 18 | } 19 | 20 | private renderComponent = (ctx: Context) => { 21 | const newProps = { ...ctx, ...this.props }; 22 | return ; 23 | }; 24 | }; 25 | }; 26 | 27 | return withContext; 28 | }; 29 | 30 | export default createContextHOC; 31 | -------------------------------------------------------------------------------- /src/utils/locales.ts: -------------------------------------------------------------------------------- 1 | // Originally taken from https://github.com/trucknet-io/react-targem/blob/develop/src/utils/locale.ts 2 | 3 | import { Locale } from "src/config/locales"; 4 | 5 | export function findLocale( 6 | supportedLocales: Locale[], 7 | locale: string 8 | ): Locale | undefined { 9 | if (supportedLocales.includes(locale as Locale)) { 10 | return locale as Locale; 11 | } 12 | for (const localeToMatch of supportedLocales) { 13 | if (localeToMatch.includes(locale.split("-")[0])) { 14 | return localeToMatch; 15 | } 16 | } 17 | return undefined; 18 | } 19 | 20 | export function getBrowserLocale( 21 | supportedLocales: Locale[], 22 | fallbackLocale: Locale 23 | ): Locale { 24 | let browserLocale: Locale | undefined; 25 | if (typeof window !== "undefined" && window.navigator) { 26 | const lang = window.navigator.language; 27 | if (lang) { 28 | browserLocale = findLocale(supportedLocales, lang); 29 | } 30 | } 31 | 32 | return browserLocale || fallbackLocale; 33 | } 34 | -------------------------------------------------------------------------------- /src/components/ui-kit/NavBar/NavBar.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | width: 100%; 4 | height: 3.4rem; 5 | background-color: var(--color-primary); 6 | } 7 | 8 | .item { 9 | min-width: 100px; 10 | padding: 1rem 0.6rem; 11 | color: var(--color-text-contrast); 12 | text-align: center; 13 | text-decoration: none; 14 | transition: background-color 0.5s ease; 15 | } 16 | 17 | .item .text { 18 | opacity: 0.8; 19 | } 20 | 21 | .item-dark { 22 | background-color: var(--color-primary-darker); 23 | } 24 | 25 | .item-active { 26 | border-bottom: 4px solid var(--color-primary-darker); 27 | } 28 | 29 | .item-active .text { 30 | opacity: 1; 31 | } 32 | 33 | .badge { 34 | position: relative; 35 | top: -3px; 36 | display: inline-block; 37 | width: 22px; 38 | margin-right: 0.5rem; 39 | margin-left: 0.5rem; 40 | background-color: var(--color-danger); 41 | border-radius: 50%; 42 | color: var(--color-text-contrast); 43 | font-size: 9px; 44 | line-height: 22px; 45 | vertical-align: middle; 46 | } 47 | -------------------------------------------------------------------------------- /src/components/ui-kit/NavBar/NavBar.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { NavBar, NavBarItem } from "./NavBar"; 3 | 4 | export default { title: "components/ui-kit/NavBar", component: NavBar }; 5 | 6 | export const withDefaultView = (): React.ReactNode => ( 7 | 8 | 9 | 10 | 11 | ); 12 | 13 | export const withBadge = (): React.ReactNode => ( 14 | 15 | 16 | 17 | 18 | ); 19 | 20 | export const withBigNumberBadge = (): React.ReactNode => ( 21 | 22 | 23 | 24 | 25 | ); 26 | 27 | export const withBlinking = (): React.ReactNode => ( 28 | 29 | 30 | 31 | 32 | ); 33 | -------------------------------------------------------------------------------- /src/components/ui-kit/Typography/Typography.css: -------------------------------------------------------------------------------- 1 | .common { 2 | margin-top: 0; 3 | margin-bottom: 0; 4 | color: var(--color-text); 5 | letter-spacing: var(--letter-spacing-base); 6 | line-height: var(--line-height-base); 7 | } 8 | 9 | .common.gutter-bottom { 10 | margin-bottom: 0.7rem; 11 | } 12 | 13 | .common a { 14 | color: var(--color-text-link); 15 | } 16 | 17 | .common a:visited { 18 | color: var(--color-text-link-visited); 19 | } 20 | 21 | .common a:active { 22 | color: var(--color-text-link-active); 23 | } 24 | 25 | .color-muted { 26 | color: var(--color-text-muted); 27 | } 28 | 29 | .color-contrast { 30 | color: var(--color-text-contrast); 31 | } 32 | 33 | .color-danger { 34 | color: var(--color-danger); 35 | } 36 | 37 | .p { 38 | font-family: var(--font-family-primary); 39 | font-size: var(--font-size-base); 40 | } 41 | 42 | .h1, 43 | .h2, 44 | .h3 { 45 | font-family: var(--font-family-secondary); 46 | } 47 | 48 | .h1 { 49 | font-size: 2.8rem; 50 | } 51 | 52 | .h2 { 53 | font-size: 2.4rem; 54 | } 55 | 56 | .h3 { 57 | font-size: 2rem; 58 | } 59 | -------------------------------------------------------------------------------- /src/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 17 | 18 | <%= htmlWebpackPlugin.options.title %> 19 | 20 | 24 | 25 | 26 | 27 | 30 |

31 | 32 | 33 | -------------------------------------------------------------------------------- /src/wrappers/BodyWrapper/BodyWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import classes from "./BodyWrapper.css"; 3 | import cn from "clsx"; 4 | import { NavBar, NavBarItem } from "src/components/ui-kit/NavBar"; 5 | import { T } from "react-targem"; 6 | import { routes } from "src/config/routes"; 7 | import { withChat, WithChat } from "src/contexts/ChatContext"; 8 | 9 | interface BodyWrapperProps extends WithChat { 10 | children: React.ReactNode; 11 | } 12 | 13 | class BodyWrapper extends PureComponent { 14 | render(): React.ReactNode { 15 | const { chatMessagesUnreadCount } = this.props; 16 | return ( 17 |
18 | 19 | } 21 | to={routes.home} 22 | badge={chatMessagesUnreadCount} 23 | isBlinking={chatMessagesUnreadCount > 0} 24 | /> 25 | } to={routes.settings} /> 26 | 27 | {this.props.children} 28 |
29 | ); 30 | } 31 | } 32 | 33 | export default withChat(BodyWrapper); 34 | -------------------------------------------------------------------------------- /src/pages/Settings/SettingsPage.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import SettingsPage from "./SettingsPage"; 3 | import { action } from "@storybook/addon-actions"; 4 | 5 | export default { title: "pages/Settings", component: SettingsPage }; 6 | 7 | const handleResetDefaultClick = action("onResetDefaultClick"); 8 | const handleUsernameChange = action("onUsernameChange"); 9 | const handleLocaleChange = action("onLocaleChange"); 10 | const handleThemeChange = action("onThemeChange"); 11 | const handleIs12hoursChange = action("onIs12hoursChange"); 12 | const handleIsCtrlEnterToSend = action("onIsCtrlEnterToSend"); 13 | 14 | const defaultProps = { 15 | onResetDefaultClick: handleResetDefaultClick, 16 | onUsernameChange: handleUsernameChange, 17 | onLocaleChange: handleLocaleChange, 18 | onThemeChange: handleThemeChange, 19 | onIs12hoursChange: handleIs12hoursChange, 20 | onIsCtrlEnterToSend: handleIsCtrlEnterToSend, 21 | locale: "en-GB", 22 | username: "goooseman", 23 | theme: "default", 24 | is12hours: true, 25 | isCtrlEnterToSend: false, 26 | } as const; 27 | 28 | export const withDefaultView = (): React.ReactNode => ( 29 | 30 | ); 31 | -------------------------------------------------------------------------------- /__utils__/render.tsx: -------------------------------------------------------------------------------- 1 | import { render, RenderOptions, RenderResult } from "@testing-library/react"; 2 | import React from "react"; 3 | import { TargemStatefulProvider } from "react-targem"; 4 | import "@testing-library/jest-dom"; 5 | 6 | export const BlankWrapper: React.StatelessComponent<{}> = (props: { 7 | children?: React.ReactNode; 8 | }) => <>{props.children}; 9 | 10 | const AllTheProviders = (Wrapper: React.ComponentType = BlankWrapper) => ({ 11 | children, 12 | }: { 13 | children?: React.ReactNode; 14 | }) => { 15 | return ( 16 | 17 | 18 | {/** StrictMode is useful for `this.setState` functions to be called twice and to prevent side-effects */} 19 | {children} 20 | 21 | 22 | ); 23 | }; 24 | 25 | const customRender = ( 26 | ui: React.ReactElement, 27 | options?: RenderOptions 28 | ): RenderResult => { 29 | return render(ui, { ...options, wrapper: AllTheProviders(options?.wrapper) }); 30 | }; 31 | 32 | // re-export everything 33 | export * from "@testing-library/react"; 34 | 35 | // override render method 36 | export { customRender as render }; 37 | -------------------------------------------------------------------------------- /src/components/ui-kit/NavBar/NavBar.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { NavBar, NavBarItem } from "./NavBar"; 3 | import { render } from "__utils__/renderWithRouter"; 4 | 5 | it("should blink", () => { 6 | jest.useFakeTimers(); 7 | const { getByText } = render( 8 | 9 | 10 | 11 | ); 12 | const navBar = getByText("Foo").parentElement; 13 | jest.advanceTimersByTime(1000); 14 | expect(navBar).toHaveClass("itemDark"); 15 | jest.advanceTimersByTime(1000); 16 | expect(navBar).not.toHaveClass("itemDark"); 17 | jest.advanceTimersByTime(1000); 18 | expect(navBar).toHaveClass("itemDark"); 19 | }); 20 | 21 | it("should become light after blinking stop", () => { 22 | jest.useFakeTimers(); 23 | const { getByText, rerender } = render( 24 | 25 | 26 | 27 | ); 28 | const navBar = getByText("Foo").parentElement; 29 | jest.advanceTimersByTime(1000); 30 | expect(navBar).toHaveClass("itemDark"); 31 | rerender( 32 | 33 | 34 | 35 | ); 36 | expect(navBar).not.toHaveClass("itemDark"); 37 | }); 38 | -------------------------------------------------------------------------------- /src/components/ui-kit/Button/Button.css: -------------------------------------------------------------------------------- 1 | .button { 2 | min-width: 3rem; 3 | padding: 0.5rem 1rem; 4 | border: 0; 5 | background-color: var(--color-primary); 6 | color: var(--color-text-contrast); 7 | cursor: pointer; 8 | outline: 0; 9 | text-transform: uppercase; 10 | transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, 11 | box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, 12 | border 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; 13 | } 14 | 15 | .border-radius { 16 | border-radius: var(--border-radius); 17 | } 18 | 19 | .button[disabled] { 20 | background-color: var(--color-primary-lighter); 21 | pointer-events: none; 22 | } 23 | 24 | .button:hover, 25 | .button:active, 26 | .button:focus { 27 | background-color: var(--color-primary-darker); 28 | } 29 | 30 | .button-secondary { 31 | background-color: var(--color-secondary); 32 | } 33 | 34 | .button-secondary[disabled] { 35 | background-color: var(--color-secondary-lighter); 36 | } 37 | 38 | .button-secondary:hover, 39 | .button-secondary:active, 40 | .button-secondary:focus { 41 | background-color: var(--color-secondary-darker); 42 | } 43 | 44 | .button-lg { 45 | padding: 1rem 1.5rem; 46 | } 47 | 48 | .button-sm { 49 | min-width: 0; 50 | padding: 0.5rem 0.5rem; 51 | } 52 | -------------------------------------------------------------------------------- /config/webpack/config.webpack.storybook.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const path = require("path"); 3 | 4 | // This file does not exports a webpack configuration file, but exports a function, which customizes the configuration 5 | 6 | module.exports = function (config) { 7 | // https://github.com/storybookjs/storybook/tree/master/addons/storysource#parser 8 | 9 | config.module.rules.push({ 10 | test: /\.stories\.tsx?$/, 11 | exclude: [/node_modules/], 12 | loaders: [ 13 | { 14 | loader: require.resolve("@storybook/source-loader"), 15 | options: { parser: "typescript" }, 16 | }, 17 | ], 18 | enforce: "pre", 19 | }); 20 | 21 | // https://www.npmjs.com/package/react-docgen-typescript-loader 22 | config.module.rules.push({ 23 | test: /\.tsx?$/, 24 | exclude: [/node_modules/], 25 | use: [ 26 | { 27 | loader: require.resolve("react-docgen-typescript-loader"), 28 | options: { 29 | // Provide the path to your tsconfig.json so that your stories can 30 | // display types from outside each individual story. 31 | tsconfigPath: path.resolve(__dirname, "../../tsconfig.json"), 32 | }, 33 | }, 34 | ], 35 | }); 36 | 37 | return config; 38 | }; 39 | -------------------------------------------------------------------------------- /src/pages/Chat/ChatPage.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | display: flex; 4 | width: 100%; 5 | 6 | /* A hardcoded NavBar height, can be exported as variable, but better to be fixed in a different way */ 7 | height: calc(100vh - 3.4rem); 8 | flex-direction: column; 9 | } 10 | 11 | .messages-container { 12 | flex-grow: 1; 13 | overflow-y: auto; 14 | } 15 | 16 | .search-container { 17 | display: flex; 18 | align-items: center; 19 | justify-content: space-between; 20 | padding: 1rem 1rem; 21 | background-color: var(--color-secondary-darker); 22 | } 23 | 24 | .search-input { 25 | max-width: 40rem; 26 | } 27 | 28 | .search-button { 29 | position: absolute; 30 | right: 1rem; 31 | bottom: calc(100% + 0.8rem); 32 | } 33 | 34 | .search-navigator { 35 | display: flex; 36 | align-items: center; 37 | margin-left: 1rem; 38 | } 39 | 40 | .search-navigator p { 41 | margin-right: 0.5rem; 42 | margin-left: 0.5rem; 43 | white-space: nowrap; 44 | } 45 | 46 | .error-container { 47 | display: flex; 48 | align-items: center; 49 | justify-content: space-between; 50 | margin: 0 -0.5rem; 51 | } 52 | 53 | .error-container > * { 54 | margin: 0 0.5rem; 55 | } 56 | 57 | [dir="rtl"] .search-button { 58 | right: auto; 59 | left: 1rem; 60 | } 61 | 62 | [dir="rtl"] .search-navigator { 63 | margin-right: 1rem; 64 | margin-left: 0; 65 | } 66 | -------------------------------------------------------------------------------- /src/components/ui-kit/RadioGroup/RadioGroup.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import RadioGroup from "./RadioGroup"; 3 | import { render, fireEvent } from "__utils__/render"; 4 | 5 | const defaultProps = { 6 | labelledWith: "Foo?", 7 | id: "story", 8 | options: [ 9 | { 10 | value: "foo", 11 | text: "Foo!", 12 | }, 13 | { 14 | value: "bar", 15 | text: "Bar!", 16 | }, 17 | ], 18 | value: "foo", 19 | }; 20 | 21 | it("should fire onChange after option being clicked", () => { 22 | const onChangeSpy = jest.fn(); 23 | const { getByLabelText } = render( 24 | 25 | ); 26 | const radioButton = getByLabelText("Bar!"); 27 | 28 | fireEvent.click(radioButton); 29 | 30 | expect(onChangeSpy).toBeCalledTimes(1); 31 | expect(onChangeSpy).toBeCalledWith("bar"); 32 | }); 33 | 34 | it("should work with boolean values", () => { 35 | const onChangeSpy = jest.fn(); 36 | const { getByLabelText } = render( 37 | 43 | ); 44 | const radioButton = getByLabelText("Foo"); 45 | 46 | fireEvent.click(radioButton); 47 | 48 | expect(onChangeSpy).toBeCalledTimes(1); 49 | expect(onChangeSpy).toBeCalledWith(true); 50 | }); 51 | -------------------------------------------------------------------------------- /src/components/ui-kit/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import classes from "./Button.css"; 3 | import cn from "clsx"; 4 | 5 | interface ButtonProps 6 | extends React.DetailedHTMLProps< 7 | React.ButtonHTMLAttributes, 8 | HTMLButtonElement 9 | > { 10 | size: "sm" | "md" | "lg"; 11 | color: "primary" | "secondary"; 12 | hasBorderRadius: boolean; 13 | } 14 | 15 | /** 16 | * Custom `Button` component to be used as a drop-in replacement for ` 47 | ); 48 | } 49 | } 50 | 51 | export default Button; 52 | -------------------------------------------------------------------------------- /src/styles/themes/default.css: -------------------------------------------------------------------------------- 1 | /* This a default theme, so no extra css selectors are needed */ 2 | 3 | /* Colors: https://www.materialui.co/colors */ 4 | 5 | /* This vars will be taken by postcss-custom-properties to create fallback css properties */ 6 | :root { 7 | /* colors */ 8 | --color-primary-lighter: #ffe0b2; /* 100 */ 9 | --color-primary: #ff9800; /* 500 */ 10 | --color-primary-darker: #d84315; /* 800 */ 11 | --color-secondary-lighter: #b3e5fc; /* 100 */ 12 | --color-secondary: #03a9f4; /* 500 */ 13 | --color-secondary-darker: #0277bd; /* 800 */ 14 | --color-text: #000; 15 | --color-text-muted: #808080; 16 | --color-text-contrast: #fff; 17 | --color-text-link: #00e; 18 | --color-text-link-visited: #551a8b; 19 | --color-text-link-active: #f00; 20 | --color-border: #000; 21 | --color-background: #fff; 22 | --color-danger: #b71c1c; 23 | 24 | /* fonts */ 25 | --font-family-primary: helvetica neue, helvetica, arial, sans-serif; 26 | --font-family-secondary: georgia, times new roman, times, serif; 27 | --font-size-base: 16px; 28 | --letter-spacing-base: 0; 29 | --line-height-base: 1.3; 30 | 31 | /* sizes */ 32 | --border-width-regular: 0.1rem; 33 | --border-radius: 4px; 34 | 35 | /* z-indexes */ 36 | --z-index-0: 0; 37 | --z-index-1: 1; 38 | --z-index-2: 10; 39 | --z-index-3: 100; 40 | --z-index-4: 1000; 41 | --z-index-5: 10000; 42 | } 43 | -------------------------------------------------------------------------------- /src/components/TimeDisplay/TimeDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | 3 | export interface TimeDisplayProps { 4 | date: Date; 5 | locale: string; 6 | is12hours: boolean; 7 | } 8 | 9 | /** A component which diplays time, if the date is today or date and time if not */ 10 | class TimeDisplay extends PureComponent { 11 | render(): React.ReactNode { 12 | const { locale, is12hours, date } = this.props; 13 | 14 | const isDateHidden = this.isToday(date); 15 | 16 | const options: Intl.DateTimeFormatOptions = { 17 | year: this.isThisYear(date) ? undefined : "numeric", 18 | month: isDateHidden ? undefined : "short", 19 | day: isDateHidden ? undefined : "2-digit", 20 | hour: "2-digit", 21 | minute: "2-digit", 22 | hour12: is12hours, 23 | } as const; 24 | 25 | return new Intl.DateTimeFormat(locale, options).format(date); 26 | } 27 | 28 | private isToday = (someDate: Date) => { 29 | const today = new Date(); 30 | return ( 31 | someDate.getDate() == today.getDate() && 32 | someDate.getMonth() == today.getMonth() && 33 | someDate.getFullYear() == today.getFullYear() 34 | ); 35 | }; 36 | 37 | private isThisYear = (someDate: Date) => { 38 | const today = new Date(); 39 | return someDate.getFullYear() == today.getFullYear(); 40 | }; 41 | } 42 | 43 | export default TimeDisplay; 44 | -------------------------------------------------------------------------------- /__utils__/renderWithRouter.tsx: -------------------------------------------------------------------------------- 1 | import { createMemoryHistory, MemoryHistory } from "history"; 2 | import React from "react"; 3 | import { Router } from "react-router-dom"; 4 | import { render, RenderResult, RenderOptions, BlankWrapper } from "./render"; 5 | 6 | interface RenderWithRouterReturn extends RenderResult { 7 | history: MemoryHistory; 8 | } 9 | 10 | const renderWithRouter = ( 11 | ui: React.ReactElement, 12 | routerOptions: { 13 | route: string; 14 | } = { 15 | route: "/", 16 | }, 17 | options?: RenderOptions 18 | ): RenderWithRouterReturn => { 19 | const history = createMemoryHistory({ 20 | initialEntries: [routerOptions.route], 21 | }); 22 | const RouterProvider = (Wrapper: React.ComponentType = BlankWrapper) => ({ 23 | children, 24 | }: { 25 | children?: React.ReactNode; 26 | }) => { 27 | return ( 28 | 29 | {children} 30 | 31 | ); 32 | }; 33 | 34 | return { 35 | ...render(ui, { 36 | ...options, 37 | wrapper: RouterProvider(options?.wrapper), 38 | }), 39 | // adding `history` to the returned utilities to allow us 40 | // to reference it in our tests (just try to avoid using 41 | // this to test implementation details). 42 | history, 43 | }; 44 | }; 45 | 46 | export * from "@testing-library/react"; 47 | 48 | export { renderWithRouter as render }; 49 | -------------------------------------------------------------------------------- /src/components/ui-kit/Input/Input.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | display: inline-flex; 4 | width: 100%; 5 | flex-direction: column; 6 | } 7 | 8 | .common { 9 | width: 100%; 10 | padding: 0.5rem 0.5rem; 11 | border: 1px solid var(--color-border); 12 | background-color: var(--color-background); 13 | border-radius: var(--border-radius); 14 | color: var(--color-text); 15 | font-family: var(--font-family-primary); 16 | font-size: var(--font-size-base); 17 | letter-spacing: var(--letter-spacing-base); 18 | line-height: var(--line-height-base); 19 | outline-color: var(--color-primary); 20 | } 21 | 22 | .common::placeholder { 23 | color: var(--color-text-muted); 24 | opacity: 1; 25 | } 26 | 27 | select.common { 28 | appearance: none; 29 | } 30 | 31 | textarea.common { 32 | height: 100%; 33 | } 34 | 35 | .container-inline { 36 | display: flex; 37 | flex-direction: row; 38 | align-items: center; 39 | } 40 | 41 | .container-inline .common { 42 | width: auto; 43 | order: 0; 44 | margin-right: 0.4rem; 45 | margin-left: 0.4rem; 46 | } 47 | 48 | .container-inline label.label { 49 | order: 1; 50 | margin-bottom: 0; 51 | } 52 | 53 | .input-container { 54 | position: relative; 55 | } 56 | 57 | .input-addon-right { 58 | position: absolute; 59 | top: 0; 60 | right: 1rem; 61 | bottom: 0; 62 | height: 20px; 63 | margin: auto 0; 64 | } 65 | 66 | [dir="rtl"] .input-addon-right { 67 | right: auto; 68 | left: 1rem; 69 | } 70 | -------------------------------------------------------------------------------- /src/components/ui-kit/RadioGroup/RadioGroup.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import RadioGroup from "./RadioGroup"; 3 | import { action } from "@storybook/addon-actions"; 4 | 5 | export default { title: "components/ui-kit/RadioGroup", component: RadioGroup }; 6 | 7 | const handleChange = action("onClick"); 8 | 9 | const defaultProps = { 10 | labelledWith: "Foo?", 11 | id: "story", 12 | options: [ 13 | { 14 | value: "foo", 15 | text: "Foo!", 16 | }, 17 | { 18 | value: "bar", 19 | text: "Bar!", 20 | }, 21 | ], 22 | onChange: handleChange, 23 | value: "foo", 24 | }; 25 | 26 | export const withTwoOptions = (): React.ReactNode => ( 27 | 28 | ); 29 | 30 | const fourOptions = [ 31 | { 32 | value: "foo", 33 | text: "Foo!", 34 | }, 35 | { 36 | value: "bar", 37 | text: "Bar!", 38 | }, 39 | { 40 | value: "fuu", 41 | text: "Fuu!", 42 | }, 43 | { 44 | value: "ber", 45 | text: "Ber!", 46 | }, 47 | ]; 48 | 49 | export const withFourOptions = (): React.ReactNode => ( 50 | 51 | ); 52 | 53 | const booleanOptions = [ 54 | { 55 | value: false, 56 | text: "False", 57 | }, 58 | { 59 | value: true, 60 | text: "True", 61 | }, 62 | ]; 63 | 64 | export const withBooleanOptions = (): React.ReactNode => ( 65 | 66 | ); 67 | -------------------------------------------------------------------------------- /src/components/TimeDisplay/TimeDisplay.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import TimeDisplay from "./TimeDisplay"; 3 | 4 | export default { title: "components/TimeDisplay", component: TimeDisplay }; 5 | 6 | const now = new Date(); 7 | 8 | const defaultProps = { 9 | locale: "en-US", 10 | is12hours: true, 11 | date: now, 12 | }; 13 | 14 | export const withEnUsAnd12h = (): React.ReactNode => ( 15 | 16 | ); 17 | 18 | export const withEnUsAnd24h = (): React.ReactNode => ( 19 | 20 | ); 21 | 22 | const anotherDay = new Date(now.getFullYear(), 3, 29); 23 | 24 | export const withDateAnotherDay = (): React.ReactNode => ( 25 | 26 | ); 27 | 28 | export const withDateAnotherDayIn24h = (): React.ReactNode => ( 29 | 30 | ); 31 | 32 | export const withDateAnotherDayAndFrenchLanguage = (): React.ReactNode => ( 33 | 39 | ); 40 | 41 | export const withDateAnotherDayAndFrenchLanguageIn12hours = (): React.ReactNode => ( 42 | 43 | ); 44 | 45 | const anotherYear = new Date(1976, 3, 11); 46 | 47 | export const withDateAnotherYear = (): React.ReactNode => ( 48 | 49 | ); 50 | -------------------------------------------------------------------------------- /src/pages/Settings/SettingsPage.container.tsx: -------------------------------------------------------------------------------- 1 | import { RouteComponentProps } from "react-router-dom"; 2 | import React, { PureComponent } from "react"; 3 | import SettingsPage from "./SettingsPage"; 4 | import { withSettings, WithSettings } from "src/contexts/SettingsContext"; 5 | 6 | interface SettingsPageContainerProps 7 | extends RouteComponentProps, 8 | WithSettings {} 9 | 10 | class SettingsPageContainer extends PureComponent { 11 | render(): React.ReactNode { 12 | const { is12hours, isCtrlEnterToSend, username, lang, theme } = this.props; 13 | 14 | return ( 15 | 28 | ); 29 | } 30 | 31 | private handleFieldChange = (field: string) => (value: unknown) => { 32 | this.props.setSettings({ [field]: value }); 33 | }; 34 | 35 | private handleResetDefaultClick = () => { 36 | this.props.resetSettings(); 37 | }; 38 | } 39 | 40 | export default withSettings(SettingsPageContainer); 41 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", // Specifies the ESLint parser 3 | parserOptions: { 4 | ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features 5 | sourceType: "module", // Allows for the use of imports 6 | ecmaFeatures: { 7 | jsx: true, // Allows for the parsing of JSX 8 | }, 9 | }, 10 | settings: { 11 | react: { 12 | version: "detect", // Tells eslint-plugin-react to automatically detect the version of React to use 13 | }, 14 | }, 15 | extends: [ 16 | "plugin:react/recommended", // Uses the recommended rules from @eslint-plugin-react 17 | "plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin 18 | "prettier/@typescript-eslint", // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 19 | "plugin:prettier/recommended", // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 20 | "plugin:jest-dom/recommended", 21 | ], 22 | rules: { 23 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 24 | // e.g. "@typescript-eslint/explicit-function-return-type": "off", 25 | "no-console": "error", 26 | "@typescript-eslint/ban-types": "off", 27 | "@typescript-eslint/no-empty-interface": "off", 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /.vscode/components.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "React Component": { 3 | "prefix": "reactComponent", 4 | "body": [ 5 | "import React, { PureComponent } from \"react\";", 6 | "import classes from \"./${1:${TM_FILENAME_BASE}}.css\";", 7 | "import cn from \"clsx\";", 8 | "", 9 | "interface ${1:${TM_FILENAME_BASE}}Props {", 10 | "\t", 11 | "}", 12 | "", 13 | "class ${1:${TM_FILENAME_BASE}} extends PureComponent<${1:${TM_FILENAME_BASE}}Props> {", 14 | "", 15 | "\trender(): React.ReactNode {", 16 | "\t\treturn (", 17 | "\t\t\t
", 18 | "\t\t\t\t$0", 19 | "\t\t\t
", 20 | "\t\t)", 21 | "\t}", 22 | "}", 23 | "", 24 | "export default ${1:${TM_FILENAME_BASE}};" 25 | ], 26 | "description": "Creates a React component class with ES7 module system and TypeScript interfaces" 27 | }, 28 | "React Component Index": { 29 | "prefix": "reactComponentIndex", 30 | "body": ["import ${1} from \"./${1}\";", "export default ${1};"] 31 | }, 32 | "React Component Storybook": { 33 | "prefix": "reactComponentStorybook", 34 | "body": [ 35 | "import React from \"react\";", 36 | "import ${TM_FILENAME_BASE/.stories//} from \"./${TM_FILENAME_BASE/.stories//}\";", 37 | "", 38 | "export default { title: \"${TM_FILENAME_BASE/.stories//}\", component: ${TM_FILENAME_BASE/.stories//} };", 39 | "", 40 | "export const withSmth = (): React.ReactNode => (", 41 | "\t$0", 42 | ");" 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | const webpackDevelopment = require("../config/webpack/config.webpack.development"); 2 | const webpackProduction = require("../config/webpack/config.webpack.production"); 3 | const webpackStorybook = require("../config/webpack/config.webpack.storybook"); 4 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 5 | 6 | module.exports = { 7 | addons: [ 8 | "@storybook/addon-actions/register", 9 | "@storybook/addon-storysource", 10 | "@storybook/addon-docs", 11 | "@storybook/addon-viewport/register", 12 | "@storybook/addon-a11y/register", 13 | "@storybook/addon-contexts/register", 14 | ], 15 | stories: ["../src/**/*.stories.(tsx|mdx)"], 16 | webpackFinal: async (config, { configType }) => { 17 | // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION' 18 | // You can change the configuration based on that. 19 | // 'PRODUCTION' is used when building the static version of storybook. 20 | 21 | const customConfig = 22 | configType === "DEVELOPMENT" ? webpackDevelopment : webpackProduction; 23 | 24 | // https://storybook.js.org/docs/configurations/custom-webpack-config/#examples 25 | 26 | const safeCustomPlugins = customConfig.plugins.filter((p) => { 27 | return !(p instanceof HtmlWebpackPlugin); 28 | }); 29 | 30 | const finalConfig = webpackStorybook({ 31 | ...customConfig, 32 | entry: config.entry, 33 | output: config.output, 34 | plugins: [...config.plugins, ...safeCustomPlugins], // https://github.com/storybookjs/storybook/issues/6020 35 | }); 36 | 37 | return finalConfig; 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /src/pages/NotFound/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import classes from "./NotFound.css"; 3 | import cn from "clsx"; 4 | import { RouteComponentProps, Link } from "react-router-dom"; 5 | import Typography from "src/components/ui-kit/Typography"; 6 | import { routes } from "src/config/routes"; 7 | import { T } from "react-targem"; 8 | 9 | // Images 10 | 11 | import Image404Dog from "./404-dog.jpg"; 12 | 13 | interface NotFoundProps extends RouteComponentProps {} 14 | 15 | class NotFound extends PureComponent { 16 | render(): React.ReactNode { 17 | return ( 18 |
19 |
20 | {/* "error page" by eoshea is licensed under CC BY-NC-SA 2.0 */} 21 | {/* https://ccsearch.creativecommons.org/photos/3803cd7d-a9a2-413b-a01c-86182d316197 */} 22 | Not found image 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 |
37 |
38 | ); 39 | } 40 | } 41 | 42 | export default NotFound; 43 | -------------------------------------------------------------------------------- /.storybook/contexts.js: -------------------------------------------------------------------------------- 1 | // https://github.com/storybookjs/storybook/tree/master/addons/contexts 2 | 3 | import { SettingsContextProvider } from "../src/contexts/SettingsContext"; 4 | import { themes } from "../src/config/themes"; 5 | import { locales } from "../src/config/locales"; 6 | import { TargemProvider } from "react-targem"; 7 | import translationsJson from "src/i18n/translations.json"; 8 | 9 | const localeParams = locales.map((l, i) => { 10 | return { 11 | name: l.internationalName, 12 | props: { locale: l.key, translations: translationsJson }, 13 | default: i === 0, 14 | }; 15 | }); 16 | 17 | export const contexts = [ 18 | { 19 | icon: "wrench", 20 | title: "Settings", 21 | components: [SettingsContextProvider], 22 | params: [ 23 | { 24 | name: "Default theme / 12 hours / CTRL + ENTER disabled", 25 | props: { is12hours: true, theme: "default", isCtrlEnterToSend: false }, 26 | }, 27 | { 28 | name: "Dark theme / 12 hours / CTRL + ENTER disabled", 29 | props: { is12hours: true, theme: "dark", isCtrlEnterToSend: false }, 30 | }, 31 | { 32 | name: "Default theme / 24 hours / CTRL + ENTER disabled", 33 | props: { is12hours: false, theme: "default", isCtrlEnterToSend: false }, 34 | }, 35 | { 36 | name: "Default theme / 12 hours / CTRL + ENTER enabled", 37 | props: { is12hours: true, theme: "default", isCtrlEnterToSend: true }, 38 | }, 39 | ], 40 | }, 41 | { 42 | icon: "globe", 43 | title: "Locale", 44 | components: [TargemProvider], 45 | params: localeParams, 46 | }, 47 | ]; 48 | -------------------------------------------------------------------------------- /src/utils/gstate/gstate.ts: -------------------------------------------------------------------------------- 1 | export interface EmptyEvent { 2 | type: E; 3 | payload?: unknown; 4 | } 5 | 6 | export interface GMachine< 7 | T extends string, 8 | E extends string, 9 | Ev extends EmptyEvent 10 | > { 11 | value: T; 12 | transition: (currentState: T, event: Ev) => T; 13 | } 14 | 15 | export type MachineOptions< 16 | T extends string, 17 | E extends string, 18 | Ev extends EmptyEvent 19 | > = { 20 | [stateName in T]: { 21 | actions?: { 22 | onEnter?: (event: Ev) => void; 23 | onExit?: (event: Ev) => void; 24 | }; 25 | transitions: Partial< 26 | { 27 | [eventName in E]: { 28 | target: T; 29 | action?: (event: Ev) => void; 30 | }; 31 | } 32 | >; 33 | }; 34 | } & { 35 | initialState: T; 36 | }; 37 | 38 | export const createMachine = < 39 | T extends string, 40 | E extends string, 41 | Ev extends EmptyEvent 42 | >( 43 | definition: MachineOptions 44 | ): GMachine => { 45 | const machine = { 46 | value: definition.initialState, 47 | transition: (currentState: T, event: Ev) => { 48 | const currStateDefinition = definition[currentState]; 49 | const destTransition = currStateDefinition.transitions[event.type]; 50 | if (!destTransition) { 51 | return machine.value; 52 | } 53 | const destState = destTransition.target; 54 | machine.value = destState; 55 | destTransition.action?.(event); 56 | currStateDefinition.actions?.onExit?.(event); 57 | definition[destState].actions?.onEnter?.(event); 58 | return machine.value; 59 | }, 60 | }; 61 | return machine; 62 | }; 63 | -------------------------------------------------------------------------------- /src/components/ui-kit/Typography/Typography.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Typography from "./Typography"; 3 | import { Link } from "react-router-dom"; 4 | 5 | export default { title: "components/ui-kit/Typography", component: Typography }; 6 | 7 | export const withP = (): React.ReactNode => ( 8 | Hello, world! 9 | ); 10 | 11 | export const withPWithoutGutterBottom = (): React.ReactNode => ( 12 | Hello, world! 13 | ); 14 | 15 | export const withSmallSize = (): React.ReactNode => ( 16 | Hello, world! 17 | ); 18 | 19 | export const withH1 = (): React.ReactNode => ( 20 | Hello, world! 21 | ); 22 | 23 | export const withH2 = (): React.ReactNode => ( 24 | Hello, world! 25 | ); 26 | 27 | export const withH3 = (): React.ReactNode => ( 28 | Hello, world! 29 | ); 30 | 31 | export const withStylesPassed = (): React.ReactNode => ( 32 | Hello, world! 33 | ); 34 | 35 | export const withMutedColor = (): React.ReactNode => ( 36 | Hello, world! 37 | ); 38 | 39 | export const withContrastColor = (): React.ReactNode => ( 40 |
41 | Hello, world! 42 |
43 | ); 44 | 45 | export const withDangerColor = (): React.ReactNode => ( 46 | Hello, world! 47 | ); 48 | 49 | export const withLink = (): React.ReactNode => ( 50 | 51 | Hello, world! 52 | 53 | ); 54 | -------------------------------------------------------------------------------- /src/wrappers/AppWrapper/AppWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | SettingsContextProvider, 4 | withSettings, 5 | WithSettings, 6 | } from "src/contexts/SettingsContext"; 7 | import { ChatContextProvider } from "src/contexts/ChatContext"; 8 | import StorybookSharedWrapper from "src/wrappers/StorybookSharedWrapper"; 9 | import { TargemProvider } from "react-targem"; 10 | 11 | // translation.json file is autogenerated and ignored 12 | // so we use require() to prevent tsc compile time errors before webpack is first run 13 | // eslint-disable-next-line @typescript-eslint/no-var-requires 14 | const translationsJson = require("src/i18n/translations.json"); 15 | 16 | interface AppWrapperProps { 17 | children: React.ReactChild; 18 | } 19 | 20 | interface AppWrapperInternalProps extends WithSettings, AppWrapperProps {} 21 | 22 | class AppWrapperInternal extends React.PureComponent { 23 | render(): React.ReactNode { 24 | const { lang, username, userId } = this.props; 25 | 26 | return ( 27 | 32 | 33 | {this.props.children} 34 | 35 | 36 | ); 37 | } 38 | } 39 | 40 | const AppWrapperInternalWithSettings = withSettings(AppWrapperInternal); 41 | 42 | class AppWrapper extends React.PureComponent { 43 | render(): React.ReactNode { 44 | return ( 45 | 46 | 47 | {this.props.children} 48 | 49 | 50 | ); 51 | } 52 | } 53 | 54 | export default AppWrapper; 55 | -------------------------------------------------------------------------------- /src/components/ui-kit/Typography/Typography.tsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import classes from "./Typography.css"; 3 | import cn from "clsx"; 4 | 5 | interface TypographyProps { 6 | variant: "p" | "h1" | "h2" | "h3" | "label"; // Inlined to be displayed in Storybook Docs 7 | gutterBottom: boolean; 8 | size: "small" | "normal"; 9 | color: "normal" | "muted" | "contrast" | "danger"; 10 | children: React.ReactNode; 11 | className?: string; 12 | style?: React.CSSProperties; 13 | htmlFor?: string; 14 | id?: string; 15 | } 16 | 17 | /** A component to be used as a drop-in replacement for `

`, `

`, `

`, `

` */ 18 | class Typography extends PureComponent { 19 | public static defaultProps = { 20 | variant: "p", 21 | color: "normal", 22 | gutterBottom: true, 23 | size: "normal", 24 | }; 25 | 26 | render(): React.ReactNode { 27 | const { 28 | variant, 29 | children, 30 | style, 31 | size, 32 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 33 | gutterBottom, 34 | ...otherProps 35 | } = this.props; 36 | const Component = variant; 37 | 38 | return ( 39 | 40 | {size === "small" ? {children} : children} 41 | 42 | ); 43 | } 44 | 45 | private getClassName = (): string => { 46 | const { variant, className, color, gutterBottom } = this.props; 47 | return cn(className, classes.common, { 48 | [classes.p]: variant === "p", 49 | [classes.h1]: variant === "h1", 50 | [classes.h2]: variant === "h2", 51 | [classes.h3]: variant === "h3", 52 | [classes.colorMuted]: color === "muted", 53 | [classes.colorContrast]: color === "contrast", 54 | [classes.colorDanger]: color === "danger", 55 | [classes.gutterBottom]: gutterBottom, 56 | }); 57 | }; 58 | } 59 | 60 | export default Typography; 61 | -------------------------------------------------------------------------------- /src/pages/Chat/components/ChatInput/ChatInput.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ChatInput from "./ChatInput"; 3 | import { render, fireEvent } from "__utils__/render"; 4 | 5 | const changeEvent = { 6 | target: { 7 | value: "Foooo", 8 | }, 9 | }; 10 | 11 | const ctrlEnterEvent = { 12 | ctrlKey: true, 13 | keyCode: 13, 14 | }; 15 | 16 | it("should fire onSubmit after send button being clicked", () => { 17 | const onSubmitSpy = jest.fn(); 18 | const { getByLabelText, getByTitle } = render( 19 | 20 | ); 21 | const placeholder = getByLabelText("Message contents"); 22 | fireEvent.change(placeholder, changeEvent); 23 | const button = getByTitle("Send!"); 24 | fireEvent.click(button); 25 | expect(onSubmitSpy).toBeCalledTimes(1); 26 | expect(onSubmitSpy).toBeCalledWith("Foooo"); 27 | }); 28 | 29 | it("should fire onSubmit after CTRL + ENTER being pressed", async () => { 30 | const onSubmitSpy = jest.fn(); 31 | const { getByLabelText } = render( 32 | 33 | ); 34 | const placeholder = getByLabelText("Message contents"); 35 | fireEvent.change(placeholder, changeEvent); 36 | 37 | fireEvent.keyPress(document, ctrlEnterEvent); 38 | expect(onSubmitSpy).toBeCalledTimes(1); 39 | expect(onSubmitSpy).toBeCalledWith("Foooo"); 40 | }); 41 | 42 | /** 43 | * There was a bug in Chrome, https://bugs.chromium.org/p/chromium/issues/detail?id=79407 44 | */ 45 | it("should fire onSubmit after CTRL + ENTER being pressed (old chrome)", async () => { 46 | const onSubmitSpy = jest.fn(); 47 | const { getByLabelText } = render( 48 | 49 | ); 50 | const placeholder = getByLabelText("Message contents"); 51 | fireEvent.change(placeholder, changeEvent); 52 | 53 | fireEvent.keyPress(document, { 54 | ...ctrlEnterEvent, 55 | keyCode: 10, 56 | }); 57 | expect(onSubmitSpy).toBeCalledTimes(1); 58 | expect(onSubmitSpy).toBeCalledWith("Foooo"); 59 | }); 60 | -------------------------------------------------------------------------------- /src/pages/Chat/components/ChatMessage/ChatMessage.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | width: 100%; 4 | flex-direction: column; 5 | padding: 0.4rem 0.8rem; 6 | 7 | --triangle-size: 14px; 8 | --background-color: var(--color-primary-lighter); 9 | } 10 | 11 | .is-current-search.container { 12 | --background-color: var(--color-secondary-lighter); 13 | } 14 | 15 | .bubble-container { 16 | display: flex; 17 | width: 100%; 18 | } 19 | 20 | .inbox .bubble-container { 21 | flex-direction: row; 22 | } 23 | 24 | .outbox .bubble-container { 25 | flex-direction: row-reverse; 26 | } 27 | 28 | .username { 29 | padding: 0.2rem var(--triangle-size); 30 | } 31 | 32 | .outbox .username { 33 | align-self: flex-end; 34 | } 35 | 36 | .bubble { 37 | max-width: 60%; 38 | background-color: var(--background-color); 39 | word-break: break-word; 40 | } 41 | 42 | .bubble-content { 43 | padding: 0.8rem 1.6rem; 44 | } 45 | 46 | .triangle { 47 | border-top: var(--triangle-size) solid var(--background-color); 48 | } 49 | 50 | [dir="ltr"] .inbox .bubble, 51 | [dir="rtl"] .outbox .bubble { 52 | border-radius: 0 var(--border-radius) var(--border-radius) 53 | var(--border-radius); 54 | } 55 | 56 | [dir="ltr"] .outbox .bubble, 57 | [dir="rtl"] .inbox .bubble { 58 | border-radius: var(--border-radius) 0 var(--border-radius) 59 | var(--border-radius); 60 | } 61 | 62 | [dir="ltr"] .inbox .triangle, 63 | [dir="rtl"] .outbox .triangle { 64 | border-left: 10px solid transparent; 65 | border-top-left-radius: 2px; 66 | } 67 | 68 | [dir="ltr"] .outbox .triangle, 69 | [dir="rtl"] .inbox .triangle { 70 | border-right: 10px solid transparent; 71 | border-top-right-radius: 2px; 72 | } 73 | 74 | .date { 75 | align-self: flex-end; 76 | margin-right: 1rem; 77 | margin-left: 1rem; 78 | } 79 | 80 | .image { 81 | width: 100%; 82 | max-width: 100%; 83 | max-height: 200px; 84 | object-fit: cover; 85 | object-position: 50% 0; 86 | } 87 | 88 | .youtube-container { 89 | position: relative; 90 | height: 0; 91 | padding-top: 25px; 92 | padding-bottom: 56.25% /* 16:9 */; 93 | } 94 | 95 | .youtube { 96 | position: absolute; 97 | top: 0; 98 | left: 0; 99 | width: 100%; 100 | height: 100%; 101 | } 102 | -------------------------------------------------------------------------------- /src/services/ChatService/README.md: -------------------------------------------------------------------------------- 1 | # Chat Service 2 | 3 | > Library agnostic Chat Service. 4 | 5 | ### Specification 6 | 7 | - Each message contains a randomly generated `userId` 8 | - If there is no internet connection, sent messages are displayed (without a checkmark) and sent after the connection is reinitialized. 9 | - User is able to change the username, but old messages will not be updated 10 | - Chat has optimistic UI updates 11 | - `userId` can only be changed by resetting the settings or cleaning local storage 12 | 13 | ### Frontend 14 | 15 | This module export `ChatService` which can be used with any frontend library: React, Vue, AngularJS and etc. 16 | 17 | In a React application, it can be connected to a React _smart_ container, which should store chat messages in its' state. Or it can be connected to a global context if data is needed in several places across the application. 18 | 19 | `ChatAdapter` is responsible for the connection to a backend and transforming messages to our schema. By separating this responsibility we will have an ability to replace this backend socket implementation to any other. 20 | 21 | `ChatService` is responsible for this application's business logic layer and storing full messages list in-memory. 22 | 23 | ### Backend 24 | 25 | A server has the following API. 26 | 27 | ##### Send message 28 | 29 | ```typescript 30 | const content: { 31 | id: string; 32 | userId: string; 33 | text: string; 34 | username: string; 35 | createdAt: string; // ISO 8601 36 | status: "none"; 37 | } = { ... }; 38 | await chatIO.emit("message", content, cb); 39 | ``` 40 | 41 | ##### List messages 42 | 43 | ```typescript 44 | const Content: { 45 | id: string; 46 | userId: string; 47 | text: string; 48 | username: string; 49 | createdAt: string; // ISO 8601 50 | status: "none"; 51 | } = { ... }; 52 | await chatIO.emit("listMessages", (err?: Error, data: { items: Content[]} ) => { ... }); 53 | ``` 54 | 55 | ##### Recieve message 56 | 57 | ```typescript 58 | const Content: { 59 | id: string; 60 | userId: string; 61 | text: string; 62 | username: string; 63 | createdAt: string; // ISO 8601 64 | status: "none"; 65 | } = { ... }; 66 | chatIO.on("message", (data: Content) => { ... }); 67 | ``` 68 | 69 | ### TODO 70 | 71 | - [ ] Pagination 72 | - [ ] Authorization 73 | - [ ] Seen marks 74 | -------------------------------------------------------------------------------- /src/pages/Settings/SettingsPage.container.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import SettingsPageContainer from "./SettingsPage.container"; 3 | import { render, fireEvent } from "__utils__/renderWithRouter"; 4 | import { withRouter } from "react-router-dom"; 5 | 6 | const Container = withRouter(SettingsPageContainer); 7 | 8 | const resetDefaultsButtonText = "Reset to defaults"; 9 | 10 | it("should call reset settings method when 'Reset to defaults' is clicked", () => { 11 | const resetSettingsSpy = jest.fn(); 12 | const { getByText } = render(); 13 | const button = getByText(resetDefaultsButtonText); 14 | fireEvent.click(button); 15 | 16 | expect(resetSettingsSpy).toBeCalledTimes(1); 17 | }); 18 | 19 | it("should call set settings method when username field is changed", () => { 20 | const setSettingsSpy = jest.fn(); 21 | const { getByLabelText } = render(); 22 | const input = getByLabelText("Username"); 23 | fireEvent.change(input, { 24 | target: { 25 | value: "foo", 26 | }, 27 | }); 28 | 29 | expect(setSettingsSpy).toBeCalledWith({ username: "foo" }); 30 | }); 31 | 32 | it("should change language when language dropdown is changed", () => { 33 | const setSettingsSpy = jest.fn(); 34 | const { getByLabelText } = render(); 35 | 36 | const select = getByLabelText("Language"); 37 | fireEvent.change(select, { 38 | target: { 39 | value: "ru", 40 | }, 41 | }); 42 | 43 | expect(setSettingsSpy).toBeCalledWith({ lang: "ru" }); 44 | }); 45 | 46 | it("should change theme when theme radio is changed", () => { 47 | const setSettingsSpy = jest.fn(); 48 | const { getByLabelText } = render(); 49 | 50 | const radio = getByLabelText("Dark"); 51 | fireEvent.click(radio); 52 | 53 | expect(setSettingsSpy).toBeCalledWith({ theme: "dark" }); 54 | }); 55 | 56 | it("should change clock settings when clock settings radio is changed", () => { 57 | const setSettingsSpy = jest.fn(); 58 | const { getByLabelText } = render(); 59 | 60 | const radio = getByLabelText("12 hours"); 61 | fireEvent.click(radio); 62 | 63 | expect(setSettingsSpy).toBeCalledWith({ is12hours: true }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/components/ui-kit/Input/Input.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Input from "./Input"; 3 | import { action } from "@storybook/addon-actions"; 4 | 5 | export default { title: "components/ui-kit/Input", component: Input }; 6 | 7 | const handleChange = action("onClick"); 8 | 9 | const defaultProps = { 10 | id: "story", 11 | type: "text", 12 | component: "input", 13 | onChange: handleChange, 14 | } as const; 15 | 16 | export const withInput = (): React.ReactNode => ; 17 | 18 | export const withInputAddonRight = (): React.ReactNode => ( 19 | ...

} /> 20 | ); 21 | 22 | export const withInputNumber = (): React.ReactNode => ( 23 | 24 | ); 25 | 26 | export const withInputRadio = (): React.ReactNode => ( 27 | 28 | ); 29 | 30 | export const withInputCheckbox = (): React.ReactNode => ( 31 | 32 | ); 33 | 34 | export const withLabelledInput = (): React.ReactNode => ( 35 | 36 | ); 37 | 38 | export const withInputAndPlaceholder = (): React.ReactNode => ( 39 | 40 | ); 41 | 42 | const defaultTextAreaProps = { 43 | id: "story", 44 | component: "textarea", 45 | onChange: handleChange, 46 | } as const; 47 | 48 | export const withTextarea = (): React.ReactNode => ( 49 | 50 | ); 51 | 52 | export const withLabelledTextarea = (): React.ReactNode => ( 53 | 54 | ); 55 | 56 | export const withTextareaAndPlaceholder = (): React.ReactNode => ( 57 | 58 | ); 59 | 60 | const defaultSelectProps = { 61 | id: "story", 62 | component: "select", 63 | onChange: handleChange, 64 | } as const; 65 | 66 | export const withSelect = (): React.ReactNode => ( 67 | 68 | 69 | 70 | 71 | ); 72 | 73 | export const withLabelledSelect = (): React.ReactNode => ( 74 | 75 | 76 | 77 | 78 | ); 79 | -------------------------------------------------------------------------------- /src/components/ui-kit/RadioGroup/RadioGroup.tsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import classes from "./RadioGroup.css"; 3 | import cn from "clsx"; 4 | import Typography from "src/components/ui-kit/Typography"; 5 | import Input from "src/components/ui-kit/Input"; 6 | 7 | interface RadioGroupProps

{ 8 | labelledWith: React.ReactNode; 9 | options: { 10 | value: P; 11 | text: React.ReactNode; 12 | }[]; 13 | onChange: (value: P) => void; 14 | value: P; 15 | id: string; 16 | className?: string; 17 | } 18 | 19 | interface StringLike { 20 | toString: () => string; 21 | } 22 | 23 | class RadioGroup

extends PureComponent< 24 | RadioGroupProps

25 | > { 26 | render(): React.ReactNode { 27 | const { 28 | className, 29 | id, 30 | labelledWith, 31 | options, 32 | onChange, 33 | value, 34 | } = this.props; 35 | 36 | const titleId = `${id.toString()}-title`; 37 | 38 | return ( 39 |

44 | {labelledWith ? ( 45 | {labelledWith} 46 | ) : null} 47 |
48 | {options.map((o) => ( 49 | 57 | ))} 58 |
59 |
60 | ); 61 | } 62 | } 63 | 64 | interface RadioGroupItemProps

{ 65 | value: P; 66 | text: React.ReactNode; 67 | activeValue: P; 68 | onChange: (value: P) => void; 69 | id: string; 70 | } 71 | 72 | class RadioGroupItem

extends PureComponent< 73 | RadioGroupItemProps

74 | > { 75 | render(): React.ReactNode { 76 | const { activeValue, id, value, text } = this.props; 77 | 78 | return ( 79 | 89 | ); 90 | } 91 | 92 | private handleChange = (): void => { 93 | this.props.onChange(this.props.value); 94 | }; 95 | } 96 | 97 | export default RadioGroup; 98 | -------------------------------------------------------------------------------- /src/components/ui-kit/NavBar/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import classes from "./NavBar.css"; 3 | import cn from "clsx"; 4 | import { NavLink } from "react-router-dom"; 5 | import Typography from "../Typography"; 6 | 7 | interface NavBarProps { 8 | children: React.ReactNode; 9 | } 10 | 11 | export class NavBar extends PureComponent { 12 | render(): React.ReactNode { 13 | return

{this.props.children}
; 14 | } 15 | } 16 | 17 | interface NavBarItemProps { 18 | text: React.ReactNode; 19 | to: string; 20 | badge?: number; 21 | isBlinking: boolean; 22 | } 23 | 24 | interface NavBarItemState { 25 | isDark: boolean; 26 | } 27 | 28 | export class NavBarItem extends PureComponent< 29 | NavBarItemProps, 30 | NavBarItemState 31 | > { 32 | static defaultProps: Partial = { 33 | isBlinking: false, 34 | }; 35 | 36 | public blinkingInverval?: NodeJS.Timeout; 37 | 38 | public state: NavBarItemState = { 39 | isDark: false, 40 | }; 41 | 42 | public componentDidMount(): void { 43 | if (this.props.isBlinking) { 44 | this.startBlinking(); 45 | } 46 | } 47 | 48 | public componentDidUpdate(prevProps: NavBarItemProps): void { 49 | if (prevProps.isBlinking === this.props.isBlinking) { 50 | return; 51 | } 52 | if (this.props.isBlinking) { 53 | this.startBlinking(); 54 | return; 55 | } 56 | this.stopBlinking(); 57 | } 58 | 59 | public componentWillUnmount(): void { 60 | this.stopBlinking(); 61 | } 62 | 63 | render(): React.ReactNode { 64 | const { badge, text, to } = this.props; 65 | const { isDark } = this.state; 66 | 67 | return ( 68 | 76 | {text} 77 | {badge ? ( 78 | 79 | {" "} 80 | {badge} 81 | 82 | ) : null} 83 | 84 | ); 85 | } 86 | 87 | private startBlinking = () => { 88 | this.blinkingInverval = setInterval(() => { 89 | this.setState((state: NavBarItemState) => ({ 90 | isDark: !state.isDark, 91 | })); 92 | }, 1000); 93 | }; 94 | 95 | private stopBlinking = () => { 96 | if (this.blinkingInverval) { 97 | clearInterval(this.blinkingInverval); 98 | } 99 | this.setState({ 100 | isDark: false, 101 | }); 102 | }; 103 | } 104 | -------------------------------------------------------------------------------- /src/utils/gstate/gstate.spec.ts: -------------------------------------------------------------------------------- 1 | import { createMachine, GMachine, EmptyEvent } from "./gstate"; 2 | 3 | type MachineStates = "off" | "on"; 4 | type MachineEvents = "switch"; 5 | 6 | interface SwitchEvent extends EmptyEvent { 7 | type: "switch"; 8 | } 9 | 10 | let machine: GMachine; 11 | 12 | let transitionActionSpy: jest.Mock; 13 | let onEnterSpy: jest.Mock; 14 | let onExitSpy: jest.Mock; 15 | 16 | beforeEach(() => { 17 | transitionActionSpy = jest.fn(); 18 | onEnterSpy = jest.fn(); 19 | onExitSpy = jest.fn(); 20 | machine = createMachine({ 21 | off: { 22 | transitions: { 23 | switch: { 24 | target: "on", 25 | action: transitionActionSpy, 26 | }, 27 | }, 28 | actions: { 29 | onEnter: onEnterSpy, 30 | onExit: onExitSpy, 31 | }, 32 | }, 33 | on: { 34 | transitions: { 35 | switch: { 36 | target: "off", 37 | action: transitionActionSpy, 38 | }, 39 | }, 40 | actions: { 41 | onEnter: onEnterSpy, 42 | onExit: onExitSpy, 43 | }, 44 | }, 45 | initialState: "off", 46 | }); 47 | }); 48 | 49 | it("should use initialState as default value", () => { 50 | expect(machine.value).toBe("off"); 51 | }); 52 | 53 | it("should contain transiton function which returns value", () => { 54 | expect(machine.transition("off", { type: "switch" })).toBe("on"); 55 | }); 56 | 57 | it("should switch the state", () => { 58 | let state = machine.value; 59 | state = machine.transition(state, { type: "switch" }); 60 | expect(state).toBe("on"); 61 | state = machine.transition(state, { type: "switch" }); 62 | expect(state).toBe("off"); 63 | }); 64 | 65 | it("should fire action", () => { 66 | let state = machine.value; 67 | state = machine.transition(state, { type: "switch" }); 68 | expect(transitionActionSpy).toBeCalledTimes(1); 69 | expect(transitionActionSpy).toBeCalledWith({ type: "switch" }); 70 | machine.transition(state, { type: "switch" }); 71 | expect(transitionActionSpy).toBeCalledTimes(2); 72 | }); 73 | 74 | it("should fire onEnter", () => { 75 | let state = machine.value; 76 | state = machine.transition(state, { type: "switch" }); 77 | expect(onEnterSpy).toBeCalledTimes(1); 78 | expect(onEnterSpy).toBeCalledWith({ type: "switch" }); 79 | machine.transition(state, { type: "switch" }); 80 | expect(onEnterSpy).toBeCalledTimes(2); 81 | }); 82 | 83 | it("should fire onExit", () => { 84 | let state = machine.value; 85 | state = machine.transition(state, { type: "switch" }); 86 | expect(onExitSpy).toBeCalledTimes(1); 87 | expect(onExitSpy).toBeCalledWith({ type: "switch" }); 88 | machine.transition(state, { type: "switch" }); 89 | expect(onExitSpy).toBeCalledTimes(2); 90 | }); 91 | -------------------------------------------------------------------------------- /src/pages/Chat/components/ChatInput/ChatInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import classes from "./ChatInput.css"; 3 | import cn from "clsx"; 4 | import Button from "src/components/ui-kit/Button"; 5 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 6 | import { faEnvelope } from "@fortawesome/free-solid-svg-icons"; 7 | import { withLocale, WithLocale } from "react-targem"; 8 | import Input from "src/components/ui-kit/Input"; 9 | 10 | export interface ChatInputPureProps { 11 | onSubmit: (message: string) => void; 12 | } 13 | 14 | interface ChatInputProps extends ChatInputPureProps, WithLocale { 15 | isCtrlEnterToSend: boolean; 16 | } 17 | 18 | interface ChatInputState { 19 | message: string; 20 | } 21 | 22 | /** 23 | * Custom `Input` component to be used as a drop-in replacement for `