Initial commit
8
.env.dev.example
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
INLINE_RUNTIME_CHUNK=true
|
||||||
|
GENERATE_SOURCEMAP=true
|
||||||
|
|
||||||
|
REACT_APP_ENABLE_REDUX_DEV_TOOLS=false
|
||||||
|
REACT_APP_PLATFORM=dev
|
||||||
|
REACT_APP_PERSISTENCE_DRIVER=localstorage
|
||||||
|
REACT_APP_GOOGLE_ANALYTICS_ID=null
|
||||||
|
REACT_APP_SENTRY_DSN=null
|
8
.env.prod.chrome.example
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
INLINE_RUNTIME_CHUNK=false
|
||||||
|
GENERATE_SOURCEMAP=false
|
||||||
|
|
||||||
|
REACT_APP_ENABLE_REDUX_DEV_TOOLS=false
|
||||||
|
REACT_APP_PLATFORM=chrome
|
||||||
|
REACT_APP_PERSISTENCE_DRIVER=localstorage
|
||||||
|
REACT_APP_GOOGLE_ANALYTICS_ID=
|
||||||
|
REACT_APP_SENTRY_DSN=null
|
8
.env.prod.electron.example
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
INLINE_RUNTIME_CHUNK=false
|
||||||
|
GENERATE_SOURCEMAP=false
|
||||||
|
|
||||||
|
REACT_APP_ENABLE_REDUX_DEV_TOOLS=false
|
||||||
|
REACT_APP_PLATFORM=electron
|
||||||
|
REACT_APP_PERSISTENCE_DRIVER=localstorage
|
||||||
|
REACT_APP_GOOGLE_ANALYTICS_ID=
|
||||||
|
REACT_APP_SENTRY_DSN=null
|
12
.env.prod.sentry.example
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
INLINE_RUNTIME_CHUNK=true
|
||||||
|
GENERATE_SOURCEMAP=true
|
||||||
|
|
||||||
|
REACT_APP_ENABLE_REDUX_DEV_TOOLS=false
|
||||||
|
REACT_APP_PLATFORM=web
|
||||||
|
REACT_APP_PERSISTENCE_DRIVER=localstorage
|
||||||
|
REACT_APP_GOOGLE_ANALYTICS_ID=
|
||||||
|
REACT_APP_SENTRY_DSN=
|
||||||
|
|
||||||
|
SENTRY_DSN=
|
||||||
|
SENTRY_ORG=
|
||||||
|
SENTRY_PROJECT=websocketking
|
8
.env.prod.web.example
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
INLINE_RUNTIME_CHUNK=true
|
||||||
|
GENERATE_SOURCEMAP=false
|
||||||
|
|
||||||
|
REACT_APP_ENABLE_REDUX_DEV_TOOLS=false
|
||||||
|
REACT_APP_PLATFORM=web
|
||||||
|
REACT_APP_PERSISTENCE_DRIVER=localstorage
|
||||||
|
REACT_APP_GOOGLE_ANALYTICS_ID=
|
||||||
|
REACT_APP_SENTRY_DSN=
|
69
.eslintrc.js
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
module.exports = {
|
||||||
|
extends: [
|
||||||
|
'airbnb-typescript',
|
||||||
|
],
|
||||||
|
parserOptions: {
|
||||||
|
project: './tsconfig.json',
|
||||||
|
},
|
||||||
|
ignorePatterns: [
|
||||||
|
'**/*.js',
|
||||||
|
'src/stories/*.tsx',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'jsx-a11y/label-has-associated-control': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
required: {
|
||||||
|
some: [
|
||||||
|
'nesting',
|
||||||
|
'id',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
controlComponents: [
|
||||||
|
'AutosizeInput',
|
||||||
|
'Editor',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'import/no-extraneous-dependencies': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
devDependencies: [
|
||||||
|
'.storybook/**',
|
||||||
|
'**/*.stories.tsx',
|
||||||
|
'**/*.test.ts',
|
||||||
|
'**/*.test.tsx',
|
||||||
|
'src/tests/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'import/prefer-default-export': 'off',
|
||||||
|
},
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
'src/redux/migrations/*.ts',
|
||||||
|
'src/redux/reducers/*.ts',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'no-param-reassign': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
'src/redux/migrations.ts',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'global-require': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
'src/**/*.stories.tsx',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'react/jsx-props-no-spreading': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
26
.gitignore
vendored
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.dev
|
||||||
|
.env.prod.chrome
|
||||||
|
.env.prod.web
|
||||||
|
.env.prod.sentry
|
||||||
|
.env.prod.electron
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
chrome-build.zip
|
12
.storybook/decorators/ContextMenuDecorator.tsx
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { ContextMenuProvider } from '../../src/providers/ContextMenuProvider';
|
||||||
|
|
||||||
|
export default function ContextMenuDecorator(
|
||||||
|
component: any,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<ContextMenuProvider>
|
||||||
|
{component()}
|
||||||
|
</ContextMenuProvider>
|
||||||
|
)
|
||||||
|
}
|
12
.storybook/decorators/DropdownMenuDecorator.tsx
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { DropdownMenuProvider } from '../../src/providers/DropdownMenuProvider';
|
||||||
|
|
||||||
|
export default function DropdownMenuDecorator(
|
||||||
|
component: any,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuProvider>
|
||||||
|
{component()}
|
||||||
|
</DropdownMenuProvider>
|
||||||
|
)
|
||||||
|
}
|
20
.storybook/decorators/FormikDecorator.tsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { Formik } from 'formik';
|
||||||
|
|
||||||
|
export interface FormikDecoratorProps {
|
||||||
|
initialValues: { [key: string]: any },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FormikDecorator(
|
||||||
|
component: any,
|
||||||
|
{ args }: any,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={args.initialValues}
|
||||||
|
onSubmit={() => { }}
|
||||||
|
>
|
||||||
|
{component()}
|
||||||
|
</Formik>
|
||||||
|
)
|
||||||
|
}
|
12
.storybook/main.js
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
module.exports = {
|
||||||
|
'stories': [
|
||||||
|
'../src/**/*.stories.mdx',
|
||||||
|
'../src/**/*.stories.@(js|jsx|ts|tsx)'
|
||||||
|
],
|
||||||
|
'addons': [
|
||||||
|
'@storybook/addon-links',
|
||||||
|
'@storybook/addon-essentials',
|
||||||
|
'@storybook/preset-create-react-app',
|
||||||
|
'@storybook/addon-controls',
|
||||||
|
]
|
||||||
|
};
|
16
.storybook/preview.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { GlobalStyles as TwinGlobalStyles } from 'twin.macro';
|
||||||
|
import GlobalStyles from '../src/components/General/Styled/GlobalStyles';
|
||||||
|
|
||||||
|
export const parameters = {
|
||||||
|
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decorators = [
|
||||||
|
(Story) => (
|
||||||
|
<>
|
||||||
|
<TwinGlobalStyles />
|
||||||
|
<GlobalStyles backgroundColor="white" />
|
||||||
|
<Story />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
];
|
46
README.md
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
# Websocket King
|
||||||
|
|
||||||
|
- sometimes window is not pickedup on refresh\
|
||||||
|
- migrations
|
||||||
|
|
||||||
|
## About
|
||||||
|
|
||||||
|
WebSocket King is a a tool designed to assist in developing and debugging WebSocket connections.
|
||||||
|
|
||||||
|
- Website - https://websocketking.com
|
||||||
|
- Chrome Extension - https://chrome.google.com/webstore/detail/cbcbkhdmedgianpaifchdaddpnmgnknn
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
1. Clone project
|
||||||
|
2. `cp .env.dev.example .env.dev`
|
||||||
|
3. `yarn start`
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
### `yarn build:chrome`
|
||||||
|
|
||||||
|
Builds the app for production (Chrome Extension) to the `build` folder.<br />
|
||||||
|
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
|
### `yarn build:web`
|
||||||
|
|
||||||
|
Builds the app for production (web) to the `build` folder.<br />
|
||||||
|
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
|
### `yarn build:electron`
|
||||||
|
|
||||||
|
BETA. Builds the app for production (Electron) to the `build` folder.<br />
|
||||||
|
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
|
### `yarn eject`
|
||||||
|
|
||||||
|
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||||
|
|
||||||
|
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||||
|
|
||||||
|
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||||
|
|
||||||
|
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
104
package.json
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
{
|
||||||
|
"name": "websocket-king",
|
||||||
|
"version": "4.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/browser": "^5.14.2",
|
||||||
|
"@testing-library/jest-dom": "^5.11.5",
|
||||||
|
"@testing-library/react": "^11.1.0",
|
||||||
|
"@testing-library/user-event": "^12.1.10",
|
||||||
|
"date-fns": "^2.8.1",
|
||||||
|
"env-cmd": "^10.0.1",
|
||||||
|
"formik": "^2.2.3",
|
||||||
|
"lodash": "^4.17.20",
|
||||||
|
"prismjs": "^1.22.0",
|
||||||
|
"react": "^17.0.1",
|
||||||
|
"react-copy-to-clipboard": "^5.0.2",
|
||||||
|
"react-dom": "^17.0.1",
|
||||||
|
"react-ga": "^3.2.0",
|
||||||
|
"react-icons": "^3.11.0",
|
||||||
|
"react-portal": "^4.2.1",
|
||||||
|
"react-redux": "^7.1.3",
|
||||||
|
"react-scripts": "4.0.0",
|
||||||
|
"react-simple-code-editor": "^0.11.0",
|
||||||
|
"redux": "^4.0.4",
|
||||||
|
"redux-thunk": "^2.3.0",
|
||||||
|
"styled-components": "^5.2.1",
|
||||||
|
"tailwindcss": "^2.0.1",
|
||||||
|
"twin.macro": "^2.0.5",
|
||||||
|
"typescript": "~4.0.5",
|
||||||
|
"use-resize-observer": "^7.0.0",
|
||||||
|
"uuid": "^8.3.1",
|
||||||
|
"yup": "^0.29.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.12.3",
|
||||||
|
"@storybook/addon-actions": "^6.1.2",
|
||||||
|
"@storybook/addon-controls": "^6.1.2",
|
||||||
|
"@storybook/addon-essentials": "^6.1.2",
|
||||||
|
"@storybook/addon-links": "^6.1.2",
|
||||||
|
"@storybook/node-logger": "^6.1.2",
|
||||||
|
"@storybook/preset-create-react-app": "^3.1.5",
|
||||||
|
"@storybook/react": "^6.1.2",
|
||||||
|
"@types/enzyme": "^3.10.8",
|
||||||
|
"@types/jest": "^26.0.15",
|
||||||
|
"@types/lodash": "^4.14.164",
|
||||||
|
"@types/node": "^14.14.5",
|
||||||
|
"@types/prismjs": "^1.16.2",
|
||||||
|
"@types/react": "^16.9.0",
|
||||||
|
"@types/react-copy-to-clipboard": "^4.3.0",
|
||||||
|
"@types/react-dom": "^16.9.0",
|
||||||
|
"@types/react-portal": "^4.0.2",
|
||||||
|
"@types/react-redux": "^7.1.5",
|
||||||
|
"@types/styled-components": "^5.1.4",
|
||||||
|
"@types/uuid": "^8.3.0",
|
||||||
|
"@types/yup": "^0.29.9",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^4.4.1",
|
||||||
|
"@wojtekmaj/enzyme-adapter-react-17": "^0.3.1",
|
||||||
|
"electron": "^10.1.3",
|
||||||
|
"enzyme": "^3.11.0",
|
||||||
|
"eslint-config-airbnb-typescript": "^12.0.0",
|
||||||
|
"eslint-plugin-import": "^2.22.0",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.3.1",
|
||||||
|
"eslint-plugin-react": "^7.20.3",
|
||||||
|
"eslint-plugin-react-hooks": "^4.0.8",
|
||||||
|
"react-is": "^17.0.1"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node_modules/.bin/env-cmd -f ./.env.dev react-scripts start",
|
||||||
|
"build:web": "node_modules/.bin/env-cmd -f ./.env.prod.web react-scripts build",
|
||||||
|
"build:chrome": "export INLINE_RUNTIME_CHUNK=false && node_modules/.bin/env-cmd -f ./.env.prod.chrome react-scripts build && node ./scripts/chrome.js",
|
||||||
|
"build:electron": "export INLINE_RUNTIME_CHUNK=false && node_modules/.bin/env-cmd -f ./.env.prod.electron react-scripts build && node ./scripts/electron.js",
|
||||||
|
"copy-env": "node ./scripts/copy-env",
|
||||||
|
"sentry:source-maps": "node ./scripts/sentry",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject",
|
||||||
|
"start:storybook": "start-storybook -p 6006 -s public",
|
||||||
|
"build:storybook": "build-storybook -s public"
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"babelMacros": {
|
||||||
|
"twin": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"preset": "styled-components",
|
||||||
|
"autoCssProp": true,
|
||||||
|
"dataTwProp": true,
|
||||||
|
"debugPlugins": false,
|
||||||
|
"debug": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"styled-components": "^5"
|
||||||
|
}
|
||||||
|
}
|
3
public-chrome/background.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
chrome.browserAction.onClicked.addListener(() => {
|
||||||
|
window.open(chrome.extension.getURL('index.html'));
|
||||||
|
});
|
38
public-chrome/manifest.json
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 2,
|
||||||
|
"name": "WebSocket King Client",
|
||||||
|
"short_name": "WebSocket King",
|
||||||
|
"version": "4.0",
|
||||||
|
"permissions": [
|
||||||
|
"unlimitedStorage"
|
||||||
|
],
|
||||||
|
"minimum_chrome_version": "50",
|
||||||
|
"background": {
|
||||||
|
"scripts": ["background.js"],
|
||||||
|
"persistent": false
|
||||||
|
},
|
||||||
|
"browser_action": {
|
||||||
|
"default_icon": {
|
||||||
|
"19": "images/logo19.png",
|
||||||
|
"38": "images/logo38.png"
|
||||||
|
},
|
||||||
|
"default_title": "WebSocket King"
|
||||||
|
},
|
||||||
|
"commands" : {
|
||||||
|
"_execute_browser_action": {
|
||||||
|
"suggested_key": {
|
||||||
|
"windows": "Alt+S",
|
||||||
|
"mac": "Alt+S",
|
||||||
|
"chromeos": "Alt+S",
|
||||||
|
"linux": "Alt+S"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "A WebSocket client for testing and debugging connections.",
|
||||||
|
"icons": {
|
||||||
|
"16": "images/logo16.png",
|
||||||
|
"32": "images/logo32.png",
|
||||||
|
"48": "images/logo48.png",
|
||||||
|
"128": "images/logo128.png"
|
||||||
|
}
|
||||||
|
}
|
43
public-electron/main.js
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
const { app, BrowserWindow } = require('electron');
|
||||||
|
|
||||||
|
function createWindow () {
|
||||||
|
// Create the browser window.
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// and load the index.html of the app.
|
||||||
|
win.loadFile('index.html');
|
||||||
|
|
||||||
|
// Open the DevTools.
|
||||||
|
win.webContents.openDevTools()
|
||||||
|
}
|
||||||
|
|
||||||
|
// This method will be called when Electron has finished
|
||||||
|
// initialization and is ready to create browser windows.
|
||||||
|
// Some APIs can only be used after this event occurs.
|
||||||
|
app.whenReady().then(createWindow);
|
||||||
|
|
||||||
|
// Quit when all windows are closed, except on macOS. There, it's common
|
||||||
|
// for applications and their menu bar to stay active until the user quits
|
||||||
|
// explicitly with Cmd + Q.
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
// On macOS it's common to re-create a window in the app when the
|
||||||
|
// dock icon is clicked and there are no other windows open.
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// In this file you can include the rest of your app's specific main process
|
||||||
|
// code. You can also put them in separate files and require them here.
|
11
public-electron/package.json
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"name": "websocket-king",
|
||||||
|
"version": "3.0",
|
||||||
|
"main": "main.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^10.1.3"
|
||||||
|
}
|
||||||
|
}
|
BIN
public/favicon.ico
Normal file
After Width: | Height: | Size: 107 KiB |
BIN
public/images/banner-large.png
Normal file
After Width: | Height: | Size: 78 KiB |
BIN
public/images/banner-small.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
public/images/logo128.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
public/images/logo16.png
Normal file
After Width: | Height: | Size: 586 B |
BIN
public/images/logo19.png
Normal file
After Width: | Height: | Size: 713 B |
BIN
public/images/logo192.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
public/images/logo32.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
public/images/logo38.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
public/images/logo48.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
public/images/logo512.png
Normal file
After Width: | Height: | Size: 68 KiB |
BIN
public/images/logo64.png
Normal file
After Width: | Height: | Size: 4 KiB |
BIN
public/images/screenshot-1.png
Normal file
After Width: | Height: | Size: 207 KiB |
BIN
public/images/screenshot-2.png
Normal file
After Width: | Height: | Size: 163 KiB |
20
public/index.html
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="A WebSocket client for designed for testing and debugging WebSocket connections."
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<title>WebSocket King client: A testing and debugging tool for WebSockets</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app. 😞</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
25
public/manifest.json
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"short_name": "WebSocket King",
|
||||||
|
"name": "WebSocket King client: A testing and debugging tool for WebSockets",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "images/logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "images/logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
1
public/robots.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
User-agent: *
|
17
scripts/chrome.js
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const execSync = require('child_process').execSync;
|
||||||
|
|
||||||
|
const workingDir = process.cwd();
|
||||||
|
const buildDir = `${ workingDir }/build`;
|
||||||
|
|
||||||
|
const removeFiles = ['robots.txt', 'manifest.json'];
|
||||||
|
|
||||||
|
removeFiles.forEach(
|
||||||
|
file => fs.unlinkSync(`${ buildDir }/${ file }`)
|
||||||
|
);
|
||||||
|
|
||||||
|
execSync('cp -r public-chrome/. build');
|
||||||
|
|
||||||
|
execSync('rm chrome-build.zip || true');
|
||||||
|
|
||||||
|
execSync('zip -vr chrome-build.zip build/* -x "*.DS_Store"');
|
6
scripts/copy-env.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
const execSync = require('child_process').execSync;
|
||||||
|
|
||||||
|
const fileName = process.argv[process.argv.length - 1];
|
||||||
|
|
||||||
|
execSync(`cp ${ fileName }.example ${ fileName }`);
|
||||||
|
|
15
scripts/electron.js
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const execSync = require('child_process').execSync;
|
||||||
|
|
||||||
|
const workingDir = process.cwd();
|
||||||
|
const buildDir = `${ workingDir }/build`;
|
||||||
|
|
||||||
|
const removeFiles = ['service-worker.js', 'robots.txt', 'manifest.json'];
|
||||||
|
|
||||||
|
removeFiles.forEach(
|
||||||
|
file => fs.unlinkSync(`${ buildDir }/${ file }`)
|
||||||
|
);
|
||||||
|
|
||||||
|
execSync('cp -r public-electron/. build');
|
||||||
|
execSync('cd build && yarn');
|
||||||
|
|
9
scripts/sentry.js
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
const execSync = require('child_process').execSync;
|
||||||
|
const version = require('../package.json').version;
|
||||||
|
|
||||||
|
execSync(`./node_modules/.bin/env-cmd -f ./.env.prod.sentry react-scripts build`, { stdio: 'inherit' });
|
||||||
|
execSync(`./node_modules/.bin/env-cmd -f ./.env.prod.sentry sentry-cli releases new websocket-king-${version}`, { stdio: 'inherit' });
|
||||||
|
execSync(`./node_modules/.bin/env-cmd -f ./.env.prod.sentry sentry-cli releases files websocket-king-${version} upload-sourcemaps --ext js --ext map ./build`, { stdio: 'inherit' });
|
||||||
|
execSync(`./node_modules/.bin/env-cmd -f ./.env.prod.sentry sentry-cli releases finalize websocket-king-${version}`, { stdio: 'inherit' });
|
||||||
|
|
||||||
|
|
39
src/App.tsx
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { GlobalStyles as TwinGlobalStyles } from 'twin.macro';
|
||||||
|
import Store from './redux/store';
|
||||||
|
import LayoutConnected from './LayoutConnected';
|
||||||
|
import { ContextMenuProvider } from './providers/ContextMenuProvider';
|
||||||
|
import { DropdownMenuProvider } from './providers/DropdownMenuProvider';
|
||||||
|
import { PopupProvider } from './providers/PopupProvider';
|
||||||
|
import GlobalStyles from './components/General/Styled/GlobalStyles';
|
||||||
|
import InitializeRedux from './bootstrap/InitializeRedux';
|
||||||
|
import NotificationsProvider from './providers/notifications/notifications.provider';
|
||||||
|
import InitializeAfterContext from './bootstrap/InitializeAfterContext';
|
||||||
|
import TourProvider from './providers/tour/tour.provider';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<React.StrictMode>
|
||||||
|
<Provider store={Store}>
|
||||||
|
<TwinGlobalStyles />
|
||||||
|
<GlobalStyles />
|
||||||
|
<InitializeRedux>
|
||||||
|
<ContextMenuProvider>
|
||||||
|
<DropdownMenuProvider>
|
||||||
|
<PopupProvider>
|
||||||
|
<TourProvider>
|
||||||
|
<NotificationsProvider>
|
||||||
|
<InitializeAfterContext>
|
||||||
|
<LayoutConnected />
|
||||||
|
</InitializeAfterContext>
|
||||||
|
</NotificationsProvider>
|
||||||
|
</TourProvider>
|
||||||
|
</PopupProvider>
|
||||||
|
</DropdownMenuProvider>
|
||||||
|
</ContextMenuProvider>
|
||||||
|
</InitializeRedux>
|
||||||
|
</Provider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
}
|
52
src/Layout.tsx
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import React from 'react';
|
||||||
|
import 'twin.macro';
|
||||||
|
import ConnectionsConnected from './components/Connections/ConnectionsConnected';
|
||||||
|
import SidebarConnected from './components/Sidebar/SidebarConnected';
|
||||||
|
import HeaderConnected from './components/Header/HeaderConnected';
|
||||||
|
import EmptyMessage from './components/General/Utilities/EmptyMessage';
|
||||||
|
|
||||||
|
export interface LayoutProps {
|
||||||
|
sidebarOpen: boolean,
|
||||||
|
projectOpen: boolean,
|
||||||
|
projectsExist: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout({
|
||||||
|
sidebarOpen,
|
||||||
|
projectOpen,
|
||||||
|
projectsExist,
|
||||||
|
}: LayoutProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div tw="flex flex-col h-full">
|
||||||
|
<div tw="flex-grow-0">
|
||||||
|
<HeaderConnected />
|
||||||
|
</div>
|
||||||
|
<div tw="flex flex-grow pb-2">
|
||||||
|
{projectOpen && (
|
||||||
|
<>
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div tw="pr-2 flex flex-grow min-w-xs max-w-lg w-1/3 lg:w-1/4">
|
||||||
|
<SidebarConnected />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ConnectionsConnected
|
||||||
|
paddingLeft={!sidebarOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(!projectOpen && !projectsExist) && (
|
||||||
|
<EmptyMessage heading="No Projects Exist">
|
||||||
|
Click the projects button in the top left to create one.
|
||||||
|
</EmptyMessage>
|
||||||
|
)}
|
||||||
|
{(!projectOpen && projectsExist) && (
|
||||||
|
<EmptyMessage heading="No Project Open">
|
||||||
|
Click the projects button in the top left to open one.
|
||||||
|
</EmptyMessage>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
14
src/LayoutConnected.tsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import Layout from './Layout';
|
||||||
|
import { isAnyProjectOpen } from './redux/selectors/projects';
|
||||||
|
import State from './redux/state';
|
||||||
|
|
||||||
|
function mapStateToProps(state: State) {
|
||||||
|
return {
|
||||||
|
projectOpen: isAnyProjectOpen(state),
|
||||||
|
projectsExist: Object.values(state.projects).length > 0,
|
||||||
|
sidebarOpen: isAnyProjectOpen(state) && state.userInterfaceProperties.SidebarOpen.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(Layout);
|
20
src/bootstrap/InitializeAfterContext.tsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import useInitializeChromeRatingPrompt from './hooks/useInitializeChromeRatingPrompt';
|
||||||
|
import useInitializeTourPrompt from './hooks/useInitializeTourPrompt';
|
||||||
|
|
||||||
|
export interface InitializeAfterContextProps {
|
||||||
|
children: ReactNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InitializeAfterContext({
|
||||||
|
children,
|
||||||
|
}: InitializeAfterContextProps) {
|
||||||
|
useInitializeChromeRatingPrompt();
|
||||||
|
useInitializeTourPrompt();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
50
src/bootstrap/InitializeRedux.tsx
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import 'twin.macro';
|
||||||
|
import { internalPropertiesAppIsReady } from '../redux/selectors/internal-properties';
|
||||||
|
import State from '../redux/state';
|
||||||
|
import useInitializeRunCount from './hooks/useInitializeRunCount';
|
||||||
|
import useInitializeWindowId from './hooks/useInitializeWindowId';
|
||||||
|
|
||||||
|
export interface InitializeReduxProps {
|
||||||
|
children: ReactNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InitializeRedux({
|
||||||
|
children,
|
||||||
|
}: InitializeReduxProps) {
|
||||||
|
const storeReady = useSelector<State, boolean>(
|
||||||
|
(state) => (
|
||||||
|
state.internalProperties !== null
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const reduxReady = useSelector<State, boolean>(
|
||||||
|
(state) => internalPropertiesAppIsReady(state),
|
||||||
|
);
|
||||||
|
|
||||||
|
useInitializeRunCount(storeReady);
|
||||||
|
useInitializeWindowId(storeReady);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!reduxReady && (
|
||||||
|
<div tw="flex flex-col h-full items-center justify-center">
|
||||||
|
<img
|
||||||
|
tw="w-16 h-16 animate-bounce"
|
||||||
|
srcSet={`
|
||||||
|
/images/logo128.png 2x,
|
||||||
|
/images/logo64.png 1x
|
||||||
|
`}
|
||||||
|
src="/images/logo64.png"
|
||||||
|
alt="WebSocket King logo"
|
||||||
|
/>
|
||||||
|
<p tw="my-4 font-semibold uppercase text-xs text-gray-600">
|
||||||
|
WebSocket King
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{reduxReady && children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
50
src/bootstrap/hooks/useInitializeChromeRatingPrompt.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import { useContext, useEffect } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import config from '../../config';
|
||||||
|
import isPlatform from '../../helpers/isPlatform';
|
||||||
|
import NotificationsActions from '../../providers/notifications/notifications.actions';
|
||||||
|
import { NotificationsDispatchContext } from '../../providers/notifications/notifications.provider';
|
||||||
|
import { internalPropertiesSet } from '../../redux/actions/internal-properties';
|
||||||
|
import State from '../../redux/state';
|
||||||
|
|
||||||
|
function useInitializeChromeRatingPrompt() {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const notificationsDispatch = useContext(NotificationsDispatchContext);
|
||||||
|
const internalProperties = useSelector<State, State['internalProperties']>(
|
||||||
|
(state) => state.internalProperties,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => {
|
||||||
|
if (
|
||||||
|
internalProperties.RunCount.value > 20
|
||||||
|
&& !internalProperties.HasShownChromeRatingPrompt.value
|
||||||
|
&& isPlatform('chrome')
|
||||||
|
) {
|
||||||
|
notificationsDispatch({
|
||||||
|
type: NotificationsActions.Push,
|
||||||
|
payload: {
|
||||||
|
title: 'Enjoying WebSocket King?',
|
||||||
|
body: 'Why not leave a rating on the Chrome Web Store.',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'No thanks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Give rating',
|
||||||
|
theme: 'primary',
|
||||||
|
onClick: () => window.open(config.chromeWebstoreLink, '_blank'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch(
|
||||||
|
internalPropertiesSet('HasShownChromeRatingPrompt', true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useInitializeChromeRatingPrompt;
|
23
src/bootstrap/hooks/useInitializeRunCount.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { internalPropertiesIncrement, internalPropertiesSet } from '../../redux/actions/internal-properties';
|
||||||
|
|
||||||
|
function useInitializeRunCount(storeReady: boolean) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => {
|
||||||
|
if (storeReady) {
|
||||||
|
dispatch(
|
||||||
|
internalPropertiesIncrement(
|
||||||
|
'RunCount',
|
||||||
|
internalPropertiesSet('InitializedRunCount', true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[storeReady],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useInitializeRunCount;
|
49
src/bootstrap/hooks/useInitializeTourPrompt.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import { useContext, useEffect } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import NotificationsActions from '../../providers/notifications/notifications.actions';
|
||||||
|
import { NotificationsDispatchContext } from '../../providers/notifications/notifications.provider';
|
||||||
|
import TourActions from '../../providers/tour/tour.actions';
|
||||||
|
import { TourDispatchContext } from '../../providers/tour/tour.provider';
|
||||||
|
import { internalPropertiesSet } from '../../redux/actions/internal-properties';
|
||||||
|
import State from '../../redux/state';
|
||||||
|
|
||||||
|
function useInitializeTourPrompt() {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const notificationsDispatch = useContext(NotificationsDispatchContext);
|
||||||
|
const tourDispatch = useContext(TourDispatchContext);
|
||||||
|
const hasShownTourPrompt = useSelector<State, boolean>(
|
||||||
|
(state) => state.internalProperties.HasShownTourPrompt.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => {
|
||||||
|
if (!hasShownTourPrompt) {
|
||||||
|
notificationsDispatch({
|
||||||
|
type: NotificationsActions.Push,
|
||||||
|
payload: {
|
||||||
|
title: 'Welcome!',
|
||||||
|
body: 'WebSocket King is a client for developing, testing and debugging WebSocket connections. Would you like to learn the basics?',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'No thanks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Take tour',
|
||||||
|
theme: 'primary',
|
||||||
|
onClick: () => tourDispatch!({
|
||||||
|
type: TourActions.Open,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch(
|
||||||
|
internalPropertiesSet('HasShownTourPrompt', true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useInitializeTourPrompt;
|
20
src/bootstrap/hooks/useInitializeWindowId.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { internalPropertiesInitializeWindowId } from '../../redux/actions/internal-properties';
|
||||||
|
|
||||||
|
function useInitializeWindowId(storeReady: boolean) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => {
|
||||||
|
if (storeReady) {
|
||||||
|
dispatch(
|
||||||
|
internalPropertiesInitializeWindowId(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[storeReady],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useInitializeWindowId;
|
20
src/components/Connections/Connection.styles.tsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import tw, { css, styled, theme } from 'twin.macro';
|
||||||
|
|
||||||
|
const purpleLight = theme`colors.purple.500`;
|
||||||
|
const purpleDark = theme`colors.purple.700`;
|
||||||
|
|
||||||
|
export const ConnectionComponent = styled.div(
|
||||||
|
({ highlighted }: { highlighted: boolean }) => [
|
||||||
|
tw`h-full flex flex-col relative bg-white dark:bg-gray-850 rounded-lg overflow-hidden duration-500 ease-in-out border border-gray-250 dark:border-gray-700 transition-all duration-500`,
|
||||||
|
css`
|
||||||
|
&:before {
|
||||||
|
${tw`transition-all duration-300 absolute inset-0 pointer-events-none z-30 rounded-lg`}
|
||||||
|
content: "";
|
||||||
|
box-shadow: inset 0 0 0 ${highlighted ? `2px ${purpleLight}` : '0 transparent'};
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
box-shadow: inset 0 0 0 ${highlighted ? `2px ${purpleDark}` : '0 transparent'};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
);
|
29
src/components/Connections/Connection.tsx
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import React from 'react';
|
||||||
|
import 'twin.macro';
|
||||||
|
import ConnectionModel from '../../models/connection';
|
||||||
|
import EventsConnected from './Events/EventsConnected';
|
||||||
|
import HeaderConnected from './Header/HeaderConnected';
|
||||||
|
import EditorConnected from './Editor/EditorConnected';
|
||||||
|
import { ConnectionComponent } from './Connection.styles';
|
||||||
|
|
||||||
|
export interface ConnectionProps {
|
||||||
|
connection: ConnectionModel,
|
||||||
|
highlighted: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Connection({
|
||||||
|
connection,
|
||||||
|
highlighted,
|
||||||
|
}: ConnectionProps) {
|
||||||
|
return (
|
||||||
|
<div tw="h-full w-full pr-2">
|
||||||
|
<ConnectionComponent highlighted={highlighted}>
|
||||||
|
<HeaderConnected connection={connection} />
|
||||||
|
<EditorConnected connection={connection} />
|
||||||
|
<div tw="flex flex-col flex-grow">
|
||||||
|
<EventsConnected connection={connection} />
|
||||||
|
</div>
|
||||||
|
</ConnectionComponent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
9
src/components/Connections/Connections.styles.tsx
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import tw, { styled } from 'twin.macro';
|
||||||
|
|
||||||
|
export const ConnectionWrapperComponent = styled.div`
|
||||||
|
${tw`flex flex-col flex-grow relative lg:min-w-48-5 xl:min-w-32-5 xxl:min-w-24-5`}
|
||||||
|
flex-basis: 0;
|
||||||
|
min-width: 95%;
|
||||||
|
max-width: 100%;
|
||||||
|
content-visibility: auto;
|
||||||
|
`;
|
104
src/components/Connections/Connections.tsx
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import ConnectionModel from '../../models/connection';
|
||||||
|
import EmptyMessage from '../General/Utilities/EmptyMessage';
|
||||||
|
import Connection from './Connection';
|
||||||
|
import useArrayInsertCallback from '../../hooks/useArrayInsertCallback';
|
||||||
|
import { ConnectionWrapperComponent } from './Connections.styles';
|
||||||
|
|
||||||
|
export interface ConnectionsProps {
|
||||||
|
paddingLeft: boolean,
|
||||||
|
connectionsMinimized: ConnectionModel[],
|
||||||
|
connectionsMaximized: ConnectionModel[],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Connections({
|
||||||
|
paddingLeft,
|
||||||
|
connectionsMinimized,
|
||||||
|
connectionsMaximized,
|
||||||
|
}: ConnectionsProps) {
|
||||||
|
const container = useRef<HTMLDivElement>(null);
|
||||||
|
const connectionComponentRefs = useRef<HTMLDivElement[]>([]);
|
||||||
|
const [
|
||||||
|
recentlyMaximizedConnections,
|
||||||
|
setRecentlyMaximizedConnections,
|
||||||
|
] = useState<ConnectionModel[]>([]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => {
|
||||||
|
connectionComponentRefs.current = connectionComponentRefs.current
|
||||||
|
.slice(0, connectionsMaximized.length);
|
||||||
|
},
|
||||||
|
[connectionsMaximized],
|
||||||
|
);
|
||||||
|
|
||||||
|
useArrayInsertCallback<ConnectionModel>(
|
||||||
|
connectionsMaximized,
|
||||||
|
'id',
|
||||||
|
(connections) => {
|
||||||
|
const connectionComponent = connectionComponentRefs.current[
|
||||||
|
connectionsMaximized.indexOf(connections[0])
|
||||||
|
];
|
||||||
|
|
||||||
|
if (connectionComponent && container.current) {
|
||||||
|
container.current.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
left: connectionComponent.offsetLeft - container.current.offsetLeft,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
|
||||||
|
setRecentlyMaximizedConnections([
|
||||||
|
...recentlyMaximizedConnections,
|
||||||
|
...connections,
|
||||||
|
]);
|
||||||
|
|
||||||
|
setTimeout(
|
||||||
|
() => setRecentlyMaximizedConnections([]),
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!connectionsMaximized.length && connectionsMinimized.length) {
|
||||||
|
return (
|
||||||
|
<EmptyMessage heading="No Open Connections">
|
||||||
|
All of your connections are minimized. Select one from the top to open it.
|
||||||
|
</EmptyMessage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connectionsMaximized.length) {
|
||||||
|
return (
|
||||||
|
<EmptyMessage heading="No Connections">
|
||||||
|
Select
|
||||||
|
{' '}
|
||||||
|
<strong>+</strong>
|
||||||
|
{' '}
|
||||||
|
at the top to create a new one.
|
||||||
|
</EmptyMessage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={container}
|
||||||
|
tw="w-full flex flex-row overflow-x-auto"
|
||||||
|
css={[paddingLeft && tw`pl-2`]}
|
||||||
|
>
|
||||||
|
{connectionsMaximized.map((connection, index) => (
|
||||||
|
<ConnectionWrapperComponent
|
||||||
|
key={connection.id}
|
||||||
|
ref={(element: HTMLDivElement) => {
|
||||||
|
connectionComponentRefs.current[index] = element;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Connection
|
||||||
|
connection={connection}
|
||||||
|
highlighted={recentlyMaximizedConnections.includes(connection)}
|
||||||
|
/>
|
||||||
|
</ConnectionWrapperComponent>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
13
src/components/Connections/ConnectionsConnected.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import State from '../../redux/state';
|
||||||
|
import { connectionsMaximizedForWindow, connectionsMinimizedForWindow } from '../../redux/selectors/connections';
|
||||||
|
import Connections from './Connections';
|
||||||
|
|
||||||
|
function mapStateToProps(state: State) {
|
||||||
|
return {
|
||||||
|
connectionsMaximized: connectionsMaximizedForWindow(state),
|
||||||
|
connectionsMinimized: connectionsMinimizedForWindow(state),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(Connections);
|
110
src/components/Connections/Editor/Editor.tsx
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
import React, { useContext } from 'react';
|
||||||
|
import 'twin.macro';
|
||||||
|
import { MdAdd } from 'react-icons/md';
|
||||||
|
import TabModel from '../../../models/tab';
|
||||||
|
import Tab from './EditorTab';
|
||||||
|
import SavedPayload from '../../../models/saved-payload';
|
||||||
|
import SavedPayloadValidator from '../../../models/saved-payload/validator';
|
||||||
|
import Connection from '../../../models/connection';
|
||||||
|
import Project from '../../../models/project';
|
||||||
|
import EditorContent from './EditorContent';
|
||||||
|
import PopupPrompt from '../../General/PopupPresets/PopupPrompt';
|
||||||
|
import { PopupContext } from '../../../providers/PopupProvider';
|
||||||
|
import ButtonSecondary from '../../General/Styled/ButtonSecondary';
|
||||||
|
import {
|
||||||
|
tabClose,
|
||||||
|
tabCreate,
|
||||||
|
tabSwitch,
|
||||||
|
tabUpdateContent,
|
||||||
|
} from '../../../redux/actions/tabs';
|
||||||
|
import { savedPayloadCreateFromTab, savedPayloadUpdate } from '../../../redux/actions/saved-payloads';
|
||||||
|
import { socketSend } from '../../../redux/actions/connection-sockets';
|
||||||
|
|
||||||
|
export interface EditorProps {
|
||||||
|
connection: Connection,
|
||||||
|
project: Project,
|
||||||
|
tabs: TabModel[],
|
||||||
|
savedPayloads: { [key: string]: SavedPayload },
|
||||||
|
onCloseTab: typeof tabClose,
|
||||||
|
onSwitchTab: typeof tabSwitch,
|
||||||
|
onCreateTab: typeof tabCreate,
|
||||||
|
onTabContentChange: typeof tabUpdateContent,
|
||||||
|
onCreateSavedPayload: typeof savedPayloadCreateFromTab,
|
||||||
|
onSavedPayloadChange: typeof savedPayloadUpdate,
|
||||||
|
onWebSocketSend: typeof socketSend,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Editor({
|
||||||
|
connection,
|
||||||
|
project,
|
||||||
|
tabs,
|
||||||
|
savedPayloads,
|
||||||
|
onCloseTab,
|
||||||
|
onSwitchTab,
|
||||||
|
onCreateTab,
|
||||||
|
onTabContentChange,
|
||||||
|
onCreateSavedPayload,
|
||||||
|
onSavedPayloadChange,
|
||||||
|
onWebSocketSend,
|
||||||
|
}: EditorProps) {
|
||||||
|
const popup = useContext(PopupContext);
|
||||||
|
|
||||||
|
const selectedTab = tabs.find((tab) => tab.selected)!;
|
||||||
|
|
||||||
|
const selectedSavedPayload = Object.values(savedPayloads)
|
||||||
|
.find((savedPayload) => selectedTab.savedPayloadId === savedPayload.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-tour="connection-editor">
|
||||||
|
<div tw="flex flex-wrap w-full overflow-x-auto overflow-y-hidden">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Tab
|
||||||
|
key={tab.id}
|
||||||
|
tab={tab}
|
||||||
|
savedPayload={savedPayloads[tab.savedPayloadId!]}
|
||||||
|
showClose={tabs.length > 1}
|
||||||
|
onClose={() => onCloseTab(tab)}
|
||||||
|
onSwitch={() => onSwitchTab(tab)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<ButtonSecondary
|
||||||
|
type="button"
|
||||||
|
title="New Tab"
|
||||||
|
tw="px-4 py-2 cursor-pointer flex items-center"
|
||||||
|
onClick={() => onCreateTab(connection)}
|
||||||
|
>
|
||||||
|
<MdAdd />
|
||||||
|
</ButtonSecondary>
|
||||||
|
</div>
|
||||||
|
<EditorContent
|
||||||
|
connection={connection}
|
||||||
|
selectedTab={selectedTab}
|
||||||
|
selectedSavedPayload={selectedSavedPayload}
|
||||||
|
onSend={(content) => onWebSocketSend(connection, content)}
|
||||||
|
onSave={(tab) => onSavedPayloadChange(selectedSavedPayload!, { content: tab.content })}
|
||||||
|
onSaveAs={async (tab) => {
|
||||||
|
const name = await popup.push<string>(
|
||||||
|
'Save Payload',
|
||||||
|
PopupPrompt,
|
||||||
|
{
|
||||||
|
label: 'Payload Name',
|
||||||
|
submitLabel: 'Save',
|
||||||
|
yupValidator: SavedPayloadValidator.name,
|
||||||
|
maxLength: SavedPayloadValidator.nameLength,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (name?.length) {
|
||||||
|
onCreateSavedPayload(
|
||||||
|
project,
|
||||||
|
connection,
|
||||||
|
tab,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onTabContentChanged={(tab, content) => onTabContentChange(tab, content)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
39
src/components/Connections/Editor/EditorConnected.tsx
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { bindActionCreators, Dispatch } from 'redux';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import State from '../../../redux/state';
|
||||||
|
import {
|
||||||
|
tabClose,
|
||||||
|
tabCreate,
|
||||||
|
tabSwitch,
|
||||||
|
tabUpdateContent,
|
||||||
|
} from '../../../redux/actions/tabs';
|
||||||
|
import { tabsForConnection } from '../../../redux/selectors/tabs';
|
||||||
|
import { savedPayloadCreateFromTab, savedPayloadUpdate } from '../../../redux/actions/saved-payloads';
|
||||||
|
import { socketSend } from '../../../redux/actions/connection-sockets';
|
||||||
|
import { currentProject } from '../../../redux/selectors/projects';
|
||||||
|
import Editor from './Editor';
|
||||||
|
|
||||||
|
function mapStateToProps(state: State, props: any) {
|
||||||
|
return {
|
||||||
|
tabs: tabsForConnection(state, props.connection.id),
|
||||||
|
project: currentProject(state),
|
||||||
|
savedPayloads: state.savedPayloads,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDispatchToProps(dispatch: Dispatch) {
|
||||||
|
return bindActionCreators(
|
||||||
|
{
|
||||||
|
onCloseTab: tabClose,
|
||||||
|
onSwitchTab: tabSwitch,
|
||||||
|
onCreateTab: tabCreate,
|
||||||
|
onTabContentChange: tabUpdateContent,
|
||||||
|
onCreateSavedPayload: savedPayloadCreateFromTab,
|
||||||
|
onSavedPayloadChange: savedPayloadUpdate,
|
||||||
|
onWebSocketSend: socketSend,
|
||||||
|
},
|
||||||
|
dispatch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(Editor);
|
107
src/components/Connections/Editor/EditorContent.tsx
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import 'twin.macro';
|
||||||
|
import Tab from '../../../models/tab';
|
||||||
|
import SavedPayload from '../../../models/saved-payload';
|
||||||
|
import Connection, { ConnectionSocketStatus } from '../../../models/connection';
|
||||||
|
import ButtonPrimary from '../../General/Styled/ButtonPrimary';
|
||||||
|
import ButtonSecondary from '../../General/Styled/ButtonSecondary';
|
||||||
|
import Editor from '../../General/Editor/Editor';
|
||||||
|
|
||||||
|
export interface EditorContentProps {
|
||||||
|
connection: Connection
|
||||||
|
selectedTab: Tab,
|
||||||
|
selectedSavedPayload: SavedPayload | undefined,
|
||||||
|
onSave: (tab: Tab) => void,
|
||||||
|
onSaveAs: (tab: Tab) => void,
|
||||||
|
onSend: (content: string) => void,
|
||||||
|
onTabContentChanged: (tab: Tab, content: string) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditorContent({
|
||||||
|
connection,
|
||||||
|
selectedTab,
|
||||||
|
selectedSavedPayload,
|
||||||
|
onSave,
|
||||||
|
onSaveAs,
|
||||||
|
onSend,
|
||||||
|
onTabContentChanged,
|
||||||
|
}: EditorContentProps) {
|
||||||
|
const [content, setContent] = useState<string>(selectedTab?.content);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => {
|
||||||
|
if (selectedTab) {
|
||||||
|
setContent(selectedTab.content);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedTab, selectedSavedPayload],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!selectedTab) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
<span tw="sr-only">Payload</span>
|
||||||
|
<div tw="mt-2 px-4">
|
||||||
|
<Editor
|
||||||
|
value={content}
|
||||||
|
onChange={(newContent) => setContent(newContent)}
|
||||||
|
onBlur={() => onTabContentChanged(selectedTab, content)}
|
||||||
|
minLines={3}
|
||||||
|
maxLines={6}
|
||||||
|
placeholder="Payload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div tw="flex my-2 px-4">
|
||||||
|
<ButtonPrimary
|
||||||
|
type="button"
|
||||||
|
disabled={connection.socketStatus !== ConnectionSocketStatus.Connected}
|
||||||
|
tw="py-1 px-4 mr-2 rounded"
|
||||||
|
onClick={() => onSend(selectedTab.content)}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</ButtonPrimary>
|
||||||
|
{selectedSavedPayload && selectedTab.content === selectedSavedPayload.content && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tw="px-4 text-gray-400 cursor-default text-xs"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Saved
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{selectedSavedPayload && selectedTab.content !== selectedSavedPayload.content && (
|
||||||
|
<>
|
||||||
|
<ButtonSecondary
|
||||||
|
type="button"
|
||||||
|
tw="px-4 rounded text-xs"
|
||||||
|
onClick={() => onSave(selectedTab)}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</ButtonSecondary>
|
||||||
|
<ButtonSecondary
|
||||||
|
type="button"
|
||||||
|
tw="px-4 rounded text-xs"
|
||||||
|
onClick={() => onTabContentChanged(selectedTab, selectedSavedPayload.content)}
|
||||||
|
>
|
||||||
|
Discard Changes
|
||||||
|
</ButtonSecondary>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selectedTab.content?.length > 0 && (
|
||||||
|
<ButtonSecondary
|
||||||
|
type="button"
|
||||||
|
tw="px-4 rounded text-xs"
|
||||||
|
onClick={() => onSaveAs(selectedTab)}
|
||||||
|
>
|
||||||
|
Save As
|
||||||
|
</ButtonSecondary>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
73
src/components/Connections/Editor/EditorTab.tsx
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { MdClose } from 'react-icons/md';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import TabModel from '../../../models/tab';
|
||||||
|
import SavedPayload from '../../../models/saved-payload';
|
||||||
|
import TextLimit from '../../General/Utilities/TextLimit';
|
||||||
|
import { ContextMenuContext } from '../../../providers/ContextMenuProvider';
|
||||||
|
|
||||||
|
export interface EditorTabProps {
|
||||||
|
tab: TabModel,
|
||||||
|
savedPayload: SavedPayload | undefined,
|
||||||
|
showClose: boolean,
|
||||||
|
onClose: any,
|
||||||
|
onSwitch: any,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditorTab({
|
||||||
|
tab,
|
||||||
|
savedPayload,
|
||||||
|
showClose,
|
||||||
|
onClose,
|
||||||
|
onSwitch,
|
||||||
|
}: EditorTabProps) {
|
||||||
|
const contextMenu = useContext(ContextMenuContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSwitch()}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
if (!showClose) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
contextMenu.openForMouseEvent(
|
||||||
|
event,
|
||||||
|
[
|
||||||
|
...!tab.selected ? [{
|
||||||
|
label: 'Switch to',
|
||||||
|
onClick: () => onSwitch(),
|
||||||
|
}] : [],
|
||||||
|
...showClose ? [{
|
||||||
|
label: 'Close',
|
||||||
|
onClick: () => onClose(),
|
||||||
|
}] : [],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
css={[
|
||||||
|
tw`flex flex-grow justify-between items-center border-r dark:border-gray-700 py-1 px-4 whitespace-nowrap uppercase font-semibold text-sm text-gray-700 dark:text-gray-200`,
|
||||||
|
showClose && tw`pr-3`,
|
||||||
|
tab.selected && tw`bg-white dark:bg-gray-850 cursor-default`,
|
||||||
|
!tab.selected && tw`bg-gray-200 dark:bg-gray-900 hover:bg-gray-100 hover:dark:bg-gray-800`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{savedPayload
|
||||||
|
? <TextLimit characters={20}>{savedPayload.name}</TextLimit>
|
||||||
|
: `Untitled ${tab.number}`}
|
||||||
|
{showClose && (
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
onClick={(event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
tw="text-xs text-gray-700 dark:text-gray-400 p-1 ml-2 hover:bg-gray-500 hover:dark:bg-gray-700 hover:text-white cursor-pointer"
|
||||||
|
>
|
||||||
|
<MdClose />
|
||||||
|
</div>
|
||||||
|
) }
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
112
src/components/Connections/Events/EventRow.tsx
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import {
|
||||||
|
FaRegCopy, FaRegEdit, FaArrowUp, FaArrowDown,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||||
|
import { MdDone, RiInformationLine } from 'react-icons/all';
|
||||||
|
import Event, { EventType } from '../../../models/event';
|
||||||
|
import EventRowPayload from './EventRowPayload';
|
||||||
|
|
||||||
|
export interface EventRowProps {
|
||||||
|
event: Event,
|
||||||
|
shouldFormatPayload: boolean,
|
||||||
|
onOpenInNewTab: () => void,
|
||||||
|
layout: 'narrow' | 'wide',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventRow({
|
||||||
|
event,
|
||||||
|
shouldFormatPayload,
|
||||||
|
onOpenInNewTab,
|
||||||
|
layout,
|
||||||
|
}: EventRowProps) {
|
||||||
|
const [copiedToClipboard, setCopiedToClipboard] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className="group"
|
||||||
|
css={[
|
||||||
|
tw`max-w-full flex flex-row justify-between py-1 px-4 hover:bg-gray-100 hover:dark:bg-gray-800`,
|
||||||
|
layout === 'narrow' && tw`flex-wrap`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div tw="order-1 flex flex-shrink-0">
|
||||||
|
<div tw="text-gray-400 dark:text-gray-600 font-mono">
|
||||||
|
{format(new Date(event.timestamp), 'HH:mm ss.SS')}
|
||||||
|
</div>
|
||||||
|
{event.type === EventType.Sent && (
|
||||||
|
<div tw="text-green-500 dark:text-green-200 ml-2 p-1 text-xs">
|
||||||
|
<FaArrowUp title="Sent payload" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{event.type === EventType.Received && (
|
||||||
|
<div tw="text-red-500 dark:text-red-200 ml-2 p-1 text-xs">
|
||||||
|
<FaArrowDown title="Received payload" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{event.type === EventType.Meta && (
|
||||||
|
<div tw="text-gray-500 dark:text-gray-700 ml-2 p-1 text-xs">
|
||||||
|
<RiInformationLine title="Information" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
css={[
|
||||||
|
tw`flex-grow min-w-0 font-mono`,
|
||||||
|
layout === 'narrow' && tw`order-4 w-full`,
|
||||||
|
layout === 'wide' && tw`order-3 ml-4`,
|
||||||
|
event.type === EventType.Sent && tw`text-green-900 dark:text-green-200`,
|
||||||
|
event.type === EventType.Received && tw`text-red-900 dark:text-red-200`,
|
||||||
|
event.type === EventType.Meta && tw`text-gray-900 dark:text-gray-200`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<pre tw="whitespace-pre-wrap break-words">
|
||||||
|
<EventRowPayload
|
||||||
|
event={event}
|
||||||
|
shouldFormatPayload={shouldFormatPayload}
|
||||||
|
/>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
css={[
|
||||||
|
tw`invisible group-hover:visible text-gray-400`,
|
||||||
|
layout === 'narrow' && tw`order-3`,
|
||||||
|
layout === 'wide' && tw`order-4`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CopyToClipboard
|
||||||
|
text={event.payload}
|
||||||
|
onCopy={() => {
|
||||||
|
if (!copiedToClipboard) {
|
||||||
|
setCopiedToClipboard(true);
|
||||||
|
setTimeout(() => setCopiedToClipboard(false), 2000);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
css={[
|
||||||
|
tw`ml-2 hover:text-gray-600`,
|
||||||
|
copiedToClipboard && tw`cursor-default`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{!copiedToClipboard && <FaRegCopy title="Copy to Clipboard" />}
|
||||||
|
{copiedToClipboard && <MdDone title="Copied to Clipboard" />}
|
||||||
|
</button>
|
||||||
|
</CopyToClipboard>
|
||||||
|
{event.type !== EventType.Meta && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tw="ml-2 hover:text-gray-600"
|
||||||
|
onClick={() => onOpenInNewTab()}
|
||||||
|
>
|
||||||
|
<FaRegEdit title="Open in Editor" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
26
src/components/Connections/Events/EventRowPayload.tsx
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import React from 'react';
|
||||||
|
import Event, { EventFormat } from '../../../models/event';
|
||||||
|
|
||||||
|
export interface EventRowPayloadPropTypes {
|
||||||
|
event: Event,
|
||||||
|
shouldFormatPayload: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventRowPayload({
|
||||||
|
event,
|
||||||
|
shouldFormatPayload,
|
||||||
|
}: EventRowPayloadPropTypes) {
|
||||||
|
if (shouldFormatPayload && event.format === EventFormat.Json) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{JSON.stringify(
|
||||||
|
JSON.parse(event.payload),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{event.payload}</>;
|
||||||
|
}
|
72
src/components/Connections/Events/Events.tsx
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import React from 'react';
|
||||||
|
import 'twin.macro';
|
||||||
|
import { AiOutlineDelete } from 'react-icons/all';
|
||||||
|
import useResizeObserver from 'use-resize-observer/polyfilled';
|
||||||
|
import Event from '../../../models/event';
|
||||||
|
import EmptyMessage from '../../General/Utilities/EmptyMessage';
|
||||||
|
import Heading from '../../General/Utilities/Heading';
|
||||||
|
import Connection from '../../../models/connection';
|
||||||
|
import EventRow from './EventRow';
|
||||||
|
import { eventsRemoveForConnection } from '../../../redux/actions/events';
|
||||||
|
import { tabCreate } from '../../../redux/actions/tabs';
|
||||||
|
|
||||||
|
export interface EventsProps {
|
||||||
|
connection: Connection,
|
||||||
|
formatEventPayloads: boolean,
|
||||||
|
events: Event[],
|
||||||
|
onClear: typeof eventsRemoveForConnection,
|
||||||
|
onCreateInTab: typeof tabCreate,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Events({
|
||||||
|
connection,
|
||||||
|
formatEventPayloads,
|
||||||
|
events,
|
||||||
|
onClear,
|
||||||
|
onCreateInTab,
|
||||||
|
}: EventsProps) {
|
||||||
|
const {
|
||||||
|
ref: containerRef,
|
||||||
|
width: containerWidth = 1,
|
||||||
|
} = useResizeObserver<HTMLDivElement>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Heading
|
||||||
|
tw="flex-grow-0"
|
||||||
|
buttons={[
|
||||||
|
{
|
||||||
|
icon: <AiOutlineDelete />,
|
||||||
|
alt: 'Clear Output',
|
||||||
|
onClick: () => onClear(connection),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Output
|
||||||
|
</Heading>
|
||||||
|
<div
|
||||||
|
tw="flex-grow relative flex"
|
||||||
|
ref={containerRef}
|
||||||
|
>
|
||||||
|
{!!events.length && (
|
||||||
|
<div tw="absolute inset-0 overflow-auto py-2 select-text">
|
||||||
|
{events.map((event) => (
|
||||||
|
<EventRow
|
||||||
|
key={event.id}
|
||||||
|
event={event}
|
||||||
|
shouldFormatPayload={formatEventPayloads}
|
||||||
|
onOpenInNewTab={() => onCreateInTab(connection, event.payload)}
|
||||||
|
layout={containerWidth < 400 ? 'narrow' : 'wide'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!events.length && (
|
||||||
|
<EmptyMessage heading="No Output">
|
||||||
|
Sent and received messages will show up here.
|
||||||
|
</EmptyMessage>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
27
src/components/Connections/Events/EventsConnected.tsx
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { bindActionCreators, Dispatch } from 'redux';
|
||||||
|
import Events from './Events';
|
||||||
|
import State from '../../../redux/state';
|
||||||
|
import { eventsRemoveForConnection } from '../../../redux/actions/events';
|
||||||
|
import { eventsForConnection } from '../../../redux/selectors/events';
|
||||||
|
import { tabCreate } from '../../../redux/actions/tabs';
|
||||||
|
import { currentProject } from '../../../redux/selectors/projects';
|
||||||
|
|
||||||
|
function mapStateToProps(state: State, props: any) {
|
||||||
|
return {
|
||||||
|
events: eventsForConnection(state, props.connection),
|
||||||
|
formatEventPayloads: currentProject(state).formatEventPayloads,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDispatchToProps(dispatch: Dispatch) {
|
||||||
|
return bindActionCreators(
|
||||||
|
{
|
||||||
|
onClear: eventsRemoveForConnection,
|
||||||
|
onCreateInTab: tabCreate,
|
||||||
|
},
|
||||||
|
dispatch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(Events);
|
164
src/components/Connections/Header/Header.tsx
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
import React, { useContext, useState } from 'react';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import { MdClose } from 'react-icons/md';
|
||||||
|
import { GoDash, RiSettings3Line } from 'react-icons/all';
|
||||||
|
import Connection, { ConnectionSocketStatus } from '../../../models/connection';
|
||||||
|
import HeaderName from './HeaderName';
|
||||||
|
import ButtonSecondary from '../../General/Styled/ButtonSecondary';
|
||||||
|
import ButtonPrimary from '../../General/Styled/ButtonPrimary';
|
||||||
|
import { PopupContext } from '../../../providers/PopupProvider';
|
||||||
|
import EditConnection from '../../EditConnection/EditConnection';
|
||||||
|
import { socketConnect, socketDisconnect } from '../../../redux/actions/connection-sockets';
|
||||||
|
import {
|
||||||
|
connectionDisconnectSocketAndRemove,
|
||||||
|
connectionMinimize,
|
||||||
|
connectionUpdateAutoReconnect,
|
||||||
|
connectionUpdateName,
|
||||||
|
connectionUpdateProtocols,
|
||||||
|
connectionUpdateSocketUrl,
|
||||||
|
} from '../../../redux/actions/connections';
|
||||||
|
|
||||||
|
export interface HeaderProps {
|
||||||
|
onWebSocketUrlChange: typeof connectionUpdateSocketUrl,
|
||||||
|
onWebSocketNameChange: typeof connectionUpdateName,
|
||||||
|
onWebSocketProtocolsChange: typeof connectionUpdateProtocols,
|
||||||
|
onWebSocketAutoReconnectChange: typeof connectionUpdateAutoReconnect,
|
||||||
|
onWebSocketConnect: typeof socketConnect,
|
||||||
|
onWebSocketDisconnect: typeof socketDisconnect,
|
||||||
|
onClose: typeof connectionDisconnectSocketAndRemove,
|
||||||
|
onMinimize: typeof connectionMinimize,
|
||||||
|
connection: Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Header({
|
||||||
|
onWebSocketUrlChange,
|
||||||
|
onWebSocketNameChange,
|
||||||
|
onWebSocketProtocolsChange,
|
||||||
|
onWebSocketAutoReconnectChange,
|
||||||
|
onWebSocketConnect,
|
||||||
|
onWebSocketDisconnect,
|
||||||
|
onClose,
|
||||||
|
onMinimize,
|
||||||
|
connection,
|
||||||
|
}: HeaderProps) {
|
||||||
|
const popup = useContext(PopupContext);
|
||||||
|
const [socketUrlInputFocused, setSocketUrlInputFocused] = useState<boolean>(false);
|
||||||
|
const [connectionOptionsPopupOpen, setConnectionOptionsPopupOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const connectOrDisconnectClick = () => {
|
||||||
|
if (connection.socketStatus === ConnectionSocketStatus.Disconnected) {
|
||||||
|
onWebSocketConnect(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection.socketStatus === ConnectionSocketStatus.Connected) {
|
||||||
|
onWebSocketDisconnect(connection);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div tw="w-full bg-gray-200 dark:bg-gray-900">
|
||||||
|
<div tw="flex flex-row p-2 items-center">
|
||||||
|
<div tw="flex-none pl-2 pr-4">
|
||||||
|
<HeaderName
|
||||||
|
name={connection.name}
|
||||||
|
onNameChange={(name) => onWebSocketNameChange(connection, name)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div tw="flex-grow">
|
||||||
|
<div
|
||||||
|
data-tour="connection-url"
|
||||||
|
css={[
|
||||||
|
tw`flex flex-row flex-grow overflow-hidden rounded-lg border-2 dark:border-gray-700 h-10`,
|
||||||
|
socketUrlInputFocused && tw`border-gray-400 dark:border-gray-600`,
|
||||||
|
connection.socketStatus !== ConnectionSocketStatus.Disconnected && tw`bg-gray-100 dark:bg-gray-900`,
|
||||||
|
connection.socketStatus === ConnectionSocketStatus.Disconnected && tw`bg-white dark:bg-gray-850`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
css={[
|
||||||
|
tw`flex flex-row flex-grow items-center`,
|
||||||
|
connection.socketStatus !== ConnectionSocketStatus.Disconnected && tw`pointer-events-none`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<label tw="flex-grow w-full">
|
||||||
|
<span tw="sr-only">WebSocket URL</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="WebSocket URL"
|
||||||
|
tw="w-full py-1 pl-2 pr-1 bg-transparent text-gray-900 dark:text-gray-100"
|
||||||
|
onChange={(event) => onWebSocketUrlChange(
|
||||||
|
connection,
|
||||||
|
(event.target as HTMLInputElement).value,
|
||||||
|
)}
|
||||||
|
onFocus={() => setSocketUrlInputFocused(true)}
|
||||||
|
onBlur={() => setSocketUrlInputFocused(false)}
|
||||||
|
value={connection.socketUrl}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<ButtonSecondary
|
||||||
|
onClick={async () => {
|
||||||
|
setConnectionOptionsPopupOpen(true);
|
||||||
|
await popup.push(
|
||||||
|
`${connection.name} Connection Options`,
|
||||||
|
EditConnection,
|
||||||
|
{
|
||||||
|
connection,
|
||||||
|
onWebSocketProtocolsChange,
|
||||||
|
onWebSocketAutoReconnectChange,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setConnectionOptionsPopupOpen(false);
|
||||||
|
}}
|
||||||
|
title="Connection Options"
|
||||||
|
css={[
|
||||||
|
tw`m-1 h-6 flex-none p-1 rounded`,
|
||||||
|
connectionOptionsPopupOpen && tw`bg-gray-400`,
|
||||||
|
connection.socketStatus !== ConnectionSocketStatus.Disconnected && tw`text-gray-300 dark:text-gray-600`,
|
||||||
|
connection.socketStatus === ConnectionSocketStatus.Disconnected && tw`text-gray-600 dark:text-gray-300`,
|
||||||
|
]}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RiSettings3Line tw="text-sm" />
|
||||||
|
</ButtonSecondary>
|
||||||
|
</div>
|
||||||
|
<ButtonPrimary
|
||||||
|
type="button"
|
||||||
|
onClick={() => connectOrDisconnectClick()}
|
||||||
|
disabled={
|
||||||
|
[
|
||||||
|
ConnectionSocketStatus.PendingReconnection,
|
||||||
|
ConnectionSocketStatus.Pending,
|
||||||
|
].includes(connection.socketStatus)
|
||||||
|
|| !connection.socketUrl.length
|
||||||
|
}
|
||||||
|
css={[tw`px-4 mr-2 my-1 rounded`]}
|
||||||
|
>
|
||||||
|
{connection.socketStatus === ConnectionSocketStatus.Disconnected && 'Connect'}
|
||||||
|
{connection.socketStatus === ConnectionSocketStatus.Pending && 'Connecting'}
|
||||||
|
{connection.socketStatus === ConnectionSocketStatus.Connected && 'Disconnect'}
|
||||||
|
{connection.socketStatus === ConnectionSocketStatus.PendingReconnection && `Reconnecting in ${connection.socketSecondsUntilReconnect}`}
|
||||||
|
</ButtonPrimary>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div tw="flex-none h-8 ml-2">
|
||||||
|
<ButtonSecondary
|
||||||
|
title="Minimize"
|
||||||
|
tw="px-2 h-8"
|
||||||
|
onClick={() => onMinimize(connection)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<GoDash />
|
||||||
|
</ButtonSecondary>
|
||||||
|
<ButtonSecondary
|
||||||
|
title="Close"
|
||||||
|
tw="px-2 h-8"
|
||||||
|
onClick={() => onClose(connection)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<MdClose />
|
||||||
|
</ButtonSecondary>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
35
src/components/Connections/Header/HeaderConnected.tsx
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { bindActionCreators, Dispatch } from 'redux';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { socketConnect, socketDisconnect } from '../../../redux/actions/connection-sockets';
|
||||||
|
import {
|
||||||
|
connectionMinimize,
|
||||||
|
connectionUpdateName,
|
||||||
|
connectionUpdateAutoReconnect,
|
||||||
|
connectionUpdateProtocols,
|
||||||
|
connectionUpdateSocketUrl,
|
||||||
|
connectionDisconnectSocketAndRemove,
|
||||||
|
} from '../../../redux/actions/connections';
|
||||||
|
import Header from './Header';
|
||||||
|
import State from '../../../redux/state';
|
||||||
|
|
||||||
|
function mapDispatchToProps(dispatch: Dispatch) {
|
||||||
|
return bindActionCreators(
|
||||||
|
{
|
||||||
|
onWebSocketUrlChange: connectionUpdateSocketUrl,
|
||||||
|
onWebSocketNameChange: connectionUpdateName,
|
||||||
|
onWebSocketProtocolsChange: connectionUpdateProtocols,
|
||||||
|
onWebSocketAutoReconnectChange: connectionUpdateAutoReconnect,
|
||||||
|
onWebSocketConnect: socketConnect,
|
||||||
|
onWebSocketDisconnect: socketDisconnect,
|
||||||
|
onClose: connectionDisconnectSocketAndRemove,
|
||||||
|
onMinimize: connectionMinimize,
|
||||||
|
},
|
||||||
|
dispatch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStateToProps(state: State, { connection }: any) {
|
||||||
|
return { connection };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(Header);
|
43
src/components/Connections/Header/HeaderName.tsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import React, { useContext } from 'react';
|
||||||
|
import 'twin.macro';
|
||||||
|
import { PopupContext } from '../../../providers/PopupProvider';
|
||||||
|
import ConnectionValidators from '../../../models/connection/validator';
|
||||||
|
import PopupPrompt from '../../General/PopupPresets/PopupPrompt';
|
||||||
|
|
||||||
|
export interface HeaderNameProps {
|
||||||
|
name: string,
|
||||||
|
onNameChange: (name: string) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeaderName({
|
||||||
|
name,
|
||||||
|
onNameChange,
|
||||||
|
}: HeaderNameProps) {
|
||||||
|
const popup = useContext(PopupContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tw="bg-blue-700 dark:bg-blue-800 hover:bg-blue-600 hover:dark:bg-blue-700 px-2 py-1 font-semibold text-xs text-white rounded"
|
||||||
|
onClick={async () => {
|
||||||
|
const newName = await popup.push<string>(
|
||||||
|
'Rename Connection',
|
||||||
|
PopupPrompt,
|
||||||
|
{
|
||||||
|
label: 'Connection Name',
|
||||||
|
submitLabel: 'Rename',
|
||||||
|
defaultValue: name,
|
||||||
|
yupValidator: ConnectionValidators.name,
|
||||||
|
maxLength: ConnectionValidators.nameLength,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newName?.length) {
|
||||||
|
onNameChange(newName);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
75
src/components/CreateEditPayload/CreateEditPayload.tsx
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { Form, Formik } from 'formik';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
import PopupButtons from '../General/Popup/PopupButtons';
|
||||||
|
import { PopupContext } from '../../providers/PopupProvider';
|
||||||
|
import PopupBody from '../General/Popup/PopupBody';
|
||||||
|
import SavedPayloadValidator from '../../models/saved-payload/validator';
|
||||||
|
import FormEditor from '../General/Form/FormEditor';
|
||||||
|
import FormTextInput from '../General/Form/FormTextInput';
|
||||||
|
import FormField from '../General/Form/FormField';
|
||||||
|
import Spacer from '../General/Utilities/Spacer';
|
||||||
|
|
||||||
|
export interface CreateEditProjectProps {
|
||||||
|
name: string,
|
||||||
|
content: string,
|
||||||
|
onSave: (name: string, content: string) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateEditPayload({
|
||||||
|
name,
|
||||||
|
content,
|
||||||
|
onSave,
|
||||||
|
}: CreateEditProjectProps) {
|
||||||
|
const popup = useContext(PopupContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
name,
|
||||||
|
content,
|
||||||
|
}}
|
||||||
|
onSubmit={(value) => {
|
||||||
|
onSave(value.name, value.content);
|
||||||
|
popup.pop();
|
||||||
|
}}
|
||||||
|
validationSchema={yup.object({
|
||||||
|
name: SavedPayloadValidator.name,
|
||||||
|
content: SavedPayloadValidator.content,
|
||||||
|
})}
|
||||||
|
validateOnChange={false}
|
||||||
|
validateOnBlur={false}
|
||||||
|
>
|
||||||
|
<Form>
|
||||||
|
<PopupBody>
|
||||||
|
<FormField title="Name">
|
||||||
|
<FormTextInput
|
||||||
|
name="name"
|
||||||
|
maxLength={SavedPayloadValidator.nameLength}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<Spacer />
|
||||||
|
<FormField title="Content">
|
||||||
|
<FormEditor
|
||||||
|
name="content"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</PopupBody>
|
||||||
|
<PopupButtons
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Cancel',
|
||||||
|
theme: 'secondary',
|
||||||
|
onClick: () => popup.pop(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Save',
|
||||||
|
theme: 'primary',
|
||||||
|
type: 'submit',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { bindActionCreators } from 'redux';
|
||||||
|
import { savedPayloadCreate, savedPayloadUpdate } from '../../redux/actions/saved-payloads';
|
||||||
|
import State from '../../redux/state';
|
||||||
|
import EditPayload from './CreateEditPayload';
|
||||||
|
|
||||||
|
function mapStateToProps(state: State, props: any) {
|
||||||
|
return {
|
||||||
|
name: props.savedPayload?.name || '',
|
||||||
|
content: props.savedPayload?.content || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDispatchToProps(dispatch: any, props: any) {
|
||||||
|
return bindActionCreators(
|
||||||
|
{
|
||||||
|
onSave: (name: string, content: string) => (
|
||||||
|
props.savedPayload
|
||||||
|
? savedPayloadUpdate(props.savedPayload, { name, content })
|
||||||
|
: savedPayloadCreate(props.project, name, content)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
dispatch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(EditPayload);
|
99
src/components/EditConnection/EditConnection.tsx
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { Form, Formik } from 'formik';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
import PopupButtons from '../General/Popup/PopupButtons';
|
||||||
|
import { PopupContext } from '../../providers/PopupProvider';
|
||||||
|
import FormField from '../General/Form/FormField';
|
||||||
|
import PopupBody from '../General/Popup/PopupBody';
|
||||||
|
import FormCheckbox from '../General/Form/FormCheckbox';
|
||||||
|
import FormTextInputArray from '../General/Form/FormTextInputArray';
|
||||||
|
import Spacer from '../General/Utilities/Spacer';
|
||||||
|
import Link from '../General/Styled/Link';
|
||||||
|
import Connection from '../../models/connection';
|
||||||
|
import ConnectionValidator from '../../models/connection/validator';
|
||||||
|
import { connectionUpdateAutoReconnect, connectionUpdateProtocols } from '../../redux/actions/connections';
|
||||||
|
|
||||||
|
export interface EditConnectionProps {
|
||||||
|
connection: Connection,
|
||||||
|
onWebSocketProtocolsChange: typeof connectionUpdateProtocols,
|
||||||
|
onWebSocketAutoReconnectChange: typeof connectionUpdateAutoReconnect,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditConnection({
|
||||||
|
connection,
|
||||||
|
onWebSocketProtocolsChange,
|
||||||
|
onWebSocketAutoReconnectChange,
|
||||||
|
}: EditConnectionProps) {
|
||||||
|
const popup = useContext(PopupContext);
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
socketProtocols: connection.socketProtocols,
|
||||||
|
socketAutoReconnect: connection.socketAutoReconnect,
|
||||||
|
}}
|
||||||
|
validationSchema={yup.object({
|
||||||
|
socketProtocols: ConnectionValidator.socketProtocols,
|
||||||
|
socketAutoReconnect: ConnectionValidator.socketAutoReconnect,
|
||||||
|
})}
|
||||||
|
validateOnChange={false}
|
||||||
|
validateOnBlur={false}
|
||||||
|
onSubmit={(value) => {
|
||||||
|
onWebSocketProtocolsChange(connection, value.socketProtocols);
|
||||||
|
onWebSocketAutoReconnectChange(connection, value.socketAutoReconnect);
|
||||||
|
popup.pop();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form>
|
||||||
|
<PopupBody>
|
||||||
|
<FormField
|
||||||
|
title="Websocket Protocols"
|
||||||
|
description={(
|
||||||
|
<>
|
||||||
|
Set the Sec-WebSocket-Protocol header when connecting to a WebSocket.
|
||||||
|
{' '}
|
||||||
|
It's not possible to set other headers, see
|
||||||
|
{' '}
|
||||||
|
<Link
|
||||||
|
href="https://stackoverflow.com/a/4361358"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
this
|
||||||
|
</Link>
|
||||||
|
{' '}
|
||||||
|
StackOverflow answer for more details.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FormTextInputArray
|
||||||
|
name="socketProtocols"
|
||||||
|
addItemCta="Add Protocol"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<Spacer />
|
||||||
|
<FormField
|
||||||
|
title="Auto Reconnect"
|
||||||
|
>
|
||||||
|
<FormCheckbox
|
||||||
|
name="socketAutoReconnect"
|
||||||
|
description="Attempt to reconnect to the WebSocket after being disconnected."
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</PopupBody>
|
||||||
|
<PopupButtons actions={[
|
||||||
|
{
|
||||||
|
label: 'Cancel',
|
||||||
|
theme: 'secondary',
|
||||||
|
onClick: () => popup.pop(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Save',
|
||||||
|
theme: 'primary',
|
||||||
|
type: 'submit',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
55
src/components/EditProject/EditProject.tsx
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import React, { useContext } from 'react';
|
||||||
|
import 'twin.macro';
|
||||||
|
import Project from '../../models/project';
|
||||||
|
import { PopupContext } from '../../providers/PopupProvider';
|
||||||
|
import List from '../General/List/List';
|
||||||
|
import ListItem from '../General/List/ListItem';
|
||||||
|
import EditProjectConnectionDefaults from './EditProjectConnectionDefaults';
|
||||||
|
import EditProjectGeneral from './EditProjectGeneral';
|
||||||
|
import { projectUpdate } from '../../redux/actions/projects';
|
||||||
|
|
||||||
|
export interface EditProjectProps {
|
||||||
|
project: Project,
|
||||||
|
onProjectChange: typeof projectUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditProject({
|
||||||
|
project,
|
||||||
|
onProjectChange,
|
||||||
|
}: EditProjectProps) {
|
||||||
|
const popup = useContext(PopupContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 tw="text-2xl p-4 text-gray-600 dark:text-gray-400 border-b border-gray-300 dark:border-gray-700">
|
||||||
|
{project.name}
|
||||||
|
</h1>
|
||||||
|
<List>
|
||||||
|
<ListItem
|
||||||
|
title="General"
|
||||||
|
subtitle="Project name and formatting"
|
||||||
|
onClick={() => popup.push(
|
||||||
|
'General',
|
||||||
|
EditProjectGeneral,
|
||||||
|
{
|
||||||
|
project,
|
||||||
|
onProjectChange,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="Connection Defaults"
|
||||||
|
subtitle="Default WebSocket URL, protocol header and auto-reconnect values"
|
||||||
|
onClick={() => popup.push(
|
||||||
|
'Connection Defaults',
|
||||||
|
EditProjectConnectionDefaults,
|
||||||
|
{
|
||||||
|
project,
|
||||||
|
onProjectChange,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
114
src/components/EditProject/EditProjectConnectionDefaults.tsx
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { Form, Formik } from 'formik';
|
||||||
|
import 'twin.macro';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
import Project from '../../models/project';
|
||||||
|
import ProjectValidator from '../../models/project/validator';
|
||||||
|
import PopupButtons from '../General/Popup/PopupButtons';
|
||||||
|
import { PopupContext } from '../../providers/PopupProvider';
|
||||||
|
import FormField from '../General/Form/FormField';
|
||||||
|
import FormTextInput from '../General/Form/FormTextInput';
|
||||||
|
import PopupBody from '../General/Popup/PopupBody';
|
||||||
|
import FormCheckbox from '../General/Form/FormCheckbox';
|
||||||
|
import FormTextInputArray from '../General/Form/FormTextInputArray';
|
||||||
|
import Spacer from '../General/Utilities/Spacer';
|
||||||
|
import Link from '../General/Styled/Link';
|
||||||
|
import { projectUpdate } from '../../redux/actions/projects';
|
||||||
|
|
||||||
|
export interface EditProjectProps {
|
||||||
|
project: Project,
|
||||||
|
onProjectChange: typeof projectUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditProject({
|
||||||
|
project,
|
||||||
|
onProjectChange,
|
||||||
|
}: EditProjectProps) {
|
||||||
|
const popup = useContext(PopupContext);
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
defaultSocketUrl: project.defaultSocketUrl,
|
||||||
|
defaultSocketProtocols: project.defaultSocketProtocols,
|
||||||
|
defaultSocketAutoReconnect: project.defaultSocketAutoReconnect,
|
||||||
|
}}
|
||||||
|
validationSchema={yup.object({
|
||||||
|
defaultSocketUrl: ProjectValidator.defaultSocketUrl,
|
||||||
|
defaultSocketProtocols: ProjectValidator.defaultSocketProtocols,
|
||||||
|
defaultSocketAutoReconnect: ProjectValidator.defaultSocketAutoReconnect,
|
||||||
|
})}
|
||||||
|
validateOnChange={false}
|
||||||
|
validateOnBlur={false}
|
||||||
|
onSubmit={(value) => {
|
||||||
|
onProjectChange(project, value);
|
||||||
|
popup.pop();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form>
|
||||||
|
<PopupBody>
|
||||||
|
<p tw="text-gray-900 dark:text-gray-100">
|
||||||
|
When a new connection is created, it is automatically populated
|
||||||
|
with the defaults listed below.
|
||||||
|
</p>
|
||||||
|
<Spacer />
|
||||||
|
<FormField
|
||||||
|
title="WebSocket URL"
|
||||||
|
>
|
||||||
|
<FormTextInput
|
||||||
|
name="defaultSocketUrl"
|
||||||
|
placeholder="WebSocket URL"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<Spacer />
|
||||||
|
<FormField
|
||||||
|
title="Websocket Protocols"
|
||||||
|
description={(
|
||||||
|
<>
|
||||||
|
Set the Sec-WebSocket-Protocol header when connecting to a WebSocket.
|
||||||
|
{' '}
|
||||||
|
It's not possible to set other headers, see
|
||||||
|
{' '}
|
||||||
|
<Link
|
||||||
|
href="https://stackoverflow.com/a/4361358"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
this
|
||||||
|
</Link>
|
||||||
|
{' '}
|
||||||
|
StackOverflow answer for more details.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FormTextInputArray
|
||||||
|
name="defaultSocketProtocols"
|
||||||
|
addItemCta="Add Protocol"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<Spacer />
|
||||||
|
<FormField
|
||||||
|
title="Auto Reconnect"
|
||||||
|
>
|
||||||
|
<FormCheckbox
|
||||||
|
name="defaultSocketAutoReconnect"
|
||||||
|
description="Attempt to reconnect to the WebSocket after being disconnected."
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</PopupBody>
|
||||||
|
<PopupButtons actions={[
|
||||||
|
{
|
||||||
|
label: 'Cancel',
|
||||||
|
theme: 'secondary',
|
||||||
|
onClick: () => popup.pop(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Save',
|
||||||
|
theme: 'primary',
|
||||||
|
type: 'submit',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
76
src/components/EditProject/EditProjectGeneral.tsx
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import React, { useContext } from 'react';
|
||||||
|
import { Form, Formik } from 'formik';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
import Project from '../../models/project';
|
||||||
|
import ProjectValidator from '../../models/project/validator';
|
||||||
|
import PopupButtons from '../General/Popup/PopupButtons';
|
||||||
|
import { PopupContext } from '../../providers/PopupProvider';
|
||||||
|
import FormField from '../General/Form/FormField';
|
||||||
|
import FormTextInput from '../General/Form/FormTextInput';
|
||||||
|
import PopupBody from '../General/Popup/PopupBody';
|
||||||
|
import FormCheckbox from '../General/Form/FormCheckbox';
|
||||||
|
import Spacer from '../General/Utilities/Spacer';
|
||||||
|
import { projectUpdate } from '../../redux/actions/projects';
|
||||||
|
|
||||||
|
export interface EditProjectProps {
|
||||||
|
project: Project,
|
||||||
|
onProjectChange: typeof projectUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditProject({
|
||||||
|
project,
|
||||||
|
onProjectChange,
|
||||||
|
}: EditProjectProps) {
|
||||||
|
const popup = useContext(PopupContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
name: project.name,
|
||||||
|
formatEventPayloads: project.formatEventPayloads,
|
||||||
|
}}
|
||||||
|
validationSchema={yup.object({
|
||||||
|
name: ProjectValidator.name,
|
||||||
|
formatEventPayloads: ProjectValidator.formatEventPayloads,
|
||||||
|
})}
|
||||||
|
validateOnChange={false}
|
||||||
|
validateOnBlur={false}
|
||||||
|
onSubmit={(value) => {
|
||||||
|
onProjectChange(project, value);
|
||||||
|
popup.pop();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form>
|
||||||
|
<PopupBody>
|
||||||
|
<FormField title="Project Name">
|
||||||
|
<FormTextInput
|
||||||
|
name="name"
|
||||||
|
placeholder="Project name"
|
||||||
|
maxLength={ProjectValidator.nameLength}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<Spacer />
|
||||||
|
<FormField title="Formatting">
|
||||||
|
<FormCheckbox
|
||||||
|
name="formatEventPayloads"
|
||||||
|
description="Prettify JSON payloads sent and received by WebSockets to improve readability."
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</PopupBody>
|
||||||
|
<PopupButtons actions={[
|
||||||
|
{
|
||||||
|
label: 'Cancel',
|
||||||
|
theme: 'secondary',
|
||||||
|
onClick: () => popup.pop(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Save',
|
||||||
|
theme: 'primary',
|
||||||
|
type: 'submit',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
23
src/components/EditProject/EditProjectReduxWrapper.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { bindActionCreators } from 'redux';
|
||||||
|
import { projectUpdate } from '../../redux/actions/projects';
|
||||||
|
import EditProject from './EditProject';
|
||||||
|
import State from '../../redux/state';
|
||||||
|
import { currentProject } from '../../redux/selectors/projects';
|
||||||
|
|
||||||
|
function mapStateToProps(state: State) {
|
||||||
|
return {
|
||||||
|
project: currentProject(state),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDispatchToProps(dispatch: any) {
|
||||||
|
return bindActionCreators(
|
||||||
|
{
|
||||||
|
onProjectChange: projectUpdate,
|
||||||
|
},
|
||||||
|
dispatch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(EditProject);
|
52
src/components/General/ContextMenu/ContextMenu.stories.tsx
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import React from 'react';
|
||||||
|
import 'twin.macro';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { Story, Meta } from '@storybook/react/types-6-0';
|
||||||
|
import ContextMenu, { ContextMenuProps } from './ContextMenu';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Context Menu',
|
||||||
|
component: ContextMenu,
|
||||||
|
argTypes: {
|
||||||
|
actions: {
|
||||||
|
contorl: {
|
||||||
|
type: 'object',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
contorl: {
|
||||||
|
type: 'object',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(component, { args }) => (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
tw="absolute w-4 h-4 bg-red-500 rounded-full"
|
||||||
|
style={{
|
||||||
|
left: `calc(${args.position[0]}px - 0.5rem)`,
|
||||||
|
top: `calc(${args.position[1]}px - 0.5rem)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{component()}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
const Template: Story<ContextMenuProps> = (args) => (
|
||||||
|
<ContextMenu {...args} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Primary = Template.bind({ });
|
||||||
|
|
||||||
|
Primary.args = {
|
||||||
|
actions: [
|
||||||
|
{ label: 'Action 1', onClick: action('Action 1 click') },
|
||||||
|
{ label: 'Action 2', onClick: action('Action 2 click') },
|
||||||
|
{ label: 'Action 3', onClick: action('Action 3 click') },
|
||||||
|
],
|
||||||
|
position: [200, 200],
|
||||||
|
close: action('Close triggered'),
|
||||||
|
};
|
74
src/components/General/ContextMenu/ContextMenu.tsx
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import React from 'react';
|
||||||
|
import 'twin.macro';
|
||||||
|
import ContextMenuAction from '../../../providers/context-menu-action';
|
||||||
|
|
||||||
|
export type ContextMenuProps = {
|
||||||
|
position: [number, number],
|
||||||
|
actions: ContextMenuAction[],
|
||||||
|
align: 'left' | 'right',
|
||||||
|
close: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ContextMenu({
|
||||||
|
position,
|
||||||
|
actions,
|
||||||
|
align,
|
||||||
|
close,
|
||||||
|
}: ContextMenuProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
tw="absolute inset-0"
|
||||||
|
onClick={() => close()}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
tw="flex flex-col absolute bg-white dark:bg-gray-850 dark:border dark:border-gray-700 rounded shadow w-56 overflow-hidden py-2"
|
||||||
|
style={{
|
||||||
|
left: `calc(${align === 'right' ? '-14rem' : '0px'} + ${position[0]}px)`,
|
||||||
|
top: `${position[1]}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{actions.map((action) => {
|
||||||
|
if (action === '-') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key="-"
|
||||||
|
tw="border-b my-1 border-gray-300 dark:border-gray-700"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof action === 'string') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={action}
|
||||||
|
tw="px-4 py-2 uppercase text-gray-500 text-xs font-semibold"
|
||||||
|
>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={action.key || action.label}
|
||||||
|
type="button"
|
||||||
|
tw="px-4 py-1 hover:bg-gray-200 hover:dark:bg-gray-800 text-left text-sm text-gray-700 dark:text-gray-200"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
close();
|
||||||
|
setTimeout(() => action?.onClick?.(event));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
54
src/components/General/Editor/Editor.tsx
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import React, { ReactEventHandler } from 'react';
|
||||||
|
import { theme } from 'twin.macro';
|
||||||
|
import SimpleCodeEditor from 'react-simple-code-editor';
|
||||||
|
import { highlight, languages } from 'prismjs';
|
||||||
|
import 'prismjs/components/prism-clike';
|
||||||
|
import 'prismjs/components/prism-json';
|
||||||
|
|
||||||
|
export interface EditorProps {
|
||||||
|
name?: string,
|
||||||
|
value: string,
|
||||||
|
onChange: (value: string) => void,
|
||||||
|
onBlur?: (event: ReactEventHandler) => void,
|
||||||
|
onFocus?: (event: ReactEventHandler) => void,
|
||||||
|
minLines: number,
|
||||||
|
maxLines: number,
|
||||||
|
placeholder?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Editor({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
onFocus,
|
||||||
|
placeholder,
|
||||||
|
minLines,
|
||||||
|
maxLines,
|
||||||
|
}: EditorProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="editor"
|
||||||
|
tw="overflow-y-auto"
|
||||||
|
style={{
|
||||||
|
minHeight: `${Number(theme`lineHeight.snug`) * minLines}rem`,
|
||||||
|
maxHeight: `${Number(theme`lineHeight.snug`) * maxLines}rem`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SimpleCodeEditor
|
||||||
|
name={name}
|
||||||
|
onValueChange={onChange}
|
||||||
|
value={value}
|
||||||
|
onFocus={(event: any) => onFocus?.(event)}
|
||||||
|
onBlur={(event: any) => onBlur?.(event)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
highlight={(code) => highlight(code, languages.json, 'json')}
|
||||||
|
tw="text-gray-800 dark:text-gray-200 font-mono text-sm leading-snug"
|
||||||
|
style={{
|
||||||
|
scrollBehavior: 'auto',
|
||||||
|
minHeight: `${Number(theme`lineHeight.snug`) * minLines}rem`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
27
src/components/General/Form/FormCheckbox.stories.tsx
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Story, Meta } from '@storybook/react/types-6-0';
|
||||||
|
import FormCheckbox, { FormCheckboxProps } from './FormCheckbox';
|
||||||
|
import FormikDecorator, { FormikDecoratorProps } from '../../../../.storybook/decorators/FormikDecorator';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Form / Checkbox',
|
||||||
|
component: FormCheckbox,
|
||||||
|
argTypes: { },
|
||||||
|
decorators: [
|
||||||
|
FormikDecorator,
|
||||||
|
],
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
const Template: Story<FormCheckboxProps & FormikDecoratorProps> = (args) => (
|
||||||
|
<FormCheckbox {...args} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Primary = Template.bind({ });
|
||||||
|
|
||||||
|
Primary.args = {
|
||||||
|
name: 'test',
|
||||||
|
description: 'Test description',
|
||||||
|
initialValues: {
|
||||||
|
test: true,
|
||||||
|
},
|
||||||
|
};
|
46
src/components/General/Form/FormCheckbox.tsx
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import React from 'react';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import { TiTick } from 'react-icons/all';
|
||||||
|
import { useField } from 'formik';
|
||||||
|
|
||||||
|
export interface FormCheckboxProps {
|
||||||
|
name: string,
|
||||||
|
description?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormCheckbox({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
}: FormCheckboxProps) {
|
||||||
|
const [field, , helpers] = useField({ name });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label tw="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg py-2 px-4 cursor-pointer">
|
||||||
|
<div tw="mr-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
helpers.setValue(!field.value);
|
||||||
|
helpers.setTouched(true);
|
||||||
|
}}
|
||||||
|
css={[
|
||||||
|
tw`w-6 h-6 rounded flex items-center justify-center text-xl`,
|
||||||
|
field.value && tw`bg-purple-600 dark:bg-purple-700 hover:bg-purple-500 hover:dark:bg-purple-600 text-white`,
|
||||||
|
!field.value && tw`bg-gray-200 dark:bg-gray-500 hover:bg-gray-300 hover:dark:bg-gray-400 text-gray-600 dark:text-gray-700`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TiTick />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div tw="flex-grow">
|
||||||
|
{description && (
|
||||||
|
<div
|
||||||
|
tw="text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
26
src/components/General/Form/FormEditor.stories.tsx
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Story, Meta } from '@storybook/react/types-6-0';
|
||||||
|
import FormEditor, { FormEditorProps } from './FormEditor';
|
||||||
|
import FormikDecorator, { FormikDecoratorProps } from '../../../../.storybook/decorators/FormikDecorator';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Form / Editor',
|
||||||
|
component: FormEditor,
|
||||||
|
argTypes: { },
|
||||||
|
decorators: [
|
||||||
|
FormikDecorator,
|
||||||
|
],
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
const Template: Story<FormEditorProps & FormikDecoratorProps> = (args) => (
|
||||||
|
<FormEditor {...args} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Primary = Template.bind({ });
|
||||||
|
|
||||||
|
Primary.args = {
|
||||||
|
name: 'test',
|
||||||
|
initialValues: {
|
||||||
|
test: '{ "json": "payload" }',
|
||||||
|
},
|
||||||
|
};
|
48
src/components/General/Form/FormEditor.tsx
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import { useField, ErrorMessage } from 'formik';
|
||||||
|
import Editor from '../Editor/Editor';
|
||||||
|
|
||||||
|
export interface FormEditorProps {
|
||||||
|
name: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormEditor({
|
||||||
|
name,
|
||||||
|
}: FormEditorProps) {
|
||||||
|
const [field, , helpers] = useField({ name });
|
||||||
|
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
css={[
|
||||||
|
tw`overflow-hidden bg-gray-100 dark:bg-gray-800 rounded-lg border-2 py-2 px-4`,
|
||||||
|
isFocused && tw`border-gray-400 dark:border-gray-600`,
|
||||||
|
!isFocused && tw`border-gray-200 dark:border-gray-700`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Editor
|
||||||
|
name={field.name}
|
||||||
|
onChange={(value) => {
|
||||||
|
helpers.setValue(value);
|
||||||
|
helpers.setTouched(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={(event) => {
|
||||||
|
setIsFocused(false);
|
||||||
|
field.onBlur(event);
|
||||||
|
}}
|
||||||
|
value={field.value}
|
||||||
|
minLines={10}
|
||||||
|
maxLines={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ErrorMessage
|
||||||
|
name={name}
|
||||||
|
component="div"
|
||||||
|
tw="pt-2 text-red-800 text-sm font-semibold"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
33
src/components/General/Form/FormField.stories.tsx
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Story, Meta } from '@storybook/react/types-6-0';
|
||||||
|
import FormikDecorator, { FormikDecoratorProps } from '../../../../.storybook/decorators/FormikDecorator';
|
||||||
|
import { Filled as FormTextInput } from './FormTextInput.stories';
|
||||||
|
import FormField, { FormFieldProps } from './FormField';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Form / Form Field',
|
||||||
|
component: FormField,
|
||||||
|
argTypes: { },
|
||||||
|
decorators: [
|
||||||
|
FormikDecorator,
|
||||||
|
],
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
const Template: Story<FormFieldProps & FormikDecoratorProps> = (args) => (
|
||||||
|
<FormField {...args}>
|
||||||
|
<FormTextInput name="test" initialValues={{ test: '' }} />
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const WithDescription = Template.bind({ });
|
||||||
|
|
||||||
|
WithDescription.args = {
|
||||||
|
title: 'Field Title',
|
||||||
|
description: 'Field Description or help text.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithoutDescription = Template.bind({ });
|
||||||
|
|
||||||
|
WithoutDescription.args = {
|
||||||
|
title: 'Field Title',
|
||||||
|
};
|
37
src/components/General/Form/FormField.tsx
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import 'twin.macro';
|
||||||
|
import Spacer from '../Utilities/Spacer';
|
||||||
|
|
||||||
|
export interface FormFieldProps {
|
||||||
|
title?: string,
|
||||||
|
description?: ReactNode,
|
||||||
|
children: ReactNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormField({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: FormFieldProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{title && (
|
||||||
|
<>
|
||||||
|
<span tw="uppercase font-semibold text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
<Spacer size="half" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<>
|
||||||
|
<p tw="text-sm text-gray-700 dark:text-gray-500">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
<Spacer />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
38
src/components/General/Form/FormTextInput.stories.tsx
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Story, Meta } from '@storybook/react/types-6-0';
|
||||||
|
import FormikDecorator, { FormikDecoratorProps } from '../../../../.storybook/decorators/FormikDecorator';
|
||||||
|
import FormTextInput, { FormTextInputProps } from './FormTextInput';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Form / Text Input',
|
||||||
|
component: FormTextInput,
|
||||||
|
argTypes: { },
|
||||||
|
decorators: [
|
||||||
|
FormikDecorator,
|
||||||
|
],
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
const Template: Story<FormTextInputProps & FormikDecoratorProps> = (args) => (
|
||||||
|
<FormTextInput {...args} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Filled = Template.bind({ });
|
||||||
|
|
||||||
|
Filled.args = {
|
||||||
|
name: 'test',
|
||||||
|
maxLength: 20,
|
||||||
|
initialValues: {
|
||||||
|
test: 'Value',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Placeholder = Template.bind({ });
|
||||||
|
|
||||||
|
Placeholder.args = {
|
||||||
|
name: 'test',
|
||||||
|
maxLength: 50,
|
||||||
|
placeholder: 'Placeholder',
|
||||||
|
initialValues: {
|
||||||
|
test: '',
|
||||||
|
},
|
||||||
|
};
|
34
src/components/General/Form/FormTextInput.tsx
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import React from 'react';
|
||||||
|
import 'twin.macro';
|
||||||
|
import { ErrorMessage, Field } from 'formik';
|
||||||
|
|
||||||
|
export interface FormTextInputProps {
|
||||||
|
name: string,
|
||||||
|
placeholder?: string,
|
||||||
|
maxLength?: number,
|
||||||
|
autoFocus?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormTextInput({
|
||||||
|
name,
|
||||||
|
placeholder,
|
||||||
|
maxLength,
|
||||||
|
autoFocus,
|
||||||
|
}: FormTextInputProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field
|
||||||
|
name={name}
|
||||||
|
tw="w-full bg-gray-100 dark:bg-gray-800 rounded-lg px-4 leading-8 border-2 border-gray-200 dark:border-gray-700 focus:border-gray-400 focus:dark:border-gray-600 text-gray-800 dark:text-gray-200"
|
||||||
|
placeholder={placeholder}
|
||||||
|
maxLength={maxLength}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
/>
|
||||||
|
<ErrorMessage
|
||||||
|
name={name}
|
||||||
|
component="div"
|
||||||
|
tw="pt-2 text-red-800 text-sm font-semibold"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
30
src/components/General/Form/FormTextInputArray.stories.tsx
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Story, Meta } from '@storybook/react/types-6-0';
|
||||||
|
import FormikDecorator, { FormikDecoratorProps } from '../../../../.storybook/decorators/FormikDecorator';
|
||||||
|
import FormTextInputArray, { FormTextInputArrayProps } from './FormTextInputArray';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Form / Text Input Array',
|
||||||
|
component: FormTextInputArray,
|
||||||
|
argTypes: { },
|
||||||
|
decorators: [
|
||||||
|
FormikDecorator,
|
||||||
|
],
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
const Template: Story<FormTextInputArrayProps & FormikDecoratorProps> = (args) => (
|
||||||
|
<FormTextInputArray {...args} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Primary = Template.bind({ });
|
||||||
|
|
||||||
|
Primary.args = {
|
||||||
|
name: 'test',
|
||||||
|
addItemCta: 'Add New Item',
|
||||||
|
initialValues: {
|
||||||
|
test: [
|
||||||
|
{ id: 'a', value: 'Test 1' },
|
||||||
|
{ id: 'b', value: 'Test 2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
107
src/components/General/Form/FormTextInputArray.tsx
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
import React, { Fragment, useState } from 'react';
|
||||||
|
import { MdClose } from 'react-icons/md';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { useField } from 'formik';
|
||||||
|
import ButtonSecondary from '../Styled/ButtonSecondary';
|
||||||
|
|
||||||
|
export interface FormTextInputArrayProps {
|
||||||
|
name: string,
|
||||||
|
maxLength?: number,
|
||||||
|
addItemCta: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormTextInputArray({
|
||||||
|
name,
|
||||||
|
maxLength,
|
||||||
|
addItemCta,
|
||||||
|
}: FormTextInputArrayProps) {
|
||||||
|
const [field, meta, helpers] = useField<{ id: string, value: string}[]>({ name });
|
||||||
|
const [focusedItem, setFocusedItem] = (
|
||||||
|
useState<{ id: string, value: string} | undefined>(undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
css={[
|
||||||
|
tw`w-full overflow-hidden bg-gray-100 dark:bg-gray-800 rounded-lg border-2 text-gray-800 dark:text-gray-300`,
|
||||||
|
focusedItem !== undefined && tw`border-gray-400 dark:border-gray-600`,
|
||||||
|
focusedItem === undefined && tw`border-gray-200 dark:border-gray-700`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{field.value.map((item) => (
|
||||||
|
<Fragment key={item.id}>
|
||||||
|
<div
|
||||||
|
className="group"
|
||||||
|
tw="flex"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
tw="w-full py-1 px-4 bg-transparent"
|
||||||
|
maxLength={maxLength}
|
||||||
|
value={item.value}
|
||||||
|
onFocus={() => setFocusedItem(item)}
|
||||||
|
onBlur={() => setFocusedItem(undefined)}
|
||||||
|
onChange={(event) => {
|
||||||
|
helpers.setValue(
|
||||||
|
field.value.map((existingItem) => (
|
||||||
|
item === existingItem
|
||||||
|
? {
|
||||||
|
id: existingItem.id,
|
||||||
|
value: event.target.value,
|
||||||
|
}
|
||||||
|
: existingItem
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
helpers.setTouched(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ButtonSecondary
|
||||||
|
type="button"
|
||||||
|
tw="mr-3 p-1 invisible group-hover:visible"
|
||||||
|
onClick={() => helpers.setValue(
|
||||||
|
field.value.filter((existingItem) => existingItem.id !== item.id),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MdClose />
|
||||||
|
</ButtonSecondary>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
css={[
|
||||||
|
tw`border-b border-gray-200 dark:border-gray-700 mx-4`,
|
||||||
|
focusedItem?.id === item.id && tw`border-gray-400 dark:border-gray-600`,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
<ButtonSecondary
|
||||||
|
type="button"
|
||||||
|
css={[
|
||||||
|
tw`w-full text-xs h-8 text-center`,
|
||||||
|
!!field.value.length && 'mt-2',
|
||||||
|
]}
|
||||||
|
onClick={() => (
|
||||||
|
helpers.setValue([
|
||||||
|
...field.value,
|
||||||
|
{ id: uuid(), value: '' },
|
||||||
|
])
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{addItemCta}
|
||||||
|
</ButtonSecondary>
|
||||||
|
</div>
|
||||||
|
{meta.error && (
|
||||||
|
<div
|
||||||
|
tw="pt-2 text-red-800 text-sm font-semibold"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
typeof meta.error === 'string'
|
||||||
|
? meta.error
|
||||||
|
: (meta.error as any)?.find((error: any) => !!error).value
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
32
src/components/General/Icons/SidebarIcon.tsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function SidebarIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="3"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
height="1em"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="2"
|
||||||
|
y="3"
|
||||||
|
width="20"
|
||||||
|
height="19"
|
||||||
|
rx="2"
|
||||||
|
ry="2"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="10"
|
||||||
|
y1="4"
|
||||||
|
x2="10"
|
||||||
|
y2="20"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
35
src/components/General/List/List.stories.tsx
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Story, Meta } from '@storybook/react/types-6-0';
|
||||||
|
import List, { ListProps } from './List';
|
||||||
|
import ContextMenuDecorator from '../../../../.storybook/decorators/ContextMenuDecorator';
|
||||||
|
import DropdownMenuDecorator from '../../../../.storybook/decorators/DropdownMenuDecorator';
|
||||||
|
import * as ListItem from './ListItem.stories';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'List / List',
|
||||||
|
component: List,
|
||||||
|
decorators: [
|
||||||
|
DropdownMenuDecorator,
|
||||||
|
ContextMenuDecorator,
|
||||||
|
],
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
const Template: Story<ListProps> = (args) => (
|
||||||
|
<List>
|
||||||
|
{args.children}
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Primary = Template.bind({ });
|
||||||
|
|
||||||
|
Primary.args = {
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<ListItem.Primary {...ListItem.Primary.args as any} />
|
||||||
|
<ListItem.WithLeftClickActions {...ListItem.WithLeftClickActions.args as any} />
|
||||||
|
<ListItem.WithRightClickActions {...ListItem.WithRightClickActions.args as any} />
|
||||||
|
<ListItem.WithSingleLeftClickAction {...ListItem.WithSingleLeftClickAction.args as any} />
|
||||||
|
<ListItem.WithSubtitle {...ListItem.WithSubtitle.args as any} />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
15
src/components/General/List/List.tsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface ListProps {
|
||||||
|
children: ReactNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function List({
|
||||||
|
children,
|
||||||
|
}: ListProps) {
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
59
src/components/General/List/ListItem.stories.tsx
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { Story, Meta } from '@storybook/react/types-6-0';
|
||||||
|
import ListItem, { ListItemProps } from './ListItem';
|
||||||
|
import ContextMenuDecorator from '../../../../.storybook/decorators/ContextMenuDecorator';
|
||||||
|
import DropdownMenuDecorator from '../../../../.storybook/decorators/DropdownMenuDecorator';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'List / List Item',
|
||||||
|
component: ListItem,
|
||||||
|
decorators: [
|
||||||
|
DropdownMenuDecorator,
|
||||||
|
ContextMenuDecorator,
|
||||||
|
],
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
const Template: Story<ListItemProps> = (args) => (
|
||||||
|
<ListItem {...args} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Primary = Template.bind({ });
|
||||||
|
|
||||||
|
Primary.args = {
|
||||||
|
title: 'List item title',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithSubtitle = Template.bind({ });
|
||||||
|
|
||||||
|
WithSubtitle.args = {
|
||||||
|
...Primary.args,
|
||||||
|
subtitle: 'List item subtitle',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithSingleLeftClickAction = Template.bind({ });
|
||||||
|
|
||||||
|
WithSingleLeftClickAction.args = {
|
||||||
|
...Primary.args,
|
||||||
|
onClick: action('Single left click'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithLeftClickActions = Template.bind({ });
|
||||||
|
|
||||||
|
WithLeftClickActions.args = {
|
||||||
|
...Primary.args,
|
||||||
|
primaryClickActions: [
|
||||||
|
{ label: 'Action 1', onClick: action('Action 1 click') },
|
||||||
|
{ label: 'Action 2', onClick: action('Action 2 click') },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithRightClickActions = Template.bind({ });
|
||||||
|
|
||||||
|
WithRightClickActions.args = {
|
||||||
|
...Primary.args,
|
||||||
|
secondaryClickActions: [
|
||||||
|
{ label: 'Action 1', onClick: action('Action 1 click') },
|
||||||
|
{ label: 'Action 2', onClick: action('Action 2 click') },
|
||||||
|
],
|
||||||
|
};
|
15
src/components/General/List/ListItem.test.tsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, testId } from '../../../tests/enzyme';
|
||||||
|
import ListItem from './ListItem';
|
||||||
|
|
||||||
|
describe('ListItem', () => {
|
||||||
|
it('displays the title', () => {
|
||||||
|
const wrapper = render(<ListItem title="Test Title" />);
|
||||||
|
expect(wrapper.find(testId('title')).text()).toEqual('Test Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays the subtitle', () => {
|
||||||
|
const wrapper = render(<ListItem title="Test Title" subtitle="Subtitle" />);
|
||||||
|
expect(wrapper.find(testId('subtitle')).text()).toEqual('Subtitle');
|
||||||
|
});
|
||||||
|
});
|
120
src/components/General/List/ListItem.tsx
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
import React, {
|
||||||
|
ReactElement,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { FaEllipsisH } from 'react-icons/fa';
|
||||||
|
import tw from 'twin.macro';
|
||||||
|
import { ContextMenuContext } from '../../../providers/ContextMenuProvider';
|
||||||
|
import { DropdownMenuContext } from '../../../providers/DropdownMenuProvider';
|
||||||
|
import ContextMenuAction from '../../../providers/context-menu-action';
|
||||||
|
|
||||||
|
export interface ListItemProps {
|
||||||
|
title: ReactElement | string,
|
||||||
|
subtitle?: ReactElement | string,
|
||||||
|
isSelected?: boolean,
|
||||||
|
onClick?: (event: React.MouseEvent) => void,
|
||||||
|
primaryClickActions?: ContextMenuAction[],
|
||||||
|
secondaryClickActions?: ContextMenuAction[],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ListItem({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
isSelected: isSelectedExternal,
|
||||||
|
onClick,
|
||||||
|
primaryClickActions,
|
||||||
|
secondaryClickActions,
|
||||||
|
}: ListItemProps) {
|
||||||
|
const contextMenu = useContext(ContextMenuContext);
|
||||||
|
const dropdownMenu = useContext(DropdownMenuContext);
|
||||||
|
|
||||||
|
const [isSelected, setIsSelected] = useState<boolean>(false);
|
||||||
|
const [isDropdownSelected, setIsDropdownSelected] = useState<boolean>(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className="group"
|
||||||
|
css={[
|
||||||
|
tw`flex border-b last:border-b-0 border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-100 hover:dark:bg-gray-800`,
|
||||||
|
(isSelected || isSelectedExternal) && tw`bg-gray-100 dark:bg-gray-800`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async (event) => {
|
||||||
|
if (primaryClickActions?.length) {
|
||||||
|
setIsSelected(true);
|
||||||
|
await contextMenu.openForMouseEvent(
|
||||||
|
event,
|
||||||
|
primaryClickActions,
|
||||||
|
);
|
||||||
|
setIsSelected(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick?.(event);
|
||||||
|
}}
|
||||||
|
onContextMenu={async (event) => {
|
||||||
|
if (secondaryClickActions?.length) {
|
||||||
|
setIsSelected(true);
|
||||||
|
await contextMenu.openForMouseEvent(
|
||||||
|
event,
|
||||||
|
secondaryClickActions,
|
||||||
|
);
|
||||||
|
setIsSelected(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tw="w-full flex flex-row items-center py-2 px-4"
|
||||||
|
>
|
||||||
|
<div tw="flex flex-col flex-grow text-left">
|
||||||
|
<div
|
||||||
|
tw="text-gray-800 dark:text-gray-200"
|
||||||
|
data-testid="title"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div
|
||||||
|
tw="pt-2 text-gray-500 dark:text-gray-400 text-xs"
|
||||||
|
data-testid="subtitle"
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!!secondaryClickActions?.length && (
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
css={[
|
||||||
|
tw`ml-2 text-xs text-gray-700 dark:text-gray-300 p-1 ml-2 hover:bg-gray-300 hover:dark:bg-gray-700`,
|
||||||
|
!isDropdownSelected && tw`invisible group-hover:visible`,
|
||||||
|
isDropdownSelected && tw`bg-gray-300 dark:bg-gray-900 visible`,
|
||||||
|
]}
|
||||||
|
onClick={async (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setIsSelected(true);
|
||||||
|
setIsDropdownSelected(true);
|
||||||
|
await dropdownMenu.openForElement(
|
||||||
|
event.currentTarget as HTMLElement,
|
||||||
|
secondaryClickActions!,
|
||||||
|
);
|
||||||
|
setIsSelected(false);
|
||||||
|
setIsDropdownSelected(false);
|
||||||
|
}}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaEllipsisH />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem.defaultProps = {
|
||||||
|
primaryClickActions: [],
|
||||||
|
secondaryClickActions: [],
|
||||||
|
};
|
29
src/components/General/NotificationList/NotificationList.tsx
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import React, { Fragment } from 'react';
|
||||||
|
import 'twin.macro';
|
||||||
|
import Notification from '../../../types/UserInterface/Notification';
|
||||||
|
import Spacer from '../Utilities/Spacer';
|
||||||
|
import NotificationListItem from './NotificationListItem';
|
||||||
|
|
||||||
|
export interface NotificationListProps {
|
||||||
|
notifications: Notification[],
|
||||||
|
onClose: (notification: Notification) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationList({
|
||||||
|
notifications,
|
||||||
|
onClose,
|
||||||
|
}: NotificationListProps) {
|
||||||
|
return (
|
||||||
|
<div tw="p-4">
|
||||||
|
{notifications.map((notification, index) => (
|
||||||
|
<Fragment key={notification.id}>
|
||||||
|
<NotificationListItem
|
||||||
|
notification={notification}
|
||||||
|
onClose={() => onClose(notification)}
|
||||||
|
/>
|
||||||
|
{index !== notifications.length - 1 && <Spacer />}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
import React from 'react';
|
||||||
|
import 'twin.macro';
|
||||||
|
import { MdClose } from 'react-icons/md';
|
||||||
|
import ButtonPrimary from '../Styled/ButtonPrimary';
|
||||||
|
import ButtonSecondary from '../Styled/ButtonSecondary';
|
||||||
|
import Notification from '../../../types/UserInterface/Notification';
|
||||||
|
|
||||||
|
export interface NotificationListItemProps {
|
||||||
|
notification: Notification,
|
||||||
|
onClose: () => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationListItem({
|
||||||
|
notification,
|
||||||
|
onClose,
|
||||||
|
}: NotificationListItemProps) {
|
||||||
|
return (
|
||||||
|
<div tw="bg-gray-100 dark:bg-gray-900 dark:border dark:border-gray-700 rounded-lg border shadow p-4 w-96">
|
||||||
|
<div tw="flex justify-between">
|
||||||
|
<strong tw="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{notification.title}
|
||||||
|
</strong>
|
||||||
|
<ButtonSecondary tw="p-1">
|
||||||
|
<MdClose onClick={() => onClose()} />
|
||||||
|
</ButtonSecondary>
|
||||||
|
</div>
|
||||||
|
<p tw="my-2 select-text text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{notification.body}
|
||||||
|
</p>
|
||||||
|
<div tw="flex justify-end">
|
||||||
|
{notification.actions.map((action) => (
|
||||||
|
action.theme === 'primary'
|
||||||
|
? (
|
||||||
|
<ButtonPrimary
|
||||||
|
key={action.label}
|
||||||
|
tw="ml-2 py-1 px-2 rounded"
|
||||||
|
onClick={() => {
|
||||||
|
action.onClick?.();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</ButtonPrimary>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<ButtonSecondary
|
||||||
|
key={action.label}
|
||||||
|
tw="ml-2 py-1 px-2 rounded"
|
||||||
|
onClick={() => {
|
||||||
|
action.onClick?.();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</ButtonSecondary>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
53
src/components/General/Popup/Popup.tsx
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import React from 'react';
|
||||||
|
import 'twin.macro';
|
||||||
|
import { MdClose } from 'react-icons/md';
|
||||||
|
import { BsArrowRightShort } from 'react-icons/all';
|
||||||
|
import PopupManager from '../../../types/UserInterface/PopupManager';
|
||||||
|
|
||||||
|
export interface PopupProps {
|
||||||
|
popup: PopupManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Popup({
|
||||||
|
popup,
|
||||||
|
}: PopupProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
onClick={() => popup.popToRoot()}
|
||||||
|
tw="flex justify-center overflow-y-auto py-10 items-start bg-gray-900 dark:bg-white fixed inset-0"
|
||||||
|
style={{ background: 'rgba(0, 0, 0, .15)', backdropFilter: 'grayscale(50%)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
tw="flex flex-col bg-white dark:bg-gray-900 w-full max-w-lg rounded overflow-hidden shadow-md dark:border dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<div tw="p-2 flex flex-row items-center justify-between bg-gray-800 dark:bg-gray-850 text-white">
|
||||||
|
<div tw="p-2 flex items-center uppercase text-sm font-semibold">
|
||||||
|
{popup.title?.map((title, index) => (
|
||||||
|
<React.Fragment key={title}>
|
||||||
|
<span>{title}</span>
|
||||||
|
{index !== popup.title!.length - 1 && (
|
||||||
|
<span tw="text-gray-500 text-base mx-2 inline-block">
|
||||||
|
<BsArrowRightShort />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => popup.popToRoot()}
|
||||||
|
tw="flex-grow-0 p-1 mr-2 hover:bg-gray-700 cursor-pointer"
|
||||||
|
>
|
||||||
|
<MdClose />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div tw="overflow-auto">
|
||||||
|
{popup.component}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
16
src/components/General/Popup/PopupBody.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import 'twin.macro';
|
||||||
|
|
||||||
|
export interface PopupBodyProps {
|
||||||
|
children: ReactNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PopupBody({
|
||||||
|
children,
|
||||||
|
}: PopupBodyProps) {
|
||||||
|
return (
|
||||||
|
<div tw="p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
43
src/components/General/Popup/PopupButtons.tsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import React from 'react';
|
||||||
|
import 'twin.macro';
|
||||||
|
import ButtonAction from '../../../types/UserInterface/ButtonAction';
|
||||||
|
import ButtonPrimary from '../Styled/ButtonPrimary';
|
||||||
|
import ButtonSecondary from '../Styled/ButtonSecondary';
|
||||||
|
|
||||||
|
export interface PopupButtonsProps {
|
||||||
|
actions: ButtonAction[],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PopupButtons({
|
||||||
|
actions,
|
||||||
|
}: PopupButtonsProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
tw="flex justify-end px-4 py-2 bg-gray-100 dark:bg-gray-850 border-t dark:border-none"
|
||||||
|
>
|
||||||
|
{actions.map((action) => (
|
||||||
|
action.theme === 'primary'
|
||||||
|
? (
|
||||||
|
<ButtonPrimary
|
||||||
|
key={action.label}
|
||||||
|
type={action.type || 'button'}
|
||||||
|
onClick={() => action.onClick?.()}
|
||||||
|
tw="ml-2 py-1 px-4 rounded"
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</ButtonPrimary>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<ButtonSecondary
|
||||||
|
key={action.label}
|
||||||
|
type={action.type || 'button'}
|
||||||
|
onClick={() => action.onClick?.()}
|
||||||
|
tw="ml-2 py-1 px-4 rounded"
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</ButtonSecondary>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
39
src/components/General/PopupPresets/PopupConfirmation.tsx
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import React, { useContext } from 'react';
|
||||||
|
import 'twin.macro';
|
||||||
|
import PopupButtons from '../Popup/PopupButtons';
|
||||||
|
import { PopupContext } from '../../../providers/PopupProvider';
|
||||||
|
import PopupBody from '../Popup/PopupBody';
|
||||||
|
|
||||||
|
export interface PopupConfirmationProps {
|
||||||
|
message: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PopupConfirmation({
|
||||||
|
message,
|
||||||
|
}: PopupConfirmationProps) {
|
||||||
|
const popup = useContext(PopupContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PopupBody>
|
||||||
|
<p tw="text-gray-800 dark:text-gray-200">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</PopupBody>
|
||||||
|
<PopupButtons
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'No',
|
||||||
|
theme: 'secondary',
|
||||||
|
onClick: () => popup.pop(false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Yes',
|
||||||
|
theme: 'primary',
|
||||||
|
onClick: () => popup.pop(true),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
68
src/components/General/PopupPresets/PopupPrompt.tsx
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import React, { useContext } from 'react';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
import { Formik, Form } from 'formik';
|
||||||
|
import PopupButtons from '../Popup/PopupButtons';
|
||||||
|
import { PopupContext } from '../../../providers/PopupProvider';
|
||||||
|
import FormTextInput from '../Form/FormTextInput';
|
||||||
|
import PopupBody from '../Popup/PopupBody';
|
||||||
|
|
||||||
|
export interface PopupPromptProps {
|
||||||
|
label: string,
|
||||||
|
submitLabel: string,
|
||||||
|
defaultValue?: string,
|
||||||
|
yupValidator?: yup.Schema<any>,
|
||||||
|
maxLength?: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PopupPrompt({
|
||||||
|
label,
|
||||||
|
submitLabel,
|
||||||
|
defaultValue,
|
||||||
|
yupValidator,
|
||||||
|
maxLength,
|
||||||
|
}: PopupPromptProps) {
|
||||||
|
const popup = useContext(PopupContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
field: defaultValue || '',
|
||||||
|
}}
|
||||||
|
validationSchema={yup.object({
|
||||||
|
field: yupValidator || yup.string(),
|
||||||
|
})}
|
||||||
|
validateOnChange={false}
|
||||||
|
validateOnBlur={false}
|
||||||
|
onSubmit={({ field }) => popup.pop(field)}
|
||||||
|
>
|
||||||
|
<Form>
|
||||||
|
<PopupBody>
|
||||||
|
<FormTextInput
|
||||||
|
placeholder={label}
|
||||||
|
name="field"
|
||||||
|
maxLength={maxLength}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</PopupBody>
|
||||||
|
<PopupButtons
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Cancel',
|
||||||
|
theme: 'secondary',
|
||||||
|
onClick: () => popup.pop(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: submitLabel,
|
||||||
|
theme: 'primary',
|
||||||
|
type: 'submit',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PopupPrompt.defaultValues = {
|
||||||
|
defaultValue: '',
|
||||||
|
};
|
23
src/components/General/Styled/ButtonPrimary.stories.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { Story, Meta } from '@storybook/react/types-6-0';
|
||||||
|
import ButtonPrimary from './ButtonPrimary';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Styled / Button Primary',
|
||||||
|
component: ButtonPrimary,
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
const Template: Story = (args) => (
|
||||||
|
<ButtonPrimary {...args}>
|
||||||
|
{args.children}
|
||||||
|
</ButtonPrimary>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Primary = Template.bind({ });
|
||||||
|
|
||||||
|
Primary.args = {
|
||||||
|
children: 'Button Text',
|
||||||
|
disabled: false,
|
||||||
|
onClick: action('Button click'),
|
||||||
|
};
|
14
src/components/General/Styled/ButtonPrimary.tsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import tw, { styled } from 'twin.macro';
|
||||||
|
|
||||||
|
export interface ButtonPrimaryProps {
|
||||||
|
disabled?: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ButtonPrimary = styled.button(({
|
||||||
|
disabled,
|
||||||
|
}: ButtonPrimaryProps) => [
|
||||||
|
tw`bg-purple-700 dark:bg-purple-800 hover:bg-purple-600 hover:dark:bg-purple-700 text-white font-semibold dark:border-2 dark:border-purple-700`,
|
||||||
|
disabled && tw`opacity-50 pointer-events-none`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default ButtonPrimary;
|
23
src/components/General/Styled/ButtonSecondary.stories.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { Story, Meta } from '@storybook/react/types-6-0';
|
||||||
|
import ButtonSecondary from './ButtonSecondary';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Styled / Button Secondary',
|
||||||
|
component: ButtonSecondary,
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
const Template: Story = (args) => (
|
||||||
|
<ButtonSecondary {...args}>
|
||||||
|
{args.children}
|
||||||
|
</ButtonSecondary>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Primary = Template.bind({ });
|
||||||
|
|
||||||
|
Primary.args = {
|
||||||
|
children: 'Button Text',
|
||||||
|
disabled: false,
|
||||||
|
onClick: action('Button click'),
|
||||||
|
};
|