Skip to content

Commit e7634da

Browse files
committed
feat: improve query advise
1 parent 1d83730 commit e7634da

File tree

11 files changed

+315
-135
lines changed

11 files changed

+315
-135
lines changed

src/components/App.js

Lines changed: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,34 @@
1-
import React, { useState, useRef, useEffect } from 'react';
1+
import React, { useState, useEffect } from 'react';
22
import Editor from './Editor';
33
import ElementInfo from './ElementInfo';
44
import Header from './Header';
55
import Footer from './Footer';
66
import parser from '../parser';
77

88
import { initialValues } from '../constants';
9-
import ensureArray from '../lib/ensureArray';
10-
import { AppContextProvider, useAppContext } from './Context';
9+
import { useAppContext } from './Context';
1110
import HtmlPreview from './HtmlPreview';
1211

13-
let cycle = 0;
14-
1512
function App() {
1613
const [html, setHtml] = useState(initialValues.html);
1714
const [js, setJs] = useState(initialValues.js);
1815

19-
const { htmlEditorRef, htmlPreviewRef, jsEditorRef } = useAppContext();
20-
21-
const [result, setResult] = useState({});
22-
const [elements, setElements] = useState([]);
16+
const {
17+
htmlEditorRef,
18+
htmlPreviewRef,
19+
jsEditorRef,
20+
parsed,
21+
setParsed,
22+
} = useAppContext();
2323

2424
useEffect(() => {
2525
const parsed = parser.parse(htmlPreviewRef.current, js);
26-
setResult(parsed);
27-
28-
const elements = ensureArray(parsed.code).filter(
29-
(x) => x?.nodeType === Node.ELEMENT_NODE,
30-
);
26+
setParsed(parsed);
3127

32-
cycle++;
33-
setElements(elements);
34-
elements.forEach((el) => el.classList.add('highlight'));
28+
parsed.targets?.forEach((el) => el.classList.add('highlight'));
3529

3630
return () => {
37-
elements.forEach((el) => el.classList.remove('highlight'));
31+
parsed.targets?.forEach((el) => el.classList.remove('highlight'));
3832
};
3933
}, [html, js, htmlPreviewRef.current]);
4034

@@ -65,17 +59,11 @@ function App() {
6559
/>
6660
<div className="output">
6761
<span className="text-blue-600">&gt; </span>
68-
{result.text || result.error || 'undefined'}
62+
{parsed.text || parsed.error || 'undefined'}
6963
</div>
7064
</div>
7165

72-
<div>
73-
<div>
74-
{elements.map((x, idx) => (
75-
<ElementInfo key={`${cycle}-${idx}`} element={x} />
76-
))}
77-
</div>
78-
</div>
66+
<ElementInfo />
7967
</div>
8068
</div>
8169

src/components/Context.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
import React, { useContext, useRef } from 'react';
1+
import React, { useContext, useRef, useState } from 'react';
22

33
export const AppContext = React.createContext();
44

55
function AppContextProvider(props) {
66
const jsEditorRef = useRef();
77
const htmlEditorRef = useRef();
88
const htmlPreviewRef = useRef();
9+
const [parsed, setParsed] = useState({});
910

1011
return (
1112
<AppContext.Provider
12-
value={{ jsEditorRef, htmlEditorRef, htmlPreviewRef }}
13+
value={{ jsEditorRef, htmlEditorRef, htmlPreviewRef, parsed, setParsed }}
1314
{...props}
1415
/>
1516
);

src/components/ElementInfo.js

Lines changed: 23 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,9 @@
11
import React from 'react';
22
import { getRole, computeAccessibleName } from 'dom-accessibility-api';
3-
import { links, messages } from '../constants';
4-
import SetLike from 'dom-accessibility-api/dist/polyfills/SetLike';
53
import { useAppContext } from './Context';
4+
import QueryAdvise from './QueryAdvise';
65

7-
const colors = ['bg-blue-600', 'bg-yellow-600', 'bg-orange-600', 'bg-red-600'];
8-
9-
const queries = [
10-
{ method: 'getByRole', level: 0 },
11-
{ method: 'getByLabelText', level: 0 },
12-
{ method: 'getByPlaceholderText', level: 0 },
13-
{ method: 'getByText', level: 0 },
14-
{ method: 'getByDisplayValue', level: 0 },
15-
16-
{ method: 'getByAltText', level: 1 },
17-
{ method: 'getByTitle', level: 1 },
18-
19-
{ method: 'getByTestId', level: 2 },
20-
21-
// 'container.querySelector'
22-
];
23-
24-
function escape(val) {
25-
return val.replace(/'/g, `\\'`);
26-
}
27-
28-
function getExpression({ method, data }) {
29-
const field = getFieldName(method);
30-
31-
if (method === 'getByRole' && data.role && data.name) {
32-
return `screen.getByRole('${data.role}', { name: '${escape(data.name)}' })`;
33-
}
34-
35-
if (data[field]) {
36-
return `screen.${method}('${escape(data[field])}')`;
37-
}
38-
39-
return '';
40-
}
41-
42-
function getFieldName(method) {
43-
return method[5].toLowerCase() + method.substr(6);
44-
}
6+
import { getExpression, getFieldName } from '../lib';
457

468
function getData({ root, element }) {
479
const type = element.getAttribute('type');
@@ -53,11 +15,10 @@ function getData({ root, element }) {
5315
const labelText = labelElem ? labelElem.innerText : null;
5416

5517
return {
56-
// input's require a type for the role
5718
role:
58-
element.getAttribute('role') || (tagName === 'INPUT' && !type)
59-
? ''
60-
: getRole(element),
19+
element.getAttribute('role') ||
20+
// input's require a type for the role
21+
(tagName === 'INPUT' && type !== 'text' ? '' : getRole(element)),
6122
name: computeAccessibleName(element),
6223
tagName: tagName,
6324
type: type,
@@ -82,10 +43,12 @@ function Heading({ children }) {
8243
}
8344

8445
function Field({ method, data }) {
85-
const { jsEditorRef } = useAppContext();
46+
const { jsEditorRef, parsed } = useAppContext();
8647

48+
const isActive = parsed.expression?.method === method;
8749
const field = getFieldName(method);
8850
const value = data[field];
51+
8952
const handleClick = value
9053
? () => {
9154
const expr = getExpression({ method, data });
@@ -95,7 +58,7 @@ function Field({ method, data }) {
9558

9659
return (
9760
<div
98-
className="text-xs field"
61+
className={`text-xs field ${isActive ? 'active' : ''}`}
9962
data-clickable={!!handleClick}
10063
onClick={handleClick}
10164
>
@@ -107,53 +70,26 @@ function Field({ method, data }) {
10770
);
10871
}
10972

110-
function getQueryAdvise(data) {
111-
const query = queries.find(({ method }) => getExpression({ method, data }));
112-
if (!query) {
113-
return {
114-
level: 3,
115-
expression: 'container.querySelector(…)',
116-
...messages[3],
117-
};
118-
}
119-
const expression = getExpression({ method: query.method, data });
120-
return { expression, level: query.level, ...messages[query.level] };
121-
}
122-
12373
// for inputs, the role will only work if there is also a type attribute
124-
function ElementInfo({ element }) {
125-
const { htmlPreviewRef } = useAppContext();
126-
const data = getData({ root: htmlPreviewRef.current, element });
127-
const advise = getQueryAdvise(data);
74+
function ElementInfo() {
75+
const { htmlPreviewRef, parsed } = useAppContext();
76+
const element = parsed.target;
77+
78+
const data = element && getData({ root: htmlPreviewRef.current, element });
79+
80+
if (!data) {
81+
return <div />;
82+
}
12883

12984
return (
13085
<div>
131-
<div
132-
className={[
133-
'border text-white p-4 rounded mb-8',
134-
colors[advise.level],
135-
].join(' ')}
136-
>
137-
<div className="font-bold text-xs mb-2">suggested query:</div>
138-
{advise.expression && (
139-
<div className="font-mono text-sm">&gt; {advise.expression}</div>
140-
)}
141-
</div>
86+
<QueryAdvise data={data} />
14287

143-
{/*disabled for the time being*/}
144-
{false && advise.description && (
145-
<blockquote className="text-sm mb-4 italic">
146-
<p className="font-bold text-xs mb-2">{advise.heading}:</p>
147-
<p>{advise.description}</p>
148-
<cite>
149-
<a href={links.which_query.url}>Testing Library</a>
150-
</cite>
151-
</blockquote>
152-
)}
88+
<div className="my-6 border-b" />
15389

15490
<div className="grid grid-cols-2 gap-4">
15591
<Section>
156-
<Heading>Queries Accessible to Everyone</Heading>
92+
<Heading>1. Queries Accessible to Everyone</Heading>
15793
<Field method="getByRole" data={data} />
15894
<Field method="getByLabelText" data={data} />
15995
<Field method="getByPlaceholderText" data={data} />
@@ -163,13 +99,13 @@ function ElementInfo({ element }) {
16399

164100
<div className="space-y-8">
165101
<Section>
166-
<Heading>Semantic Queries</Heading>
102+
<Heading>2. Semantic Queries</Heading>
167103
<Field method="getByAltText" data={data} />
168104
<Field method="getByTitle" data={data} />
169105
</Section>
170106

171107
<Section>
172-
<Heading>TestId</Heading>
108+
<Heading>3. TestId</Heading>
173109
<Field method="getByTestId" data={data} />
174110
</Section>
175111
</div>

src/components/Footer.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,4 @@ function Footer() {
5454
);
5555
}
5656

57-
export default Footer;
57+
export default React.memo(Footer);

src/components/QueryAdvise.js

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import React from 'react';
2+
import { messages, queries } from '../constants';
3+
import { useAppContext } from './Context';
4+
import { getExpression } from '../lib';
5+
6+
const colors = ['bg-blue-600', 'bg-yellow-600', 'bg-orange-600', 'bg-red-600'];
7+
8+
function getQueryAdvise(data) {
9+
const query = queries.find(({ method }) => getExpression({ method, data }));
10+
if (!query) {
11+
return {
12+
level: 3,
13+
expression: 'container.querySelector(…)',
14+
...messages[3],
15+
};
16+
}
17+
const expression = getExpression({ method: query.method, data });
18+
return { expression, ...query, ...messages[query.level] };
19+
}
20+
21+
function Code({ children }) {
22+
return <span className="font-bold font-mono">{children}</span>;
23+
}
24+
25+
function Quote({ heading, content, source, href }) {
26+
return (
27+
<blockquote className="text-sm mb-4 italic w-full">
28+
<p className="font-bold text-xs mb-2">{heading}:</p>
29+
<p>{content}</p>
30+
<cite>
31+
<a href={href}>{source}</a>
32+
</cite>
33+
</blockquote>
34+
);
35+
}
36+
37+
function QueryAdvise({ data }) {
38+
const { parsed, jsEditorRef } = useAppContext();
39+
const advise = getQueryAdvise(data);
40+
41+
const used = parsed?.expression || {};
42+
43+
const usingAdvisedMethod = advise.method === used.method;
44+
const hasNameArg = data.name && used.args?.[1]?.includes('name');
45+
46+
const color = usingAdvisedMethod ? 'bg-green-600' : colors[advise.level];
47+
48+
const target = parsed.target || {};
49+
50+
let suggestion;
51+
52+
if (advise.level < used.level) {
53+
suggestion = (
54+
<p>
55+
You're using <Code>{used.method}</Code>, which falls under{' '}
56+
<Code>{messages[used.level].heading}</Code>. Upgrading to{' '}
57+
<Code>{advise.method}</Code> is recommended.
58+
</p>
59+
);
60+
} else if (advise.level === 0 && advise.method !== used.method) {
61+
suggestion = (
62+
<p>
63+
Nice! <Code>{used.method}</Code> is a great selector! Using{' '}
64+
<Code>{advise.method}</Code> would still be preferable though.
65+
</p>
66+
);
67+
} else if (target.tagName === 'INPUT' && !target.getAttribute('type')) {
68+
suggestion = (
69+
<p>
70+
You can unlock <Code>getByRole</Code> by adding the{' '}
71+
<Code>type="text"</Code> attribute explicitly. Accessibility will
72+
benefit from it.
73+
</p>
74+
);
75+
} else if (
76+
advise.level === 0 &&
77+
advise.method === 'getByRole' &&
78+
!data.name
79+
) {
80+
suggestion = (
81+
<p>
82+
Awesome! This is great already! You could still make the query a bit
83+
more specific by adding the name option. This would require to add some
84+
markup though, as your element isn't named properly.
85+
</p>
86+
);
87+
} else if (
88+
advise.level === 0 &&
89+
advise.method === 'getByRole' &&
90+
data.name &&
91+
!hasNameArg
92+
) {
93+
suggestion = (
94+
<p>
95+
There is one thing though. You could make the query a bit more specific
96+
by adding the name option.
97+
</p>
98+
);
99+
} else {
100+
suggestion = <p>This is great. Ship it!</p>;
101+
}
102+
103+
const handleClick = () => {
104+
jsEditorRef.current.setValue(advise.expression);
105+
};
106+
107+
return (
108+
<div className="space-y-4 text-sm">
109+
<div className={['text-white p-4 rounded space-y-2', color].join(' ')}>
110+
<div className="font-bold text-xs">suggested query</div>
111+
{advise.expression && (
112+
<div className="font-mono cursor-pointer" onClick={handleClick}>
113+
&gt; {advise.expression}
114+
</div>
115+
)}
116+
</div>
117+
<div className="h-8">{suggestion}</div>
118+
</div>
119+
);
120+
}
121+
122+
export default QueryAdvise;

0 commit comments

Comments
 (0)