Skip to content

Commit 9342ab8

Browse files
committed
cleanup: abstract block builder into a generic component and make filter settings look the same & feature: put which tab settings is on in url params
1 parent 6f0336d commit 9342ab8

File tree

7 files changed

+928
-1057
lines changed

7 files changed

+928
-1057
lines changed
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
import React, { useState, useCallback, ReactNode } from "react";
2+
import {
3+
Card,
4+
Button,
5+
Modal,
6+
ResourceList,
7+
ResourceItem,
8+
Text,
9+
BlockStack,
10+
InlineStack,
11+
ButtonGroup,
12+
EmptyState,
13+
Box,
14+
InlineGrid,
15+
} from "@shopify/polaris";
16+
import { PlusIcon, DeleteIcon, EditIcon } from "@shopify/polaris-icons";
17+
18+
export interface BuilderViewProps<T extends { id: string }> {
19+
// Data
20+
items: T[];
21+
onItemsChange: (items: T[]) => void;
22+
23+
// Item rendering
24+
renderItemContent: (item: T) => ReactNode;
25+
renderEditForm: (props: EditFormProps<T>) => ReactNode;
26+
27+
// Labels and text
28+
labels: {
29+
singular: string;
30+
plural: string;
31+
addButton: string;
32+
editTitle: string;
33+
addTitle: string;
34+
emptyStateHeading: string;
35+
emptyStateDescription?: string;
36+
deleteConfirmTitle?: string;
37+
deleteConfirmMessage?: string;
38+
};
39+
40+
cardWrapper?: boolean;
41+
headerContent?: ReactNode;
42+
onItemClick?: (item: T) => void;
43+
validateItem?: (item: T) => boolean;
44+
onSaveSuccess?: (item: T, isNew: boolean) => void;
45+
customActions?: (item: T) => ReactNode;
46+
47+
header?: {
48+
title: string;
49+
subtitle: string;
50+
};
51+
}
52+
53+
export interface EditFormProps<T> {
54+
item: T;
55+
onChange: (item: T) => void;
56+
onSave: () => void;
57+
onCancel: () => void;
58+
isNew: boolean;
59+
}
60+
61+
export function BuilderView<T extends { id: string }>({
62+
items,
63+
onItemsChange,
64+
renderItemContent,
65+
renderEditForm,
66+
labels,
67+
cardWrapper = true,
68+
headerContent,
69+
onItemClick,
70+
validateItem,
71+
onSaveSuccess,
72+
customActions,
73+
header,
74+
}: BuilderViewProps<T>) {
75+
const [editingItemId, setEditingItemId] = useState<string | null>(null);
76+
const [isAddingNew, setIsAddingNew] = useState(false);
77+
const [editFormData, setEditFormData] = useState<T | null>(null);
78+
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
79+
const [itemIdToDelete, setItemIdToDelete] = useState<string | null>(null);
80+
81+
const handleSaveItem = useCallback(() => {
82+
if (!editFormData) return;
83+
84+
if (validateItem && !validateItem(editFormData)) {
85+
return;
86+
}
87+
88+
let updatedItems: T[];
89+
const isNew = isAddingNew;
90+
91+
if (editingItemId) {
92+
updatedItems = items.map((item) =>
93+
item.id === editingItemId ? editFormData : item,
94+
);
95+
} else if (isAddingNew) {
96+
updatedItems = [...items, editFormData];
97+
} else {
98+
return;
99+
}
100+
101+
onItemsChange(updatedItems);
102+
setEditFormData(null);
103+
setEditingItemId(null);
104+
setIsAddingNew(false);
105+
106+
if (onSaveSuccess) {
107+
onSaveSuccess(editFormData, isNew);
108+
}
109+
}, [
110+
editFormData,
111+
editingItemId,
112+
isAddingNew,
113+
items,
114+
onItemsChange,
115+
validateItem,
116+
onSaveSuccess,
117+
]);
118+
119+
const handleStartAddNew = useCallback(() => {
120+
setEditingItemId(null);
121+
setIsAddingNew(true);
122+
// Create a new item with just an ID
123+
const newItem = { id: String(Date.now()) } as T;
124+
setEditFormData(newItem);
125+
}, []);
126+
127+
const handleStartEdit = useCallback((item: T) => {
128+
setIsAddingNew(false);
129+
setEditingItemId(item.id);
130+
setEditFormData({ ...item });
131+
}, []);
132+
133+
const handleCancelEdit = useCallback(() => {
134+
setEditingItemId(null);
135+
setIsAddingNew(false);
136+
setEditFormData(null);
137+
}, []);
138+
139+
const handleDeleteTrigger = useCallback((id: string) => {
140+
setItemIdToDelete(id);
141+
setShowDeleteConfirmModal(true);
142+
}, []);
143+
144+
const confirmDelete = useCallback(() => {
145+
if (itemIdToDelete) {
146+
const updatedItems = items.filter((item) => item.id !== itemIdToDelete);
147+
onItemsChange(updatedItems);
148+
}
149+
setItemIdToDelete(null);
150+
setShowDeleteConfirmModal(false);
151+
}, [itemIdToDelete, items, onItemsChange]);
152+
153+
const cancelDelete = useCallback(() => {
154+
setItemIdToDelete(null);
155+
setShowDeleteConfirmModal(false);
156+
}, []);
157+
158+
const renderItem = (item: T) => {
159+
if (editingItemId === item.id && editFormData) {
160+
return (
161+
<Box paddingBlockEnd="400">
162+
{renderEditForm({
163+
item: editFormData,
164+
onChange: setEditFormData,
165+
onSave: handleSaveItem,
166+
onCancel: handleCancelEdit,
167+
isNew: false,
168+
})}
169+
</Box>
170+
);
171+
}
172+
173+
return (
174+
<ResourceItem
175+
id={item.id}
176+
accessibilityLabel={`View details for ${labels.singular}`}
177+
onClick={() => {
178+
if (onItemClick) {
179+
onItemClick(item);
180+
} else {
181+
handleStartEdit(item);
182+
}
183+
}}
184+
>
185+
<InlineStack
186+
wrap={false}
187+
blockAlign="center"
188+
align="space-between"
189+
gap="200"
190+
>
191+
<Box minWidth="0" maxWidth="calc(100% - 100px)" width="100%">
192+
{renderItemContent(item)}
193+
</Box>
194+
<ButtonGroup>
195+
{customActions && customActions(item)}
196+
<Button
197+
icon={EditIcon}
198+
accessibilityLabel={`Edit ${labels.singular}`}
199+
onClick={() => {
200+
handleStartEdit(item);
201+
}}
202+
variant="tertiary"
203+
/>
204+
<Button
205+
icon={DeleteIcon}
206+
accessibilityLabel={`Delete ${labels.singular}`}
207+
onPointerDown={(e: React.PointerEvent) => {
208+
e.stopPropagation();
209+
handleDeleteTrigger(item.id);
210+
}}
211+
variant="tertiary"
212+
tone="critical"
213+
/>
214+
</ButtonGroup>
215+
</InlineStack>
216+
</ResourceItem>
217+
);
218+
};
219+
220+
const emptyStateMarkup = (
221+
<EmptyState
222+
heading={labels.emptyStateHeading}
223+
action={{
224+
content: labels.addButton,
225+
icon: PlusIcon,
226+
onAction: handleStartAddNew,
227+
}}
228+
image="https://cdn.shopify.com/s/files/1/0262/4071/2726/files/emptystate-files.png"
229+
>
230+
{labels.emptyStateDescription && (
231+
<Text as="p" variant="bodyMd">
232+
{labels.emptyStateDescription}
233+
</Text>
234+
)}
235+
</EmptyState>
236+
);
237+
238+
const content = (
239+
<BlockStack gap="400">
240+
{headerContent}
241+
242+
{items.length > 0 ? (
243+
<>
244+
<ResourceList
245+
resourceName={{ singular: labels.singular, plural: labels.plural }}
246+
items={items}
247+
renderItem={renderItem}
248+
/>
249+
</>
250+
) : (
251+
!isAddingNew && emptyStateMarkup
252+
)}
253+
254+
{isAddingNew && editFormData && (
255+
<Box paddingBlockStart="400" paddingBlockEnd="400">
256+
{renderEditForm({
257+
item: editFormData,
258+
onChange: setEditFormData,
259+
onSave: handleSaveItem,
260+
onCancel: handleCancelEdit,
261+
isNew: true,
262+
})}
263+
</Box>
264+
)}
265+
266+
{!isAddingNew && !editingItemId && items.length > 0 && (
267+
<InlineStack align="end">
268+
<ButtonGroup>
269+
<Button
270+
onClick={handleStartAddNew}
271+
variant="primary"
272+
icon={PlusIcon}
273+
>
274+
{labels.addButton}
275+
</Button>
276+
</ButtonGroup>
277+
</InlineStack>
278+
)}
279+
</BlockStack>
280+
);
281+
282+
const listContent = cardWrapper ? <Card>{content}</Card> : content;
283+
284+
const finalContent = header ? (
285+
<Box paddingInline="400">
286+
<BlockStack gap={{ xs: "800", sm: "400" }}>
287+
<InlineGrid columns={{ xs: "1fr", md: "2fr 5fr" }} gap="400">
288+
<Box
289+
as="section"
290+
paddingInlineStart={{ xs: "400", sm: "0" }}
291+
paddingInlineEnd={{ xs: "400", sm: "0" }}
292+
>
293+
<BlockStack gap="400">
294+
<Text as="h3" variant="headingMd">
295+
{header.title}
296+
</Text>
297+
<Text as="p" variant="bodyMd">
298+
{header.subtitle}
299+
</Text>
300+
</BlockStack>
301+
</Box>
302+
{listContent}
303+
</InlineGrid>
304+
</BlockStack>
305+
</Box>
306+
) : (
307+
listContent
308+
);
309+
310+
return (
311+
<>
312+
{finalContent}
313+
314+
<Modal
315+
open={showDeleteConfirmModal}
316+
onClose={cancelDelete}
317+
title={labels.deleteConfirmTitle || "Confirm Deletion"}
318+
primaryAction={{
319+
content: "Delete",
320+
onAction: confirmDelete,
321+
destructive: true,
322+
}}
323+
secondaryActions={[
324+
{
325+
content: "Cancel",
326+
onAction: cancelDelete,
327+
},
328+
]}
329+
>
330+
<Modal.Section>
331+
<BlockStack>
332+
<Text as="p">
333+
{labels.deleteConfirmMessage ||
334+
`Are you sure you want to delete this ${labels.singular}?`}
335+
</Text>
336+
</BlockStack>
337+
</Modal.Section>
338+
</Modal>
339+
</>
340+
);
341+
}

0 commit comments

Comments
 (0)