Skip to content

Commit 4b233a2

Browse files
committed
Add todo list form reducer examples to react playground
1 parent 2e86a44 commit 4b233a2

File tree

1 file changed

+301
-0
lines changed

1 file changed

+301
-0
lines changed

lib/components_guide_web/controllers/react_editor_controller.ex

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,6 +1055,307 @@ defmodule ComponentsGuideWeb.ReactEditorController do
10551055
render_source(conn, source)
10561056
end
10571057

1058+
def show(conn, %{"id" => "todo-list-reducer"}) do
1059+
source = ~s"""
1060+
function TodoItem({ item }) {
1061+
const domID = useId();
1062+
const descriptionID = `${domID}-description`;
1063+
const completedID = `${domID}-completed`;
1064+
1065+
return <fieldset class="flex items-center gap-3 py-2" data-id={item.id}>
1066+
<input name="completed[]" value={item.id} type="checkbox" checked={item.completed} id={completedID} class="w-5 h-5 text-purple-700 rounded" />
1067+
<label for={descriptionID} class="sr-only">Description</label>
1068+
<input name="description[]" id={descriptionID} type="text" value={item.description} class="flex-1 rounded" />
1069+
</fieldset>
1070+
}
1071+
1072+
const initialState = {
1073+
values: {
1074+
focusID: undefined,
1075+
items: [
1076+
{
1077+
id: crypto.randomUUID(),
1078+
description: "File taxes",
1079+
completed: false,
1080+
},
1081+
],
1082+
},
1083+
errors: {},
1084+
}
1085+
1086+
function TodoList() {
1087+
const [{ values, errors }, dispatch] = useReducer(reducer, initialState)
1088+
1089+
useLayoutEffect(() => {
1090+
if (!values.focusID) return;
1091+
1092+
const el = document.querySelector(`[data-id="${values.focusID}"] input[type=text]`);
1093+
el?.focus();
1094+
}, [values.focusID]);
1095+
1096+
return <form className="w-full max-w-[40rem] mx-auto pb-16" onSubmit={(event) => {
1097+
event.preventDefault()
1098+
dispatch(event)
1099+
}}>
1100+
<div onChange={dispatch}>
1101+
{values.items.map((item, index) => (
1102+
<>
1103+
<TodoItem
1104+
key={item.id}
1105+
index={index}
1106+
item={item}
1107+
dispatch={dispatch}
1108+
/>
1109+
</>
1110+
))}
1111+
</div>
1112+
<div className="my-4" />
1113+
<button
1114+
type="button"
1115+
className="py-1 px-3 text-violet-50 bg-violet-800 rounded"
1116+
onClick={dispatch}
1117+
data-action="addItem"
1118+
>Add item</button>
1119+
<pre class="mt-8">{JSON.stringify(values, null, 2)}</pre>
1120+
</form>
1121+
}
1122+
1123+
function changed(state, event) {
1124+
const { form } = event.target
1125+
if (!form) {
1126+
return
1127+
}
1128+
1129+
const formData = new FormData(form)
1130+
const descriptions = formData.getAll("description[]").map(String)
1131+
const completeds = new Set(formData.getAll("completed[]").map(String))
1132+
1133+
for (const [index, item] of state.items.entries()) {
1134+
item.description = descriptions[index]
1135+
item.completed = completeds.has(item.id)
1136+
}
1137+
}
1138+
1139+
function clicked(state, event) {
1140+
// event.currentTarget should be the button. The first click it is, but the clicks after are null. Not sure why.
1141+
1142+
if (!(event.target instanceof Element)) {
1143+
return
1144+
}
1145+
const button = event.target.closest("button")
1146+
if (!button) {
1147+
return
1148+
}
1149+
1150+
const {
1151+
dataset: { action, payload },
1152+
} = button
1153+
1154+
if (action === "addItem") {
1155+
const id = crypto.randomUUID()
1156+
state.items.push({
1157+
id,
1158+
description: "",
1159+
completed: false,
1160+
})
1161+
state.focusID = id
1162+
} else if (action === "removeItem") {
1163+
const { id } = JSON.parse(payload) // TODO: use Zod?
1164+
const index = state.items.findIndex((q) => q.id === id)
1165+
state.items.splice(index, 1)
1166+
}
1167+
}
1168+
1169+
function reducer(state, event) {
1170+
state = structuredClone(state)
1171+
1172+
if (isInputChangeEvent(event)) {
1173+
changed(state.values, event)
1174+
} else if (isClickEvent(event)) {
1175+
clicked(state.values, event)
1176+
} else if (isSubmitEvent(event)) {
1177+
state.errors = validate(state.values)
1178+
}
1179+
1180+
return state
1181+
}
1182+
1183+
export function isInputChangeEvent(event) {
1184+
return event.type === "change" && event.target instanceof HTMLInputElement
1185+
}
1186+
1187+
export function isClickEvent(event) {
1188+
return event.type === "click"
1189+
}
1190+
1191+
export function isSubmitEvent(event) {
1192+
return event.type === "submit"
1193+
}
1194+
1195+
export default function App() {
1196+
return <TodoList />;
1197+
}
1198+
"""
1199+
1200+
render_source(conn, source)
1201+
end
1202+
1203+
def show(conn, %{"id" => "todo-list-reducer-revisions"}) do
1204+
source = ~s"""
1205+
function TodoItem({ item }) {
1206+
const domID = useId();
1207+
const descriptionID = `${domID}-description`;
1208+
const completedID = `${domID}-completed`;
1209+
1210+
return <fieldset class="flex items-center gap-3 py-2 after:content-[attr(data-revision)]" data-id={item.id}>
1211+
<input name="completed[]" value={item.id} type="checkbox" checked={item.completed} id={completedID} class="w-5 h-5 text-purple-700 rounded" />
1212+
<label for={descriptionID} class="sr-only">Description</label>
1213+
<input name="description[]" id={descriptionID} type="text" value={item.description} class="flex-1 rounded" />
1214+
</fieldset>
1215+
}
1216+
1217+
const initialState = {
1218+
values: {
1219+
focusID: undefined,
1220+
items: [
1221+
{
1222+
id: crypto.randomUUID(),
1223+
description: "File taxes",
1224+
completed: false,
1225+
},
1226+
],
1227+
},
1228+
errors: {},
1229+
}
1230+
1231+
function TodoList() {
1232+
const [{ values, errors }, dispatch] = useReducer(reducer, initialState)
1233+
1234+
useLayoutEffect(() => {
1235+
if (!values.focusID) return;
1236+
1237+
const el = document.querySelector(`[data-id="${values.focusID}"] input[type=text]`);
1238+
el?.focus();
1239+
}, [values.focusID]);
1240+
1241+
return <form className="w-full max-w-[40rem] mx-auto pb-16" onSubmit={(event) => {
1242+
event.preventDefault()
1243+
dispatch(event)
1244+
}}>
1245+
<div onChange={dispatch}>
1246+
{values.items.map((item, index) => (
1247+
<>
1248+
<TodoItem
1249+
key={item.id}
1250+
index={index}
1251+
item={item}
1252+
dispatch={dispatch}
1253+
/>
1254+
</>
1255+
))}
1256+
</div>
1257+
<div className="my-4" />
1258+
<button
1259+
type="button"
1260+
className="py-1 px-3 text-violet-50 bg-violet-800 rounded"
1261+
onClick={dispatch}
1262+
data-action="addItem"
1263+
>Add item</button>
1264+
<pre class="mt-8">{JSON.stringify(values, null, 2)}</pre>
1265+
</form>;
1266+
}
1267+
1268+
function elChanged(el) {
1269+
const newRevision = parseInt(el.dataset.revision ?? "0") + 1;
1270+
el.dataset.revision = newRevision;
1271+
return newRevision;
1272+
}
1273+
1274+
function changed(state, event) {
1275+
const input = event.target;
1276+
const { form, dataset } = input;
1277+
if (!form) {
1278+
return;
1279+
}
1280+
1281+
const formRevision = elChanged(form);
1282+
dataset.revision = formRevision;
1283+
input.closest('fieldset').dataset.revision = formRevision;
1284+
1285+
const formData = new FormData(form);
1286+
const descriptions = formData.getAll("description[]").map(String);
1287+
const completeds = new Set(formData.getAll("completed[]").map(String));
1288+
1289+
for (const [index, item] of state.items.entries()) {
1290+
item.description = descriptions[index];
1291+
item.completed = completeds.has(item.id);
1292+
}
1293+
}
1294+
1295+
function clicked(state, event) {
1296+
// event.currentTarget should be the button. The first click it is, but the clicks after are null. Not sure why.
1297+
1298+
if (!(event.target instanceof Element)) {
1299+
return
1300+
}
1301+
const button = event.target.closest("button")
1302+
if (!button) {
1303+
return
1304+
}
1305+
1306+
const {
1307+
dataset: { action, payload },
1308+
} = button
1309+
1310+
if (action === "addItem") {
1311+
const id = crypto.randomUUID()
1312+
state.items.push({
1313+
id,
1314+
description: "",
1315+
completed: false,
1316+
})
1317+
state.focusID = id
1318+
} else if (action === "removeItem") {
1319+
const { id } = JSON.parse(payload) // TODO: use Zod?
1320+
const index = state.items.findIndex((q) => q.id === id)
1321+
state.items.splice(index, 1)
1322+
}
1323+
}
1324+
1325+
function reducer(state, event) {
1326+
state = structuredClone(state)
1327+
1328+
if (isInputChangeEvent(event)) {
1329+
changed(state.values, event)
1330+
} else if (isClickEvent(event)) {
1331+
clicked(state.values, event)
1332+
} else if (isSubmitEvent(event)) {
1333+
state.errors = validate(state.values)
1334+
}
1335+
1336+
return state
1337+
}
1338+
1339+
export function isInputChangeEvent(event) {
1340+
return event.type === "change" && event.target instanceof HTMLInputElement
1341+
}
1342+
1343+
export function isClickEvent(event) {
1344+
return event.type === "click"
1345+
}
1346+
1347+
export function isSubmitEvent(event) {
1348+
return event.type === "submit"
1349+
}
1350+
1351+
export default function App() {
1352+
return <TodoList />;
1353+
}
1354+
"""
1355+
1356+
render_source(conn, source)
1357+
end
1358+
10581359
def show(conn, params) do
10591360
source = ~s"""
10601361
export default function App() {

0 commit comments

Comments
 (0)