From 50a28d0c17e000cd98073582798fa4af006d27e6 Mon Sep 17 00:00:00 2001 From: Stephan Meijer Date: Tue, 16 Jun 2020 15:09:45 +0200 Subject: [PATCH] feat: add event-debugging environment --- package-lock.json | 9 + package.json | 3 +- src/components/App.js | 10 +- src/components/DomEvents.js | 209 ++++++++++++++++ src/components/IconButton.js | 3 +- src/components/Layout.js | 3 + src/components/Preview.js | 8 +- src/components/Scrollable.js | 61 ++++- src/components/TrashcanIcon.js | 14 ++ src/hooks/usePlayground.js | 2 +- src/images/EmptyStreetImg.js | 437 +++++++++++++++++++++++++++++++++ 11 files changed, 740 insertions(+), 19 deletions(-) create mode 100644 src/components/DomEvents.js create mode 100644 src/components/TrashcanIcon.js create mode 100644 src/images/EmptyStreetImg.js diff --git a/package-lock.json b/package-lock.json index afd970e9..8fed87ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13796,6 +13796,15 @@ "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz", "integrity": "sha512-MYXhTY1BZpdJFjUovvYHVBmkq79szK/k7V3MO+36gJkWGkrXKtyr4vCPtpphaTLRAdDNoYEYFZWE8LjN+PIHNg==" }, + "react-window": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.5.tgz", + "integrity": "sha512-HeTwlNa37AFa8MDZFZOKcNEkuF2YflA0hpGPiTT9vR7OawEt+GZbfM6wqkBahD3D3pUjIabQYzsnY/BSJbgq6Q==", + "requires": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + } + }, "read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index c48d1f7f..06e8a3fc 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,8 @@ "react-dom": "^16.13.1", "react-router-dom": "^5.2.0", "react-toastify": "^6.0.5", - "react-virtualized-auto-sizer": "^1.0.2" + "react-virtualized-auto-sizer": "^1.0.2", + "react-window": "^1.8.5" }, "devDependencies": { "@babel/core": "^7.10.2", diff --git a/src/components/App.js b/src/components/App.js index bbf65f54..6674e1e7 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -3,19 +3,23 @@ import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'; import Playground from './Playground'; import Layout from './Layout'; import Embedded from './Embedded'; -import { ToastContainer } from 'react-toastify'; +import DomEvents from './DomEvents'; function App() { return ( - + + + + + + - diff --git a/src/components/DomEvents.js b/src/components/DomEvents.js new file mode 100644 index 00000000..35b70316 --- /dev/null +++ b/src/components/DomEvents.js @@ -0,0 +1,209 @@ +import React, { useRef, useCallback, useState } from 'react'; + +import Preview from './Preview'; +import MarkupEditor from './MarkupEditor'; +import usePlayground from '../hooks/usePlayground'; +import state from '../lib/state'; +import { eventMap } from '@testing-library/dom/dist/event-map'; +import { VirtualScrollable } from './Scrollable'; +import { FixedSizeList as List } from 'react-window'; +import throttle from 'lodash.throttle'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import IconButton from './IconButton'; +import TrashcanIcon from './TrashcanIcon'; +import EmptyStreetImg from '../images/EmptyStreetImg'; + +function onStateChange({ markup, query, result }) { + state.save({ markup, query }); + state.updateTitle(result?.expression?.expression); +} + +const initialValues = state.load() || {}; + +function targetToString() { + return [ + this.tagName.toLowerCase(), + this.id && `#${this.id}`, + this.name && `[name="${this.name}"]`, + this.htmlFor && `[for="${this.htmlFor}"]`, + this.value && `[value="${this.value}"]`, + this.checked !== null && `[checked=${this.checked}]`, + ] + .filter(Boolean) + .join(''); +} + +function getElementData(element) { + const value = + element.tagName === 'SELECT' && element.multiple + ? element.selectedOptions.length > 0 + ? JSON.stringify( + Array.from(element.selectedOptions).map((o) => o.value), + ) + : null + : element.value; + + const hasChecked = element.type === 'checkbox' || element.type === 'radio'; + + return { + tagName: element.tagName.toLowerCase(), + id: element.id || null, + name: element.name || null, + htmlFor: element.htmlFor || null, + value: value || null, + checked: hasChecked ? !!element.checked : null, + toString: targetToString, + }; +} + +function addLoggingEvents(node, log) { + function createEventLogger(eventType) { + return function logEvent(event) { + if (event.target === event.currentTarget) { + return; + } + + log({ + event: eventType, + target: getElementData(event.target), + }); + }; + } + + Object.keys(eventMap).forEach((name) => { + node.addEventListener( + name.toLowerCase(), + createEventLogger({ name, ...eventMap[name] }), + true, + ); + }); +} + +function EventRecord({ index, style, data }) { + const { id, event, target } = data[index]; + + return ( +
+
{id}
+ +
{event.EventType}
+
{event.name}
+ +
{target.tagName}
+
+ {target.toString()} +
+
+ ); +} + +const noop = () => {}; +function DomEvents() { + const [{ markup, result }, dispatch] = usePlayground({ + onChange: onStateChange, + ...initialValues, + }); + + const buffer = useRef([]); + const previewRef = useRef(); + const listRef = useRef(); + + const [eventCount, setEventCount] = useState(0); + + const reset = () => { + buffer.current = []; + setEventCount(0); + }; + + const flush = useCallback( + throttle(() => setEventCount(buffer.current.length), 16, { + leading: false, + }), + [setEventCount], + ); + + const setPreviewRef = useCallback((node) => { + previewRef.current = node; + + if (!node) return; + + addLoggingEvents(node, (event) => { + // insert at index 0 + event.id = buffer.current.length; + buffer.current.splice(0, 0, event); + setTimeout(flush, 0); + }); + }, []); + + return ( +
+
+
+ +
+ +
+ +
+
+ +
+ +
+
+
+
#
+ +
type
+
name
+ +
element
+
+ selector + + + +
+
+ +
+ {eventCount === 0 ? ( +
+ +
+ ) : ( + + {({ width, height }) => ( + + {EventRecord} + + )} + + )} +
+
+
+
+ ); +} + +export default DomEvents; diff --git a/src/components/IconButton.js b/src/components/IconButton.js index 789d433d..1588a7b2 100644 --- a/src/components/IconButton.js +++ b/src/components/IconButton.js @@ -1,6 +1,6 @@ import React from 'react'; -function IconButton({ children, variant, onClick, className }) { +function IconButton({ children, title, variant, onClick, className }) { return ( diff --git a/src/components/Layout.js b/src/components/Layout.js index 231a5ed3..2bd4afc7 100644 --- a/src/components/Layout.js +++ b/src/components/Layout.js @@ -1,6 +1,7 @@ import React from 'react'; import Header from './Header'; import Footer from './Footer'; +import { ToastContainer } from 'react-toastify'; function Layout({ children }) { return ( @@ -14,6 +15,8 @@ function Layout({ children }) {
+ +
); } diff --git a/src/components/Preview.js b/src/components/Preview.js index 46269e8a..1ff216b3 100644 --- a/src/components/Preview.js +++ b/src/components/Preview.js @@ -8,7 +8,7 @@ function selectByCssPath(rootNode, cssPath) { return rootNode?.querySelector(cssPath.toString().replace(/^body > /, '')); } -function Preview({ markup, accessibleRoles, elements, dispatch }) { +function Preview({ markup, accessibleRoles, elements, dispatch, variant }) { // Okay, listen up. `highlighted` can be a number of things, as I wanted to // keep a single variable to represent the state. This to reduce bug count // by creating out-of-sync states. @@ -147,7 +147,7 @@ function Preview({ markup, accessibleRoles, elements, dispatch }) { onMouseEnter={() => setHighlighted(true)} onMouseLeave={() => setHighlighted(false)} > -
+
- + {variant !== 'minimal' && ( + + )}
) : (
diff --git a/src/components/Scrollable.js b/src/components/Scrollable.js index 1d121c95..b088b8c7 100644 --- a/src/components/Scrollable.js +++ b/src/components/Scrollable.js @@ -1,15 +1,22 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { Scrollbars } from 'react-custom-scrollbars'; import AutoSizer from 'react-virtualized-auto-sizer'; const thumbStyle = { - cursor: 'pointer', - borderRadius: 4, - width: 5, + vertical: { + cursor: 'pointer', + borderRadius: 4, + width: 5, + }, + horizontal: { + cursor: 'pointer', + borderRadius: 4, + height: 5, + }, }; -const VerticalThumb = React.forwardRef(function VerticalThumb( - { style, variant, ...props }, +const Thumb = React.forwardRef(function Thumb( + { style, variant, orientation, ...props }, ref, ) { const className = @@ -22,14 +29,19 @@ const VerticalThumb = React.forwardRef(function VerticalThumb( return (
); }); -function HorizontalThumb() { +function HiddenThumb() { // for some reason the horizontal scrollbar is also rendered when we don't // need it. I've hidden it, as we only need vertical scrollbars atm anyways // The interface will still be scrollable if required, albeit without thumb. @@ -42,8 +54,10 @@ function Scrollable({ children, variant = 'light' }) { {({ width, height }) => ( } - renderThumbHorizontal={HorizontalThumb} + renderThumbVertical={() => ( + + )} + renderThumbHorizontal={HiddenThumb} >
{children}
@@ -52,4 +66,31 @@ function Scrollable({ children, variant = 'light' }) { ); } +function VirtualScrollableComponent(props) { + const { forwardedRef, style, children, className, onScroll } = props; + + const refSetter = useCallback((scrollbarsRef) => { + forwardedRef?.(scrollbarsRef?.view || null); + }, []); + + return ( + } + renderThumbHorizontal={() => } + hideTracksWhenNotNeeded={true} + > + {children} + + ); +} + +// eslint-disable-next-line react/display-name +export const VirtualScrollable = React.forwardRef((props, ref) => ( + +)); + export default Scrollable; diff --git a/src/components/TrashcanIcon.js b/src/components/TrashcanIcon.js new file mode 100644 index 00000000..93cbb357 --- /dev/null +++ b/src/components/TrashcanIcon.js @@ -0,0 +1,14 @@ +import React from 'react'; + +function TrashcanIcon() { + return ( + + + + ); +} + +export default TrashcanIcon; diff --git a/src/hooks/usePlayground.js b/src/hooks/usePlayground.js index 35f8b3fc..62209cda 100644 --- a/src/hooks/usePlayground.js +++ b/src/hooks/usePlayground.js @@ -31,7 +31,7 @@ function reducer(state, action) { } case 'SET_QUERY': { - if (action.updateEditor !== false) { + if (action.updateEditor !== false && state.queryEditor) { state.queryEditor.setValue(action.query); } diff --git a/src/images/EmptyStreetImg.js b/src/images/EmptyStreetImg.js new file mode 100644 index 00000000..ac88b72b --- /dev/null +++ b/src/images/EmptyStreetImg.js @@ -0,0 +1,437 @@ +import React from 'react'; + +function EmptyStreetImg({ width, height }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default EmptyStreetImg;