Merge pull request #17 from tomlerendu/master

Master
This commit is contained in:
Tom Lerendu 2021-06-16 14:11:47 +01:00 committed by GitHub
commit cb1c5f6167
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
253 changed files with 24548 additions and 2 deletions

8
.env.dev.example Normal file
View 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
View 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

View 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
View 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
View 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
View 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
View 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

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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 />
</>
),
];

View file

@ -1,3 +1,43 @@
# Websocket King Issues
# Websocket King
This repo is for reporting bugs and requesting features.
## 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
![Banner](public/images/banner-small.png)
## 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 cant go back!**
If you arent 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 youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.

104
package.json Normal file
View 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"
}
}

View file

@ -0,0 +1,3 @@
chrome.browserAction.onClicked.addListener(() => {
window.open(chrome.extension.getURL('index.html'));
});

View 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
View 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.

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
public/images/logo128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/images/logo16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

BIN
public/images/logo19.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 713 B

BIN
public/images/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
public/images/logo32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/images/logo38.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
public/images/logo48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
public/images/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
public/images/logo64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

20
public/index.html Normal file
View 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
View 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
View file

@ -0,0 +1 @@
User-agent: *

17
scripts/chrome.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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);

View 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}
</>
);
}

View 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}
</>
);
}

View 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;

View 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;

View 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;

View 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;

View 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'};
}
}
`,
],
);

View 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>
);
}

View 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;
`;

View 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>
);
}

View 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);

View 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>
);
}

View 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);

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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}</>;
}

View 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>
</>
);
}

View 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);

View 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>
);
}

View 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);

View 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>
);
}

View 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>
);
}

View file

@ -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);

View 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&#39;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>
);
}

View 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>
</>
);
}

View 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&#39;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>
);
}

View 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>
);
}

View 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);

View 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'),
};

View 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>
);
}

View 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>
);
}

View 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,
},
};

View 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>
);
}

View 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" }',
},
};

View 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"
/>
</>
);
}

View 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',
};

View 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>
);
}

View 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: '',
},
};

View 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"
/>
</>
);
}

View 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' },
],
},
};

View 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>
)}
</>
);
}

View 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>
);
}

View 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} />
</>
),
};

View 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>
);
}

View 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') },
],
};

View 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');
});
});

View 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: [],
};

View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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),
},
]}
/>
</>
);
}

View 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: '',
};

View 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'),
};

View 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;

View 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'),
};

Some files were not shown because too many files have changed in this diff Show more