#react #ui #designpatterns
Now we want to be able to dynamically load the widgets that the user wants, as well as allow them to order them.
Problems:
How do we give let each widget control the order of the list that’s contained in the parent?
How can we make react widgets share a common interface, so they can all be rendered generically, while still allowing customization?
How do we delay the creation of each widget until we have the necessary information they need to render?
The first thing I wanted to solve was to have a list of keys that represent different widgets, and then I wanted to map them dynamically on the home page. This would allow a user to have a list of their components and they would be displayed per user.
The first thing I tried was just to make a dictionary of the string name and the react component. This worked great at first, but when I wanted to let each component change the order of the list, that means we have to pass the use state of the list of widget keys down to each child. This list is known at the creation time of the dictionary, so it cant be passed to the widgets to create them in the dictionary.
The way to solve this is to use the factory pattern! The dictionary is now has the widget key, and it has a widget factory method, this allows us the create the component at the time we go to render it, and lets us pass the list of widget keys to the children.
This required an interface to have each widget that wants to be ordered receive their current index and the widget keys from the home component. Because we are type safe, we need to make some types for the factory methods. Finally there is the component for the drop down menu that each widget can render in their header.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
| import { FC, ReactNode } from "react";
export interface OrderedWidgetProps {
index: number;
widgetKeys: string[];
setWidgetKeys: (keys: string[]) => void;
}
export type OrderedWidgetFactory<
T extends OrderedWidgetProps = OrderedWidgetProps
> = (props: T) => React.FC<T>;
export const CreateOrderedWidgetFactory = <T extends OrderedWidgetProps>(
component: React.FC<T>
): OrderedWidgetFactory<T> => {
return (props: T) => {
return () => component(props);
};
};
export const OrderedWidgetOptions: FC<{
options?: ReactNode;
index: number;
widgetKeys: string[];
setWidgetKeys: (keys: string[]) => void;
}> = ({ options, widgetKeys, index, setWidgetKeys }) => {
const handleMoveUp = () => {
if (index === 0) return;
const newKeys = [...widgetKeys!];
const temp = newKeys[index];
newKeys[index] = newKeys[index - 1];
newKeys[index - 1] = temp;
setWidgetKeys!(newKeys);
};
const handleMoveDown = () => {
if (index === widgetKeys!.length - 1) return;
const newKeys = [...widgetKeys!];
const temp = newKeys[index];
newKeys[index] = newKeys[index + 1];
newKeys[index + 1] = temp;
setWidgetKeys!(newKeys);
};
const handleRemove = () => {
const newKeys = [...widgetKeys!];
newKeys.splice(index, 1);
setWidgetKeys!(newKeys);
};
return (
<div className="row">
<ul className="list-unstyled">
<li className="text-center row">
<button className="col btn" onClick={() => handleMoveUp()}>
<i className="bi bi-chevron-left"></i>
</button>
<span className="col my-auto">Order</span>
<button className="col btn" onClick={() => handleMoveDown()}>
<i className="bi bi-chevron-right"></i>
</button>
</li>
<li className="text-center">
<button className="btn" onClick={() => handleRemove()}>
<i className="bi bi-bookmark-fill"></i> Remove Card
</button>
</li>
</ul>
</div>
);
};
|
Here is the updated widget card.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
| import { FC, ReactNode } from "react";
import { useNavigate } from "react-router-dom";
export const WidgetCard: FC<{
children: ReactNode;
cardTitle: string;
path?: string;
size?: string;
headerContent?: ReactNode;
options?: ReactNode;
}> = ({ children, cardTitle, path = "", headerContent, options }) => {
const navigate = useNavigate();
const halfSize = { height: "400px" };
return (
<div className="card p-0 shadow " style={halfSize}>
<div className="card-header bg-primary text-light">
<div className="row">
{path != "" ? (
<button
className="border-0 bg-primary text-white col text-start"
onClick={() => navigate(path)}
>
<p className="m-0 my-auto">{cardTitle}</p>
</button>
) : (
<p className="col m-0 my-auto">{cardTitle}</p>
)}
{headerContent && (
<div className="col-auto px-0">{headerContent}</div>
)}
{options && (
<div className="col-auto my-auto dropdown">
<button
className="bg-primary border-0"
type="button"
id="optionsDropdownButton"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<i className="bi bi-three-dots-vertical text-light"></i>
</button>
<div className="dropdown-menu dropdown-menu-end">{options}</div>
</div>
)}
</div>
</div>
<div
className="card-body p-2 pt-0"
style={{ overflowY: "auto", overflowX: "hidden" }}
>
{children}
</div>
</div>
);
};
|
Heres the home page
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| import React, { useState } from "react";
import { WidgetDictionary as WidgetFactoryDictionary } from "../widgets/WidgetDictionary";
export const Home = () => {
const [widgetKeys, setWidgetKeys] = useState<string[]>([
"My Schedule",
"Upcoming Events",
"My Sites",
"Quick Links",
"Student Employment",
]);
const widgets = widgetKeys.map((key, index) => {
const OrderedWidget =
WidgetFactoryDictionary[key]?.({ index, widgetKeys, setWidgetKeys }) ??
(() => <></>);
return (
<OrderedWidget
index={index}
widgetKeys={widgetKeys}
setWidgetKeys={setWidgetKeys}
/>
);
});
return (
<>
<div className="row mx-auto my-3 p-0"></div>
<div className="row mx-auto my-3 p-0">
{widgets.map((widget, index) => (
<div className="col-12 col-md-6 col-xl-4" key={index}>
<div className="mb-3">{widget}</div>
</div>
))}
</div>
</>
);
};
|
1
2
3
4
5
6
7
8
9
| export const WidgetDictionary: Dictionary<OrderedWidgetFactory> = {
"My Schedule": CreateOrderedWidgetFactory(MySchedule),
"Upcoming Events": CreateOrderedWidgetFactory(UpcomingEventsCard),
"Quick Links": CreateOrderedWidgetFactory(QuickLinks),
"My Sites": CreateOrderedWidgetFactory(MySites),
"Student Employment": CreateOrderedWidgetFactory(StudentEmploymentCard),
};
export default WidgetDictionary;
|
Here’s an example widget that includes the interface and makes the options menu.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
| export const MySchedule: React.FC<OrderedWidgetProps> = ({
index,
widgetKeys,
setWidgetKeys,
}) => {
const [term, setTerm] = useState<Term>();
const termsQuery = useGetTermsQuery();
const terms = useMemo(() => termsQuery.data ?? [], [termsQuery.data]);
const userCoursesQuery = useGetUserCoursesForTermQuery(term?.sisTermId);
const courses = userCoursesQuery.data ?? [];
useEffect(() => {
if (!term) {
setTerm(terms[0]);
}
}, [termsQuery, term, terms]);
const WidgetBody = () => {
if (termsQuery.isLoading || userCoursesQuery.isLoading) return <Spinner />;
if (termsQuery.isError || userCoursesQuery.isError)
return <div className="text-center">Error getting schedule</div>;
if (!termsQuery.data || !userCoursesQuery.data || !term)
return <div className="text-center">Unable to get schedule</div>;
const tabs = [
...
];
return <WidgetTabMenu tabs={tabs} />;
};
const opt = (
<OrderedWidgetOptions
index={index}
widgetKeys={widgetKeys}
setWidgetKeys={setWidgetKeys}
/>
);
return (
<WidgetCard cardTitle="My Schedule" path="/schedule" options={opt}>
<WidgetBody />
</WidgetCard>
);
};
|