From a59dc855d94b332ca01b4a2477ef94ee68da9fe6 Mon Sep 17 00:00:00 2001
From: f0x52 <f0x@cthu.lu>
Date: Fri, 3 Feb 2023 12:07:40 +0100
Subject: [feature/frogend] (Mastodon) domain block CSV import (#1390)

* checkbox-list styling with taller <p> element

* CSV import/export, UI/UX improvements to import-export interface

* minor styling tweaks

* csv export, clean up export type branching

* abstract domain block entry validation

* foundation for PSL check + suggestions

* Squashed commit of the following:

commit e3655ba4fbea1d55738b2f9e407d3378af26afe6
Author: f0x <f0x@cthu.lu>
Date:   Tue Jan 31 15:19:10 2023 +0100

    let debug depend on env (prod/debug) again

commit 79c792b832a2b59e472dcdff646bad6d71b42cc9
Author: f0x <f0x@cthu.lu>
Date:   Tue Jan 31 00:34:01 2023 +0100

    update checklist components

commit 4367960fe4be4e3978077af06e63a729d64e32fb
Author: f0x <f0x@cthu.lu>
Date:   Mon Jan 30 23:46:20 2023 +0100

    checklist performance improvements

commit 204a4c02d16ffad189a6e8a6001d5bf4ff95fc4e
Author: f0x <f0x@cthu.lu>
Date:   Mon Jan 30 20:05:34 2023 +0100

    checklist field: use reducer for state

* remove debug logging

* show and use domain block suggestion

* restructure import/export buttons

* updating suggestions

* suggestion overview

* restructure check-list behavior, domain import/export
---
 web/source/settings/lib/form/check-list.jsx | 249 ++++++++++++++++------------
 1 file changed, 142 insertions(+), 107 deletions(-)

(limited to 'web/source/settings/lib/form/check-list.jsx')

diff --git a/web/source/settings/lib/form/check-list.jsx b/web/source/settings/lib/form/check-list.jsx
index c1233273d..b19e17a29 100644
--- a/web/source/settings/lib/form/check-list.jsx
+++ b/web/source/settings/lib/form/check-list.jsx
@@ -20,128 +20,163 @@
 
 const React = require("react");
 const syncpipe = require("syncpipe");
-
-function createState(entries, uniqueKey, oldState, defaultValue) {
-	return syncpipe(entries, [
-		(_) => _.map((entry) => {
-			let key = entry[uniqueKey];
-			return [
-				key,
-				{
-					...entry,
-					key,
-					checked: oldState[key]?.checked ?? entry.checked ?? defaultValue
+const { createSlice } = require("@reduxjs/toolkit");
+const { enableMapSet } = require("immer");
+
+enableMapSet(); // for use in reducers
+
+const { reducer, actions } = createSlice({
+	name: "checklist",
+	initialState: {}, // not handled by slice itself
+	reducers: {
+		updateAll: (state, { payload: checked }) => {
+			const selectedEntries = new Set();
+			return {
+				entries: syncpipe(state.entries, [
+					(_) => Object.values(_),
+					(_) => _.map((entry) => {
+						if (checked) {
+							selectedEntries.add(entry.key);
+						}
+						return [entry.key, {
+							...entry,
+							checked
+						}];
+					}),
+					(_) => Object.fromEntries(_)
+				]),
+				selectedEntries
+			};
+		},
+		update: (state, { payload: { key, value } }) => {
+			if (value.checked !== undefined) {
+				if (value.checked === true) {
+					state.selectedEntries.add(key);
+				} else {
+					state.selectedEntries.delete(key);
+				}
+			}
+
+			state.entries[key] = {
+				...state.entries[key],
+				...value
+			};
+		},
+		updateMultiple: (state, { payload }) => {
+			payload.forEach(([key, value]) => {
+				if (value.checked !== undefined) {
+					if (value.checked === true) {
+						state.selectedEntries.add(key);
+					} else {
+						state.selectedEntries.delete(key);
+					}
 				}
-			];
-		}),
-		(_) => Object.fromEntries(_)
-	]);
-}
 
-function updateAllState(state, newValue) {
-	return syncpipe(state, [
-		(_) => Object.values(_),
-		(_) => _.map((entry) => [entry.key, {
-			...entry,
-			checked: newValue
-		}]),
-		(_) => Object.fromEntries(_)
-	]);
-}
+				state.entries[key] = {
+					...state.entries[key],
+					...value
+				};
+			});
+		}
+	}
+});
 
-function updateState(state, key, newValue) {
+function initialState({ entries, uniqueKey, defaultValue }) {
+	const selectedEntries = new Set();
 	return {
-		...state,
-		[key]: {
-			...state[key],
-			...newValue
-		}
+		entries: syncpipe(entries, [
+			(_) => _.map((entry) => {
+				let key = entry[uniqueKey];
+				let checked = entry.checked ?? defaultValue;
+
+				if (checked) {
+					selectedEntries.add(key);
+				} else {
+					selectedEntries.delete(key);
+				}
+
+				return [
+					key,
+					{
+						...entry,
+						key,
+						checked
+					}
+				];
+			}),
+			(_) => Object.fromEntries(_)
+		]),
+		selectedEntries
 	};
 }
 
 module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "key", defaultValue = false }) {
-	const [state, setState] = React.useState({});
+	const [state, dispatch] = React.useReducer(reducer, null,
+		() => initialState({ entries, uniqueKey, defaultValue }) // initial state
+	);
 
-	const [someSelected, setSomeSelected] = React.useState(false);
-	const [toggleAllState, setToggleAllState] = React.useState(0);
 	const toggleAllRef = React.useRef(null);
 
 	React.useEffect(() => {
-		/* 
-			entries changed, update state,
-			re-using old state if available for key
-		*/
-		setState(createState(entries, uniqueKey, state, defaultValue));
-
-		/* eslint-disable-next-line react-hooks/exhaustive-deps */
-	}, [entries]);
-
-	React.useEffect(() => {
-		/* Updates (un)check all checkbox, based on shortcode checkboxes
-			 Can be 0 (not checked), 1 (checked) or 2 (indeterminate)
-		 */
-		if (toggleAllRef.current == null) {
-			return;
+		if (toggleAllRef.current != null) {
+			let some = state.selectedEntries.size > 0;
+			let all = false;
+			if (some) {
+				all = state.selectedEntries.size == Object.values(state.entries).length;
+			}
+			toggleAllRef.current.checked = all;
+			toggleAllRef.current.indeterminate = some && !all;
 		}
-
-		let values = Object.values(state);
-		/* one or more boxes are checked */
-		let some = values.some((v) => v.checked);
-
-		let all = false;
-		if (some) {
-			/* there's not at least one unchecked box */
-			all = !values.some((v) => v.checked == false);
+		// only needs to update when state.selectedEntries changes, not state.entries
+		// eslint-disable-next-line react-hooks/exhaustive-deps
+	}, [state.selectedEntries]);
+
+	const reset = React.useCallback(
+		() => dispatch(actions.updateAll(defaultValue)),
+		[defaultValue]
+	);
+
+	const onChange = React.useCallback(
+		(key, value) => dispatch(actions.update({ key, value })),
+		[]
+	);
+
+	const updateMultiple = React.useCallback(
+		(entries) => dispatch(actions.updateMultiple(entries)),
+		[]
+	);
+
+	return React.useMemo(() => {
+		function toggleAll(e) {
+			let checked = e.target.checked;
+			if (e.target.indeterminate) {
+				checked = false;
+			}
+			dispatch(actions.updateAll(checked));
 		}
 
-		setSomeSelected(some);
-
-		if (some && !all) {
-			setToggleAllState(2);
-			toggleAllRef.current.indeterminate = true;
-		} else {
-			setToggleAllState(all ? 1 : 0);
-			toggleAllRef.current.indeterminate = false;
+		function selectedValues() {
+			return Array.from((state.selectedEntries)).map((key) => ({
+				...state.entries[key] // returned as new object, because reducer state is immutable
+			}));
 		}
-	}, [state, toggleAllRef]);
-
-	function toggleAll(e) {
-		let selectAll = e.target.checked;
 
-		if (toggleAllState == 2) { // indeterminate
-			selectAll = false;
-		}
-
-		setState(updateAllState(state, selectAll));
-		setToggleAllState(selectAll);
-	}
-
-	function reset() {
-		setState(updateAllState(state, defaultValue));
-	}
-
-	function selectedValues() {
-		return syncpipe(state, [
-			(_) => Object.values(_),
-			(_) => _.filter((entry) => entry.checked)
-		]);
-	}
-
-	return Object.assign([
-		state,
-		reset,
-		{ name }
-	], {
-		name,
-		value: state,
-		onChange: (key, newValue) => setState(updateState(state, key, newValue)),
-		selectedValues,
-		reset,
-		someSelected,
-		toggleAll: {
-			ref: toggleAllRef,
-			value: toggleAllState,
-			onChange: toggleAll
-		}
-	});
+		return Object.assign([
+			state,
+			reset,
+			{ name }
+		], {
+			name,
+			value: state.entries,
+			onChange,
+			selectedValues,
+			reset,
+			someSelected: state.someChecked,
+			updateMultiple,
+			toggleAll: {
+				ref: toggleAllRef,
+				onChange: toggleAll
+			}
+		});
+	}, [state, reset, name, onChange, updateMultiple]);
 };
\ No newline at end of file
-- 
cgit v1.2.3

