diff --git a/.storybook/addons.js b/.storybook/addons.js deleted file mode 100644 index bc646c9..0000000 --- a/.storybook/addons.js +++ /dev/null @@ -1,3 +0,0 @@ -import '@storybook/addon-actions/register'; -import '@storybook/addon-links/register'; -import '@storybook/addon-knobs/register'; diff --git a/.storybook/config.tsx b/.storybook/config.tsx.bak similarity index 95% rename from .storybook/config.tsx rename to .storybook/config.tsx.bak index 31836a2..d471197 100644 --- a/.storybook/config.tsx +++ b/.storybook/config.tsx.bak @@ -2,6 +2,7 @@ import { configure, addParameters, addDecorator } from '@storybook/react'; import { makeDecorator } from '@storybook/addons'; import { themes } from '@storybook/theming'; import styled, { ThemeProvider } from 'styled-components'; +import 'bootstrap/dist/css/bootstrap.min.css'; import { DEFAULT_THEME, getSetting } from '../src/theme'; // automatically import all files ending in *.stories.tsx diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000..b3ae9f6 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,22 @@ +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [ + '@storybook/addon-links', + '@storybook/addon-essentials', + '@storybook/addon-onboarding', + '@storybook/addon-interactions', + ], + framework: { + name: '@storybook/react-vite', + options: {}, + }, + core: { + disableTelemetry: true, + }, + docs: { + autodocs: 'tag', + }, +}; +export default config; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 0000000..b9c1f58 --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import type { Preview } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { useGlobals, useParameter } from '@storybook/manager-api'; +import { ThemeContextProvider, testables } from '../src/Contexts'; +import { MemoryRouter } from 'react-router'; +import { DEFAULT_THEME, THEME_LIST, getSetting } from '../src/theme'; + +const { ConfigurationContext } = testables; + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + backgrounds: { + disable: true, + }, + }, + + globalTypes: { + theme: { + description: 'Global theme for components', + defaultValue: DEFAULT_THEME.name, + toolbar: { + // The label to show for this toolbar item + title: 'Theme', + icon: 'circlehollow', + // Array of plain string values or MenuItem shape (see below) + items: Object.keys(THEME_LIST), + // Change title based on selected value + dynamicTitle: true, + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + (Story, { globals: { theme } }) => { + // const themeName = useParameter('backgrounds'); + + // console.log('themeName', themeName); + + return ( + { + action('addConnectionToConfig')(connection); + }, + }} + > + + + + + ); + }, + // force storybook background to be the same as the theme + (Story, { globals: { theme } }) => ( + <> + + + + ), + ], +}; + +export default preview; diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js deleted file mode 100644 index 50cdc79..0000000 --- a/.storybook/webpack.config.js +++ /dev/null @@ -1,16 +0,0 @@ -module.exports = ({ config }) => { - config.module.rules.push({ - test: /\.(ts|tsx)$/, - use: [ - { - loader: require.resolve('awesome-typescript-loader'), - }, - // Optional - { - loader: require.resolve('react-docgen-typescript-loader'), - }, - ], - }); - config.resolve.extensions.push('.ts', '.tsx'); - return config; -}; diff --git a/package.json b/package.json index 119d171..7d9fcc6 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "lint": "yarn lint:eslint && yarn lint:types", "lint:eslint": "eslint --quiet .", "lint:types": "tsc --noEmit", - "test": "vitest" + "test": "vitest", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" }, "keywords": [], "author": { @@ -39,6 +41,14 @@ "@electron-forge/maker-zip": "^7.2.0", "@electron-forge/plugin-auto-unpack-natives": "^7.2.0", "@electron-forge/plugin-vite": "^7.2.0", + "@storybook/addon-essentials": "^7.6.17", + "@storybook/addon-interactions": "^7.6.17", + "@storybook/addon-links": "^7.6.17", + "@storybook/addon-onboarding": "^1.0.11", + "@storybook/blocks": "^7.6.17", + "@storybook/react": "^7.6.17", + "@storybook/react-vite": "^7.6.17", + "@storybook/test": "^7.6.17", "@types/mysql": "^2.15.25", "@types/node": "^20.11.19", "@types/react": "^18.2.57", @@ -49,8 +59,10 @@ "electron-devtools-installer": "^3.2.0", "eslint": "^8.56.0", "eslint-plugin-import": "^2.29.1", + "eslint-plugin-storybook": "^0.8.0", "mysql2": "^3.9.1", "react-router-dom": "^6.22.1", + "storybook": "^7.6.17", "ts-node": "^10.9.2", "typescript": "~5.3.3", "vitest": "^1.3.1" diff --git a/src/Contexts.tsx b/src/Contexts.tsx index 4da1535..3ce7783 100644 --- a/src/Contexts.tsx +++ b/src/Contexts.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, useEffect, useState } from 'react'; -import { ThemeProvider } from 'styled-components'; +import styled, { ThemeProvider } from 'styled-components'; import { ConfigProvider, theme as antdTheme } from 'antd'; import { Configuration } from './configuration/type'; import { @@ -9,6 +9,7 @@ import { isDarkTheme, THEME_LIST, } from './theme'; +import { ConnectionObject } from './component/Connection/types'; export interface ConnectToFunc { (params: object): void; @@ -27,6 +28,7 @@ export const ConnectionContext = createContext({ // eslint-disable-next-line @typescript-eslint/no-empty-function setCurrentConnectionName: () => {}, }); +ConnectionContext.displayName = 'ConnectionContext'; export interface SetDatabaseFunc { (theme: string): void; @@ -34,13 +36,16 @@ export interface SetDatabaseFunc { interface DatabaseContextProps { database: string | null; setDatabase: SetDatabaseFunc; + executeQuery: (query: string) => Promise; } export const DatabaseContext = createContext({ database: null, // eslint-disable-next-line @typescript-eslint/no-empty-function setDatabase: () => {}, + executeQuery: () => Promise.resolve(), }); +DatabaseContext.displayName = 'DatabaseContext'; interface ChangeThemeFunc { (theme: string): void; @@ -54,18 +59,28 @@ const ThemeContext = createContext({ // eslint-disable-next-line @typescript-eslint/no-empty-function changeTheme: () => {}, }); +ThemeContext.displayName = 'ThemeContext'; export function useTheme() { return useContext(ThemeContext); } +const LayoutDiv = styled.div` + width: 100%; + height: 100vh; + display: flex; + flex-direction: column; + background: ${(props) => getSetting(props.theme, 'background')}; + color: ${(props) => getSetting(props.theme, 'foreground')}; +`; + export function ThemeContextProvider({ children, }: { children: React.ReactNode; }): React.ReactElement { - const config = useConfiguration(); - const [themeName, setThemeName] = useState(config.theme); + const { configuration } = useConfiguration(); + const [themeName, setThemeName] = useState(configuration.theme); const changeTheme = (newTheme: string) => { window.config.changeTheme(newTheme); @@ -100,14 +115,22 @@ export function ThemeContextProvider({ }, }} > - {children} + {children} ); } -const ConfigurationContext = createContext(null); +type ConfigurationContextType = { + configuration: Configuration; + addConnectionToConfig: (connection: ConnectionObject) => void; +}; + +const ConfigurationContext = createContext( + null +); +ConfigurationContext.displayName = 'ConfigurationContext'; export function ConfigurationContextProvider({ children, @@ -131,14 +154,21 @@ export function ConfigurationContextProvider({ return null; } + const value: ConfigurationContextType = { + configuration, + addConnectionToConfig: (connection: ConnectionObject) => { + window.config.addConnectionToConfig(connection); + }, + }; + return ( - + {children} ); } -export function useConfiguration(): Configuration { +export function useConfiguration(): ConfigurationContextType { const value = useContext(ConfigurationContext); if (!value) { @@ -149,3 +179,7 @@ export function useConfiguration(): Configuration { return value; } + +export const testables = { + ConfigurationContext, +}; diff --git a/src/component/Cell.stories.tsx b/src/component/Cell.stories.tsx new file mode 100644 index 0000000..c192db7 --- /dev/null +++ b/src/component/Cell.stories.tsx @@ -0,0 +1,144 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import Cell from './Cell'; +import { ConnectionContext, DatabaseContext } from '../Contexts'; +import { Types } from 'mysql'; + +const meta: Meta = { + component: Cell, + decorators: [ + (Story) => ( + {}, + connectTo: (connection) => { + action('connectTo')(connection); + }, + }} + > + {}, + executeQuery: async (query) => { + action('executeQuery')(query); + + return Promise.resolve([ + [{ Name: 'foo' }, { Name: 'bar' }, { Name: 'baz' }], + ]); + }, + }} + > + + + + ), + ], + parameters: { + controls: { exclude: ['type'] }, + }, +}; + +export default meta; +type Story = StoryObj; + +/* + *👇 Render functions are a framework specific feature to allow you control on how the component renders. + * See https://storybook.js.org/docs/api/csf + * to learn how to use render functions. + */ +export const WithNULLValue: Story = { + args: { + type: Types.VARCHAR, + value: null, + }, + argTypes: { + value: { control: { type: null } }, + }, +}; + +export const WithStringType: Story = { + args: { + type: Types.VARCHAR, + value: 'VARCHAR value', + }, +}; + +export const WithNumberType: Story = { + args: { + type: Types.FLOAT, + value: 123.45, + }, +}; + +export const WithDateType: Story = { + args: { + type: Types.DATETIME, + value: new Date('Jan 20 2020'), + }, +}; + +export const WithBlobType: Story = { + args: { + type: Types.BLOB, + value: 'BLOB value', + }, +}; + +export const WithJSONType: Story = { + args: { + type: Types.JSON, + value: JSON.stringify({ backgroundColor: 'red' }), + }, +}; + +export const WithENUMType: Story = { + args: { + type: Types.ENUM, + value: 'ENUM value', + }, +}; + +// stories.add('with NULL value', () => ( +// +// )); + +// stories.add('with string type', () => ( +// +// )); + +// stories.add('with number type', () => ( +// +// )); + +// stories.add('with date type', () => { +// const defaultDate = new Date('Jan 20 2020'); +// const dateString = date('DATETIME', defaultDate); + +// return ; +// }); + +// stories.add('with blob type', () => ( +// +// )); + +// stories.add('with JSON type', () => { +// const label = 'Styles'; +// const defaultValue = { +// backgroundColor: 'red', +// }; + +// return ( +// +// ); +// }); + +// stories.add('with ENUM/SET type', () => ( +// +// )); diff --git a/src/component/Connection/ConnectionForm.stories.tsx b/src/component/Connection/ConnectionForm.stories.tsx new file mode 100644 index 0000000..faa1be0 --- /dev/null +++ b/src/component/Connection/ConnectionForm.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { ConnectionContext } from '../../Contexts'; +import ConnectionForm from './ConnectionForm'; + +const meta: Meta = { + component: ConnectionForm, + decorators: [ + (Story) => ( + {}, + connectTo: (connection) => { + action('connectTo')(connection); + }, + }} + > + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +/* + *👇 Render functions are a framework specific feature to allow you control on how the component renders. + * See https://storybook.js.org/docs/api/csf + * to learn how to use render functions. + */ +export const Form: Story = { + render: () => , +}; diff --git a/src/component/Connection/ConnectionForm.tsx b/src/component/Connection/ConnectionForm.tsx index b5dd162..44cb09e 100644 --- a/src/component/Connection/ConnectionForm.tsx +++ b/src/component/Connection/ConnectionForm.tsx @@ -1,10 +1,15 @@ import { Button, Checkbox, Form, Input } from 'antd'; -import { ConnectionContext, ConnectToFunc } from '../../Contexts'; +import { + ConnectionContext, + ConnectToFunc, + useConfiguration, +} from '../../Contexts'; import { ConnectionObject } from './types'; import { PureComponent, useContext } from 'react'; interface ConnectionFormProps { connectTo: ConnectToFunc; + addConnectionToConfig: (connection: ConnectionObject) => void; } type ConnectionFormType = ConnectionObject & { save: boolean; @@ -18,10 +23,11 @@ class ConnectionForm extends PureComponent { } handleSubmit(formData: ConnectionFormType): void { + const { addConnectionToConfig } = this.props; const { save, ...connection } = formData; if (save) { - window.config.addConnectionToConfig(connection); + addConnectionToConfig(connection); } this.props.connectTo(connection); @@ -91,8 +97,14 @@ class ConnectionForm extends PureComponent { function ConnectionFormWithContext() { const { connectTo } = useContext(ConnectionContext); - - return ; + const { addConnectionToConfig } = useConfiguration(); + + return ( + + ); } export default ConnectionFormWithContext; diff --git a/src/component/Connection/ConnectionPage.tsx b/src/component/Connection/ConnectionPage.tsx index 144e861..68d42aa 100644 --- a/src/component/Connection/ConnectionPage.tsx +++ b/src/component/Connection/ConnectionPage.tsx @@ -5,7 +5,7 @@ import { ConnectionContext, useConfiguration } from '../../Contexts'; import { useContext } from 'react'; function ConnectionPage() { - const registeredConnectionList = useConfiguration().connections; + const registeredConnectionList = useConfiguration().configuration.connections; const { connectTo } = useContext(ConnectionContext); if (registeredConnectionList === null) { diff --git a/src/component/Connection/ConnectionStack.tsx b/src/component/Connection/ConnectionStack.tsx index bb4fca1..d97e359 100644 --- a/src/component/Connection/ConnectionStack.tsx +++ b/src/component/Connection/ConnectionStack.tsx @@ -94,6 +94,7 @@ class ConnectionStack extends PureComponent { value={{ database, setDatabase: this.handleSetDatabase, + executeQuery: window.sql.executeQuery, }} > {children} diff --git a/src/component/Connection/Nav.stories.tsx b/src/component/Connection/Nav.stories.tsx new file mode 100644 index 0000000..f9ed5dc --- /dev/null +++ b/src/component/Connection/Nav.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { ConnectionContext } from '../../Contexts'; +import Nav from './Nav'; + +const meta: Meta = { + component: Nav, + decorators: [ + (Story) => ( + { + action('setCurrentConnectionName')(connection); + }, + connectTo: () => {}, + }} + > + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +/* + *👇 Render functions are a framework specific feature to allow you control on how the component renders. + * See https://storybook.js.org/docs/api/csf + * to learn how to use render functions. + */ +export const Primary: Story = { + render: () =>