From 568964008bb657dfaf8038ac2d9fa3dca8d3eb1c Mon Sep 17 00:00:00 2001 From: JF-Cozy Date: Wed, 6 Nov 2024 10:00:03 +0100 Subject: [PATCH] feat(cozy-devtools): Add first version to test providers --- packages/cozy-devtools/CHANGELOG.md | 0 packages/cozy-devtools/Readme.md | 90 +++++ packages/cozy-devtools/babel.config.js | 12 + packages/cozy-devtools/jest.config.js | 19 + packages/cozy-devtools/package.json | 47 +++ packages/cozy-devtools/src/Flags/FlagEdit.jsx | 81 ++++ packages/cozy-devtools/src/Flags/FlagItem.jsx | 54 +++ packages/cozy-devtools/src/Flags/Flags.jsx | 45 +++ packages/cozy-devtools/src/Flags/helpers.js | 32 ++ .../cozy-devtools/src/LibraryVersions.jsx | 43 +++ packages/cozy-devtools/src/PanelContent.jsx | 11 + packages/cozy-devtools/src/Pouch.jsx | 139 +++++++ packages/cozy-devtools/src/Queries.jsx | 358 ++++++++++++++++++ packages/cozy-devtools/src/common.jsx | 43 +++ packages/cozy-devtools/src/index.jsx | 242 ++++++++++++ packages/cozy-devtools/src/useLocalState.jsx | 25 ++ 16 files changed, 1241 insertions(+) create mode 100644 packages/cozy-devtools/CHANGELOG.md create mode 100644 packages/cozy-devtools/Readme.md create mode 100644 packages/cozy-devtools/babel.config.js create mode 100644 packages/cozy-devtools/jest.config.js create mode 100644 packages/cozy-devtools/package.json create mode 100644 packages/cozy-devtools/src/Flags/FlagEdit.jsx create mode 100644 packages/cozy-devtools/src/Flags/FlagItem.jsx create mode 100644 packages/cozy-devtools/src/Flags/Flags.jsx create mode 100644 packages/cozy-devtools/src/Flags/helpers.js create mode 100644 packages/cozy-devtools/src/LibraryVersions.jsx create mode 100644 packages/cozy-devtools/src/PanelContent.jsx create mode 100644 packages/cozy-devtools/src/Pouch.jsx create mode 100644 packages/cozy-devtools/src/Queries.jsx create mode 100644 packages/cozy-devtools/src/common.jsx create mode 100644 packages/cozy-devtools/src/index.jsx create mode 100644 packages/cozy-devtools/src/useLocalState.jsx diff --git a/packages/cozy-devtools/CHANGELOG.md b/packages/cozy-devtools/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/cozy-devtools/Readme.md b/packages/cozy-devtools/Readme.md new file mode 100644 index 0000000000..a85ed44ed8 --- /dev/null +++ b/packages/cozy-devtools/Readme.md @@ -0,0 +1,90 @@ +## Cozy-Devtools + +Cozy-Devtools exposes a devtool that can be injected in an app for debug +and better developer experience. It is inspired by the [awesome devtools +for react-query][react-query devtools]. + +To activate it, just run in your browser console: + +```js +flag('debug', true) +``` + +### Usage + +Before using the devtools, you need to install cozy-ui and react-inspector. + +```bash +yarn add cozy-ui # >= 48.0.0 +yarn add react-inspector # >= 5.1.0 +``` + +Next, you need to add it to your app, inside a CozyProvider. + +```jsx +import CozyClient, { CozyProvider } from 'cozy-client' +import CozyDevtools from 'cozy-client/dist/devtools' + +const App = () => { + return + /* Your app is here */ + { process.env.NODE_ENV !== 'production' ? : null } + +} +``` + +### Panels + +- The devtools is made of several "panels". +- There are default panels and the app can also inject its own adhoc panels. + +#### Queries + +Shows current queries inside cozy-client cache. Allows to see the data of +the query. The execution time is also shown, and is very valuable to track +down performance issues. It uses the execution statistics collected +from CouchDB. + +#### Flags + +Shows all the current flags and allow to modify them. + +#### Libraries + +Show library versions based on the global **VERSIONS** variable that +should be injected by the app. If it is defined, the panel will be blank. + +#### PouchLink + +If you use the PouchLink to synchronize your data to PouchDB, you can use +the optional devtool PouchLink devtool panel. Since PouchDB is optional, +it is not available by default and you need to explicitly tell the Devtools +to display it. + +```jsx +import PouchDevtools from 'cozy-client/dist/devtools/Pouch' + +() => +``` + +### Ideas for next features + +- Performance tips in query panels + + - Show index related tips + - Show slow queries + - Show repeating queries + - Show queries downloading too much data + +- Actions on queries + + - Reset data inside query + - Refetch + - Set to error + - Delete from store + +If you have any other idea, please [open an issue][open-issue] 👍 + +[react-query devtools]: https://github.com/tannerlinsley/react-query-devtools + +[open-issue]: https://github.com/cozy/cozy-libs/issues/new diff --git a/packages/cozy-devtools/babel.config.js b/packages/cozy-devtools/babel.config.js new file mode 100644 index 0000000000..870bfac930 --- /dev/null +++ b/packages/cozy-devtools/babel.config.js @@ -0,0 +1,12 @@ +module.exports = { + presets: ['cozy-app'], + env: { + transpilation: { + ignore: ['**/*.spec.jsx', '**/*.spec.js', '**/*.spec.tsx', '**/*.spec.ts'] + }, + test: { + presets: [['cozy-app', { transformRuntime: { helpers: true } }]] + } + }, + ignore: ['examples/**/*', '**/*.md', '**/*.snap'] +} diff --git a/packages/cozy-devtools/jest.config.js b/packages/cozy-devtools/jest.config.js new file mode 100644 index 0000000000..26250dc375 --- /dev/null +++ b/packages/cozy-devtools/jest.config.js @@ -0,0 +1,19 @@ +module.exports = { + testPathIgnorePatterns: ['node_modules', 'dist'], + testEnvironment: 'jest-environment-jsdom-sixteen', + testURL: 'http://localhost/', + moduleFileExtensions: ['js', 'jsx', 'json', 'ts', 'tsx'], + moduleDirectories: ['src', 'node_modules'], + moduleNameMapper: { + '^cozy-client$': 'cozy-client/dist/index' + }, + transformIgnorePatterns: ['node_modules/(?!(cozy-ui)'], + transform: { + '^.+\\.(ts|tsx|js|jsx)?$': 'babel-jest' + }, + globals: { + __ALLOW_HTTP__: false, + cozy: {} + }, + setupFilesAfterEnv: ['jest-canvas-mock'] +} diff --git a/packages/cozy-devtools/package.json b/packages/cozy-devtools/package.json new file mode 100644 index 0000000000..2765013a39 --- /dev/null +++ b/packages/cozy-devtools/package.json @@ -0,0 +1,47 @@ +{ + "name": "cozy-devtools", + "version": "0.0.1", + "description": "Cozy-Devtools exposes a devtool that can be injected in an app for debug and better developer experience.", + "main": "dist/index.js", + "license": "MIT", + "homepage": "https://github.com/cozy/cozy-libs/blob/master/packages/cozy-devtools/README.md", + "repository": { + "type": "git", + "url": "git+https://github.com/cozy/cozy-libs.git" + }, + "bugs": { + "url": "https://github.com/cozy/cozy-libs/issues" + }, + "scripts": { + "build": "rm -rf ./dist && env BABEL_ENV=transpilation babel --extensions .js,.jsx,.md,.styl,.json,.snap ./src -d ./dist --copy-files --no-copy-ignored --verbose", + "start": "yarn build --watch", + "prepublishOnly": "yarn build", + "test": "env NODE_ENV=test jest --passWithNoTests", + "lint": "cd .. && yarn eslint --ext js,jsx packages/cozy-devtools" + }, + "devDependencies": { + "babel-preset-cozy-app": "^2.5.0", + "cozy-client": "^50.0.0", + "cozy-flags": "^4.3.0", + "cozy-intent": "^2.26.0", + "cozy-pouch-link": "^50.0.0", + "cozy-ui": "^111.19.0", + "react": "^16.12.0", + "react-dom": "^16.12.0", + "stylus": "0.64.0" + }, + "dependencies": { + "date-fns": "2.29.3", + "lodash": "4.17.13", + "react-inspector": "5.1.1" + }, + "peerDependencies": { + "cozy-client": ">=50.0.0", + "cozy-flags": ">=4.3.0", + "cozy-intent": ">=2.26.0", + "cozy-pouch-link": ">=50.0.0", + "cozy-ui": ">=111.19.0", + "react": ">=16.12.0", + "react-dom": ">=16.12.0" + } +} diff --git a/packages/cozy-devtools/src/Flags/FlagEdit.jsx b/packages/cozy-devtools/src/Flags/FlagEdit.jsx new file mode 100644 index 0000000000..864dc3c5b3 --- /dev/null +++ b/packages/cozy-devtools/src/Flags/FlagEdit.jsx @@ -0,0 +1,81 @@ +import React, { useEffect, useState } from 'react' + +import flag from 'cozy-flags' +import Button from 'cozy-ui/transpiled/react/Buttons' +import TextField from 'cozy-ui/transpiled/react/TextField' + +import { isJSONString, makeHumanValue } from './helpers' + +const FlagEdit = ({ flag: editedFlag }) => { + const [formData, setFormData] = useState({ + key: '', + name: '', + value: '', + humanValue: '' + }) + + useEffect(() => { + if (editedFlag) setFormData(editedFlag) + }, [editedFlag]) + + const handleSubmit = e => { + e.preventDefault() + if (!formData.name || !formData.value) return + + /** @type {any} */ + let value = formData.value + if (isJSONString(value)) { + value = JSON.parse(value) + } else if (value === 'true' || value === 'false') { + value = Boolean(value) + } + + flag(formData.name, value) + location.reload() + } + + const handleFlagNameChange = e => { + setFormData({ + ...formData, + key: `flag__${e.target.value}`, + name: e.target.value + }) + } + + const handleFlagValueChange = e => { + let value = e.target.value + if (Number.isInteger(value)) { + value = parseInt(value) + } + setFormData({ + ...formData, + value, + humanValue: makeHumanValue(value) + }) + } + + return ( +
+ + + + + +

+ + ) +} + +export default PouchDevTool diff --git a/packages/cozy-devtools/src/Queries.jsx b/packages/cozy-devtools/src/Queries.jsx new file mode 100644 index 0000000000..174f0ee9c8 --- /dev/null +++ b/packages/cozy-devtools/src/Queries.jsx @@ -0,0 +1,358 @@ +import format from 'date-fns/format' +import get from 'lodash/get' +import overEvery from 'lodash/overEvery' +import sortBy from 'lodash/sortBy' +import React, { useState, useMemo, useCallback } from 'react' +import { TableInspector, ObjectInspector } from 'react-inspector' + +import Box from 'cozy-ui/transpiled/react/Box' +import Grid from 'cozy-ui/transpiled/react/Grid' +import ListItem from 'cozy-ui/transpiled/react/ListItem' +import ListItemText from 'cozy-ui/transpiled/react/ListItemText' +import Paper from 'cozy-ui/transpiled/react/Paper' +import Table from 'cozy-ui/transpiled/react/Table' +import TableBody from 'cozy-ui/transpiled/react/TableBody' +import MuiTableCell from 'cozy-ui/transpiled/react/TableCell' +import TableContainer from 'cozy-ui/transpiled/react/TableContainer' +import TableRow from 'cozy-ui/transpiled/react/TableRow' +import Typography from 'cozy-ui/transpiled/react/Typography' +import { withStyles } from 'cozy-ui/transpiled/react/styles' + +import PanelContent from './PanelContent' +import { NavSecondaryAction, ListGridItem } from './common' + +/** + * @type {Object.} + * @private + */ +const styles = { + panelRight: { height: '100%', overflow: 'scroll', flexGrow: 1 }, + mono: { fontFamily: 'monospace' }, + input: { width: 400 } +} + +const TableCell = withStyles({ + root: { + fontFamily: 'inherit', + fontSize: 'small' + } +})(MuiTableCell) + +const getClassNameForExecutedTimesQuery = time => { + if (time <= 100) { + return 'u-valid u-success' + } else if (time > 100 && time < 250) { + return 'u-warn u-warning' + } else { + return 'u-danger u-error' + } +} + +/** + * @param {{ queryState: import("../types").QueryState }} props - Query state containing fetchStatus, lastError + */ +const FetchStatus = props => { + const { queryState } = props + const { fetchStatus, lastError } = queryState + return ( + + {fetchStatus} + {fetchStatus === 'failed' ? ` - ${lastError}` : null} + + ) +} + +/** + * @param {{ queryState: import("../types").QueryState }} props - Query state containing definition + */ +const IndexedFields = props => { + const { queryState } = props + const { indexedFields } = queryState.definition + return ( + + {indexedFields ? indexedFields.join(', ') : null} + + ) +} + +/** + * @param {string} search - Search string + * @returns {function(import("../types").CozyClientDocument): Boolean} + */ +const makeMatcher = search => { + const specs = search.split(';') + const conditions = specs.map(spec => { + const [key, value] = spec.split(':') + return obj => { + if (!value) { + return false + } + const attr = get(obj, key) + return attr && attr.toString().toLowerCase().includes(value.toLowerCase()) + } + }) + return overEvery(conditions) +} + +const QueryData = ({ client, data, doctype }) => { + const [showTable, setShowTable] = useState(false) + const documents = client.store.getState().cozy.documents[doctype] + + const storeData = useMemo(() => { + return data.map(id => client.hydrateDocument(documents[id])) + }, [client, data, documents]) + + const handleShowTable = useCallback( + () => setShowTable(value => !value), + [setShowTable] + ) + + const [results, setResults] = useState(null) + const [search, setSearch] = useState('') + const handleSearch = useCallback( + ev => { + const searchValue = ev.target.value + const matcher = searchValue !== '' ? makeMatcher(searchValue) : null + const results = matcher ? storeData.filter(matcher) : null + setSearch(searchValue) + setResults(results) + }, + [storeData] + ) + + const viewData = results || storeData + + return ( +
+ Table:{' '} + +
+ Search:{' '} + +
+ {showTable ? ( + + ) : ( + + )} +
+ ) +} + +const ObjectInspectorAndStringificator = ({ object }) => { + const [showStringify, setShowStringify] = useState(false) + const handleStringify = useCallback( + () => setShowStringify(value => !value), + [setShowStringify] + ) + return ( + <> + Stringify:{' '} + + {showStringify ? ( +
{JSON.stringify(object, null, 2)}
+ ) : ( + + )} + + ) +} + +const QueryStateView = ({ client, name }) => { + /** + * @type {import("../types").QueryState} + */ + const queryState = client.store.getState().cozy.queries[name] + const { data, options } = queryState + const { lastFetch, lastUpdate } = useMemo(() => { + return { + lastFetch: new Date(queryState.lastFetch), + lastUpdate: new Date(queryState.lastUpdate) + } + }, [queryState]) + + return ( + <> + + + + + doctype + {queryState.definition.doctype} + + + definition + + + + + + indexFields + + + + + + fetchStatus + + + + + + lastFetch + {format(lastFetch, 'HH:mm:ss')} + + + lastUpdate + {format(lastUpdate, 'HH:mm:ss')} + + + documents + + {data && data.length !== undefined ? data.length : data ? 1 : 0} + + + + autoUpdate + + {options && options.autoUpdate + ? JSON.stringify(options.autoUpdate) + : 'null'} + + + + execution stats + + + + + +
+
+ + + ) +} + +const QueryListItem = ({ name, selected, client, onClick }) => { + const queryState = client.store.getState().cozy.queries[name] + const lastUpdate = useMemo( + () => format(new Date(queryState.lastUpdate), 'HH:mm:ss'), + [queryState] + ) + + return ( + + + {queryState.fetchStatus === 'failed' ? ( + <> + failed -{' '} + + ) : null} + {queryState.execution_stats && ( + <> + -{' '} + + )} + {lastUpdate ? <>{lastUpdate} - : null} + {queryState.data.length} docs + + } + /> + + + ) +} + +const ExecutionTime = ({ queryState }) => { + if (!queryState.execution_stats) return null + const classCSS = getClassNameForExecutedTimesQuery( + queryState.execution_stats.execution_time_ms + ) + return ( + + {Math.round(queryState.execution_stats.execution_time_ms)} ms + + ) +} + +const QueryPanels = ({ client }) => { + const queries = client.store.getState().cozy.queries + + const sortedQueries = useMemo(() => { + return sortBy(queries ? Object.values(queries) : [], queryState => + Math.max( + queryState.lastUpdate || -Infinity, + queryState.lastErrorUpdate || -Infinity + ) + ) + .map(queryState => queryState.id) + .reverse() + }, [queries]) + + const [selectedQuery, setSelectedQuery] = useState(() => sortedQueries[0]) + + return ( + <> + + {sortedQueries.map(queryName => { + return ( + setSelectedQuery(queryName)} + selected={name === selectedQuery} + /> + ) + })} + {sortedQueries.length === 0 ? ( + + No queries yet. + + ) : null} + + + + {selectedQuery} + {selectedQuery ? ( + + ) : null} + + + + ) +} + +export default QueryPanels diff --git a/packages/cozy-devtools/src/common.jsx b/packages/cozy-devtools/src/common.jsx new file mode 100644 index 0000000000..d7d0ec378e --- /dev/null +++ b/packages/cozy-devtools/src/common.jsx @@ -0,0 +1,43 @@ +import React from 'react' + +import Grid from 'cozy-ui/transpiled/react/Grid' +import Icon from 'cozy-ui/transpiled/react/Icon' +import RightIcon from 'cozy-ui/transpiled/react/Icons/Right' +import ListItemSecondaryAction from 'cozy-ui/transpiled/react/ListItemSecondaryAction' + +/** + * @type {Object.} + * @private + */ +const styles = { + listGridItem: { + height: '100%', + overflow: 'scroll', + flexBasis: 240, + flexShrink: 0, + flexGrow: 0, + boxShadow: 'var(--shadow1)' + } +} + +const NavSecondaryAction = () => { + return ( + + + + ) +} + +const ListGridItem = ({ children }) => { + return ( + + {children} + + ) +} + +export { NavSecondaryAction, ListGridItem } diff --git a/packages/cozy-devtools/src/index.jsx b/packages/cozy-devtools/src/index.jsx new file mode 100644 index 0000000000..af3befbf41 --- /dev/null +++ b/packages/cozy-devtools/src/index.jsx @@ -0,0 +1,242 @@ +import React, { useCallback, useMemo, useRef } from 'react' + +import { useClient } from 'cozy-client' +import Box from 'cozy-ui/transpiled/react/Box' +import Fab from 'cozy-ui/transpiled/react/Fab' +import Grid from 'cozy-ui/transpiled/react/Grid' +import Icon from 'cozy-ui/transpiled/react/Icon' +import IconButton from 'cozy-ui/transpiled/react/IconButton' +import CrossMedium from 'cozy-ui/transpiled/react/Icons/CrossMedium' +import GearIcon from 'cozy-ui/transpiled/react/Icons/Gear' +import List from 'cozy-ui/transpiled/react/List' +import ListItem from 'cozy-ui/transpiled/react/ListItem' +import ListItemText from 'cozy-ui/transpiled/react/ListItemText' +import Paper from 'cozy-ui/transpiled/react/Paper' +import Slide from 'cozy-ui/transpiled/react/Slide' +import Typography from 'cozy-ui/transpiled/react/Typography' +import { useTheme, makeStyles } from 'cozy-ui/transpiled/react/styles' + +import Flags from './Flags/Flags' +import LibraryVersions from './LibraryVersions' +import Queries from './Queries' +import { NavSecondaryAction, ListGridItem } from './common' +import { useLocalState } from './useLocalState' + +const ABOVE_ALL = 1000000 +const DEFAULT_PANEL_HEIGHT = 300 + +const useStyles = makeStyles(theme => ({ + fab: { + position: 'fixed', + left: '1rem', + bottom: '1rem', + zIndex: ABOVE_ALL + }, + panel: { + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + zIndex: ABOVE_ALL + }, + closeIcon: { + position: 'absolute', + top: '0', + right: '0.5rem', + transform: 'translateY(-66%)', + background: 'var(--paperBackgroundColor)', + border: `2px solid ${theme.palette.primary.main}`, + boxShadow: 'var(--shadow1)', + zIndex: 1, + '&:hover': { + background: 'var(--paperBackgroundColor)' + } + }, + panelContainer: { + background: 'var(--paperBackgroundColor)', + height: '100%', + flexWrap: 'nowrap', + overflowX: 'scroll' + }, + panelRight: { + height: '100%', + overflowY: 'scroll', + flexGrow: 1, + minWidth: 150 + }, + mono: { fontFamily: 'monospace' } +})) + +const defaultPanels = [ + { + id: 'queries', + Component: Queries + }, + { + id: 'flags', + Component: Flags + }, + { + id: 'library versions', + Component: LibraryVersions + } +] + +const DevToolsNavList = ({ selected, panels, onNav }) => { + return ( + + {panels.map(panel => { + return ( + onNav(panel.id)} + > + {panel.id} + + + ) + })} + + ) +} + +const useResizeStyles = makeStyles(theme => ({ + root: { + height: 3, + width: '100%', + background: theme.palette.primary.main, + cursor: 'row-resize' + } +})) + +const ResizeBar = ({ ...props }) => { + const theme = useTheme() + const classes = useResizeStyles(theme) + return
+} + +const DevToolsPanel = props => { + const { panels: userPanels, open, client } = props + const panels = useMemo(() => { + if (userPanels) { + return [...defaultPanels, ...userPanels] + } + return defaultPanels + }, [userPanels]) + const [currentPanel, setCurrentPanel] = useLocalState( + 'cozydevtools__panel', + 'queries' + ) + const ref = useRef() + + const [panelHeight, setPanelHeight] = useLocalState( + 'cozydevtools__height', + DEFAULT_PANEL_HEIGHT + ) + /** + * Copied/adapted from react-query + * https://github.com/tannerlinsley/react-query/blob/master/src/devtools/devtools.tsx + */ + const handleDragStart = startEvent => { + if (startEvent.button !== 0) return // Only allow left click for drag + + const node = ref.current + if (node === undefined) { + return + } + + const dragInfo = { + // @ts-ignore + originalHeight: node.getBoundingClientRect().height, + pageY: startEvent.pageY + } + + const run = moveEvent => { + const delta = dragInfo.pageY - moveEvent.pageY + const newHeight = dragInfo.originalHeight + delta + + setPanelHeight(newHeight) + } + + const unsub = () => { + document.removeEventListener('mousemove', run) + document.removeEventListener('mouseUp', unsub) + } + + document.addEventListener('mousemove', run) + document.addEventListener('mouseup', unsub) + } + + const classes = useStyles() + + return ( + + + + + + + + + + Cozy Devtools + + + + {panels.map(panelOptions => + currentPanel === panelOptions.id ? ( + + ) : null + )} + + + + ) +} + +const DevTools = ({ panels }) => { + const classes = useStyles() + const client = useClient() + + // eslint-disable-next-line no-console + console.info(' ') + // eslint-disable-next-line no-console + console.info('🟢 client :', client) + // eslint-disable-next-line no-console + console.info(' ') + + const [open, setOpen] = useLocalState('cozydevtools__open', false) + const handleToggle = useCallback(() => setOpen(state => !state), [setOpen]) + + return ( + <> + + + + + + + ) +} + +export default DevTools +export { NavSecondaryAction, ListGridItem, useLocalState } +export { default as PanelContent } from './PanelContent' diff --git a/packages/cozy-devtools/src/useLocalState.jsx b/packages/cozy-devtools/src/useLocalState.jsx new file mode 100644 index 0000000000..f6a7bf04e9 --- /dev/null +++ b/packages/cozy-devtools/src/useLocalState.jsx @@ -0,0 +1,25 @@ +import { useState, useCallback, useEffect } from 'react' + +export const useLocalState = (key, initialState) => { + const [state, setState] = useState(() => { + const item = localStorage.getItem(key) + try { + return item !== null ? JSON.parse(item) : initialState + } catch (e) { + return initialState + } + }) + + const setLocalState = useCallback( + newState => { + setState(newState) + }, + [setState] + ) + + useEffect(() => { + localStorage.setItem(key, JSON.stringify(state)) + }, [key, state]) + + return [state, setLocalState] +}