Skip to content

Commit 9a38de2

Browse files
lesleydreyerTallTeddimaMachinamylesmmurphy
authored
Feat: history enhancements (#3130)
- Adds a trash icon to delete individual items - Adds a clear button to clear all history - Changes so item not in both items list and favorites list - Fixes so it edits the correct label if the same operation is in the list twice - Adds a callback for when you click an item and it's set as the active item in editor (helpful for customizing UI based on when query changes) - Pass in entire item and de-structure needed properties in history store instead (helpful if you're customizing to build your own <HistoryContext.Provider... i.e. customizing the addToHistory/editLabel/etc functions to use a backend instead of a local storage and may need a unique id or other properties. Without passing the entire item there's no way to receive those extra properties but passing the entire item allows that) --------- Co-authored-by: Ted Thibodeau Jr <[email protected]> Co-authored-by: Dimitri POSTOLOV <[email protected]> Co-authored-by: Myles Murphy <[email protected]>
1 parent b31bf66 commit 9a38de2

File tree

9 files changed

+345
-103
lines changed

9 files changed

+345
-103
lines changed

.changeset/proud-ads-hope.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@graphiql/react': minor
3+
---
4+
5+
- Add a "clear history" button to clear all history as well as trash icons to clear individual history items
6+
7+
- Change so item is in history items or history favorites, not both
8+
9+
- Fix history label editing so if the same item is in the list more than once it edits the correct label
10+
11+
- Pass the entire history item in history functions (addToHistory, editLabel, toggleFavorite, etc.) so users building their own HistoryContext.Provider will get any additional props they added to the item in their customized functions
12+
13+
- Adds a "setActive" callback users can use to customize their UI when the history item is clicked

packages/graphiql-react/src/history/components.tsx

Lines changed: 122 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { QueryStoreItem } from '@graphiql/toolkit';
1+
import type { QueryStoreItem } from '@graphiql/toolkit';
22
import {
3-
Fragment,
43
MouseEventHandler,
54
useCallback,
65
useEffect,
@@ -10,50 +9,109 @@ import {
109
import { clsx } from 'clsx';
1110

1211
import { useEditorContext } from '../editor';
13-
import { CloseIcon, PenIcon, StarFilledIcon, StarIcon } from '../icons';
14-
import { Tooltip, UnStyledButton } from '../ui';
12+
import {
13+
CloseIcon,
14+
PenIcon,
15+
StarFilledIcon,
16+
StarIcon,
17+
TrashIcon,
18+
} from '../icons';
19+
import { Button, Tooltip, UnStyledButton } from '../ui';
1520
import { useHistoryContext } from './context';
1621

1722
import './style.css';
1823

1924
export function History() {
20-
const { items } = useHistoryContext({ nonNull: true });
21-
const reversedItems = items.slice().reverse();
25+
const { items: all, deleteFromHistory } = useHistoryContext({
26+
nonNull: true,
27+
});
28+
29+
// Reverse items since we push them in so want the latest one at the top, and pass the
30+
// original index in case multiple items share the same label so we can edit correct item
31+
let items = all
32+
.slice()
33+
.map((item, i) => ({ ...item, index: i }))
34+
.reverse();
35+
const favorites = items.filter(item => item.favorite);
36+
if (favorites.length) {
37+
items = items.filter(item => !item.favorite);
38+
}
39+
40+
const [clearStatus, setClearStatus] = useState<'success' | 'error' | null>(
41+
null,
42+
);
43+
useEffect(() => {
44+
if (clearStatus) {
45+
// reset button after a couple seconds
46+
setTimeout(() => {
47+
setClearStatus(null);
48+
}, 2000);
49+
}
50+
}, [clearStatus]);
51+
52+
const handleClearStatus = useCallback(() => {
53+
try {
54+
for (const item of items) {
55+
deleteFromHistory(item, true);
56+
}
57+
setClearStatus('success');
58+
} catch {
59+
setClearStatus('error');
60+
}
61+
}, [deleteFromHistory, items]);
62+
2263
return (
2364
<section aria-label="History" className="graphiql-history">
24-
<div className="graphiql-history-header">History</div>
25-
<ul className="graphiql-history-items">
26-
{reversedItems.map((item, i) => {
27-
return (
28-
<Fragment key={`${i}:${item.label || item.query}`}>
29-
<HistoryItem item={item} />
30-
{/**
31-
* The (reversed) items are ordered in a way that all favorites
32-
* come first, so if the next item is not a favorite anymore we
33-
* place a spacer between them to separate these groups.
34-
*/}
35-
{item.favorite &&
36-
reversedItems[i + 1] &&
37-
!reversedItems[i + 1].favorite ? (
38-
<div className="graphiql-history-item-spacer" />
39-
) : null}
40-
</Fragment>
41-
);
42-
})}
43-
</ul>
65+
<div className="graphiql-history-header">
66+
History
67+
{(clearStatus || items.length > 0) && (
68+
<Button
69+
type="button"
70+
state={clearStatus || undefined}
71+
disabled={!items.length}
72+
onClick={handleClearStatus}
73+
>
74+
{{
75+
success: 'Cleared',
76+
error: 'Failed to Clear',
77+
}[clearStatus!] || 'Clear'}
78+
</Button>
79+
)}
80+
</div>
81+
82+
{Boolean(favorites.length) && (
83+
<ul className="graphiql-history-items">
84+
{favorites.map(item => (
85+
<HistoryItem item={item} key={item.index} />
86+
))}
87+
</ul>
88+
)}
89+
90+
{Boolean(favorites.length) && Boolean(items.length) && (
91+
<div className="graphiql-history-item-spacer" />
92+
)}
93+
94+
{Boolean(items.length) && (
95+
<ul className="graphiql-history-items">
96+
{items.map(item => (
97+
<HistoryItem item={item} key={item.index} />
98+
))}
99+
</ul>
100+
)}
44101
</section>
45102
);
46103
}
47104

48105
type QueryHistoryItemProps = {
49-
item: QueryStoreItem;
106+
item: QueryStoreItem & { index?: number };
50107
};
51108

52109
export function HistoryItem(props: QueryHistoryItemProps) {
53-
const { editLabel, toggleFavorite } = useHistoryContext({
54-
nonNull: true,
55-
caller: HistoryItem,
56-
});
110+
const { editLabel, toggleFavorite, deleteFromHistory, setActive } =
111+
useHistoryContext({
112+
nonNull: true,
113+
caller: HistoryItem,
114+
});
57115
const { headerEditor, queryEditor, variableEditor } = useEditorContext({
58116
nonNull: true,
59117
caller: HistoryItem,
@@ -75,7 +133,8 @@ export function HistoryItem(props: QueryHistoryItemProps) {
75133

76134
const handleSave = useCallback(() => {
77135
setIsEditable(false);
78-
editLabel({ ...props.item, label: inputRef.current?.value });
136+
const { index, ...item } = props.item;
137+
editLabel({ ...item, label: inputRef.current?.value }, index);
79138
}, [editLabel, props.item]);
80139

81140
const handleClose = useCallback(() => {
@@ -96,7 +155,17 @@ export function HistoryItem(props: QueryHistoryItemProps) {
96155
queryEditor?.setValue(query ?? '');
97156
variableEditor?.setValue(variables ?? '');
98157
headerEditor?.setValue(headers ?? '');
99-
}, [props.item, queryEditor, variableEditor, headerEditor]);
158+
setActive(props.item);
159+
}, [headerEditor, props.item, queryEditor, setActive, variableEditor]);
160+
161+
const handleDeleteItemFromHistory: MouseEventHandler<HTMLButtonElement> =
162+
useCallback(
163+
e => {
164+
e.stopPropagation();
165+
deleteFromHistory(props.item);
166+
},
167+
[props.item, deleteFromHistory],
168+
);
100169

101170
const handleToggleFavorite: MouseEventHandler<HTMLButtonElement> =
102171
useCallback(
@@ -134,13 +203,16 @@ export function HistoryItem(props: QueryHistoryItemProps) {
134203
</>
135204
) : (
136205
<>
137-
<UnStyledButton
138-
type="button"
139-
className="graphiql-history-item-label"
140-
onClick={handleHistoryItemClick}
141-
>
142-
{displayName}
143-
</UnStyledButton>
206+
<Tooltip label="Set active">
207+
<UnStyledButton
208+
type="button"
209+
className="graphiql-history-item-label"
210+
onClick={handleHistoryItemClick}
211+
aria-label="Set active"
212+
>
213+
{displayName}
214+
</UnStyledButton>
215+
</Tooltip>
144216
<Tooltip label="Edit label">
145217
<UnStyledButton
146218
type="button"
@@ -169,6 +241,16 @@ export function HistoryItem(props: QueryHistoryItemProps) {
169241
)}
170242
</UnStyledButton>
171243
</Tooltip>
244+
<Tooltip label="Delete from history">
245+
<UnStyledButton
246+
type="button"
247+
className="graphiql-history-item-action"
248+
onClick={handleDeleteItemFromHistory}
249+
aria-label="Delete from history"
250+
>
251+
<TrashIcon aria-hidden="true" />
252+
</UnStyledButton>
253+
</Tooltip>
172254
</>
173255
)}
174256
</li>

packages/graphiql-react/src/history/context.tsx

Lines changed: 67 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export type HistoryContextType = {
88
/**
99
* Add an operation to the history.
1010
* @param operation The operation that was executed, consisting of the query,
11-
* variables, headers and the operation name.
11+
* variables, headers, and operation name.
1212
*/
1313
addToHistory(operation: {
1414
query?: string;
@@ -22,15 +22,20 @@ export type HistoryContextType = {
2222
* unset) and properties that identify the history item that the label should
2323
* be applied to. (This can result in the label being applied to multiple
2424
* history items.)
25+
* @param index Index to edit. Without it, will look for the first index matching the
26+
* operation, which may lead to misleading results if multiple items have the same label
2527
*/
26-
editLabel(args: {
27-
query?: string;
28-
variables?: string;
29-
headers?: string;
30-
operationName?: string;
31-
label?: string;
32-
favorite?: boolean;
33-
}): void;
28+
editLabel(
29+
args: {
30+
query?: string;
31+
variables?: string;
32+
headers?: string;
33+
operationName?: string;
34+
label?: string;
35+
favorite?: boolean;
36+
},
37+
index?: number,
38+
): void;
3439
/**
3540
* The list of history items.
3641
*/
@@ -50,6 +55,18 @@ export type HistoryContextType = {
5055
label?: string;
5156
favorite?: boolean;
5257
}): void;
58+
/**
59+
* Delete an operation from the history.
60+
* @param args The operation that was executed, consisting of the query,
61+
* variables, headers, and operation name.
62+
* @param clearFavorites This is only if you press the 'clear' button
63+
*/
64+
deleteFromHistory(args: QueryStoreItem, clearFavorites?: boolean): void;
65+
/**
66+
* If you need to know when an item in history is set as active to customize
67+
* your application.
68+
*/
69+
setActive(args: QueryStoreItem): void;
5370
};
5471

5572
export const HistoryContext =
@@ -64,6 +81,12 @@ export type HistoryContextProviderProps = {
6481
maxHistoryLength?: number;
6582
};
6683

84+
/**
85+
* The functions send the entire operation so users can customize their own application with
86+
* <HistoryContext.Provider value={customizedFunctions} /> and get access to the operation plus
87+
* any additional props they added for their needs (i.e., build their own functions that may save
88+
* to a backend instead of localStorage and might need an id property added to the QueryStoreItem)
89+
*/
6790
export function HistoryContextProvider(props: HistoryContextProviderProps) {
6891
const storage = useStorageContext();
6992
const historyStore = useRef(
@@ -76,51 +99,59 @@ export function HistoryContextProvider(props: HistoryContextProviderProps) {
7699
const [items, setItems] = useState(historyStore.current?.queries || []);
77100

78101
const addToHistory: HistoryContextType['addToHistory'] = useCallback(
79-
({ query, variables, headers, operationName }) => {
80-
historyStore.current?.updateHistory(
81-
query,
82-
variables,
83-
headers,
84-
operationName,
85-
);
102+
(operation: QueryStoreItem) => {
103+
historyStore.current?.updateHistory(operation);
86104
setItems(historyStore.current.queries);
87105
},
88106
[],
89107
);
90108

91109
const editLabel: HistoryContextType['editLabel'] = useCallback(
92-
({ query, variables, headers, operationName, label, favorite }) => {
93-
historyStore.current.editLabel(
94-
query,
95-
variables,
96-
headers,
97-
operationName,
98-
label,
99-
favorite,
100-
);
110+
(operation: QueryStoreItem, index?: number) => {
111+
historyStore.current.editLabel(operation, index);
101112
setItems(historyStore.current.queries);
102113
},
103114
[],
104115
);
105116

106117
const toggleFavorite: HistoryContextType['toggleFavorite'] = useCallback(
107-
({ query, variables, headers, operationName, label, favorite }) => {
108-
historyStore.current.toggleFavorite(
109-
query,
110-
variables,
111-
headers,
112-
operationName,
113-
label,
114-
favorite,
115-
);
118+
(operation: QueryStoreItem) => {
119+
historyStore.current.toggleFavorite(operation);
116120
setItems(historyStore.current.queries);
117121
},
118122
[],
119123
);
120124

125+
const setActive: HistoryContextType['setActive'] = useCallback(
126+
(item: QueryStoreItem) => {
127+
return item;
128+
},
129+
[],
130+
);
131+
132+
const deleteFromHistory: HistoryContextType['deleteFromHistory'] =
133+
useCallback((item: QueryStoreItem, clearFavorites = false) => {
134+
historyStore.current.deleteHistory(item, clearFavorites);
135+
setItems(historyStore.current.queries);
136+
}, []);
137+
121138
const value = useMemo<HistoryContextType>(
122-
() => ({ addToHistory, editLabel, items, toggleFavorite }),
123-
[addToHistory, editLabel, items, toggleFavorite],
139+
() => ({
140+
addToHistory,
141+
editLabel,
142+
items,
143+
toggleFavorite,
144+
setActive,
145+
deleteFromHistory,
146+
}),
147+
[
148+
addToHistory,
149+
editLabel,
150+
items,
151+
toggleFavorite,
152+
setActive,
153+
deleteFromHistory,
154+
],
124155
);
125156

126157
return (

0 commit comments

Comments
 (0)