diff options
| author | 2023-02-03 12:07:40 +0100 | |
|---|---|---|
| committer | 2023-02-03 12:07:40 +0100 | |
| commit | a59dc855d94b332ca01b4a2477ef94ee68da9fe6 (patch) | |
| tree | 0f8397b591927d317a2400e6f2d7f6ef1ef527db /web/source/settings/admin | |
| parent | [chore] Text formatting overhaul (#1406) (diff) | |
| download | gotosocial-a59dc855d94b332ca01b4a2477ef94ee68da9fe6.tar.xz | |
[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
Diffstat (limited to 'web/source/settings/admin')
6 files changed, 607 insertions, 313 deletions
| diff --git a/web/source/settings/admin/emoji/remote/parse-from-toot.js b/web/source/settings/admin/emoji/remote/parse-from-toot.js index 309619ea4..84bbbdc92 100644 --- a/web/source/settings/admin/emoji/remote/parse-from-toot.js +++ b/web/source/settings/admin/emoji/remote/parse-from-toot.js @@ -129,14 +129,16 @@ function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {  			title: "No emoji selected, cannot perform any actions"  		}; +	const checkListExtraProps = React.useCallback(() => ({ localEmojiCodes }), [localEmojiCodes]); +  	return (  		<div className="parsed">  			<span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>  			<form onSubmit={formSubmit}>  				<CheckList  					field={form.selectedEmoji} -					Component={EmojiEntry} -					localEmojiCodes={localEmojiCodes} +					EntryComponent={EmojiEntry} +					getExtraProps={checkListExtraProps}  				/>  				<CategorySelect @@ -170,7 +172,7 @@ function ErrorList({ errors }) {  	);  } -function EmojiEntry({ entry: emoji, localEmojiCodes, onChange }) { +function EmojiEntry({ entry: emoji, onChange, extraProps: { localEmojiCodes } }) {  	const shortcodeField = useTextInput("shortcode", {  		defaultValue: emoji.shortcode,  		validator: function validateShortcode(code) { @@ -181,9 +183,16 @@ function EmojiEntry({ entry: emoji, localEmojiCodes, onChange }) {  	});  	React.useEffect(() => { -		onChange({ valid: shortcodeField.valid }); -		/* eslint-disable-next-line react-hooks/exhaustive-deps */ -	}, [shortcodeField.valid]); +		if (emoji.valid != shortcodeField.valid) { +			onChange({ valid: shortcodeField.valid }); +		} +	}, [onChange, emoji.valid, shortcodeField.valid]); + +	React.useEffect(() => { +		shortcodeField.validate(); +		// only need this update if it's the emoji.checked that updated, not shortcodeField +		// eslint-disable-next-line react-hooks/exhaustive-deps +	}, [emoji.checked]);  	return (  		<> diff --git a/web/source/settings/admin/federation/import-export.js b/web/source/settings/admin/federation/import-export.js deleted file mode 100644 index a11174b1f..000000000 --- a/web/source/settings/admin/federation/import-export.js +++ /dev/null @@ -1,307 +0,0 @@ -/* -	GoToSocial -	Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org - -	This program is free software: you can redistribute it and/or modify -	it under the terms of the GNU Affero General Public License as published by -	the Free Software Foundation, either version 3 of the License, or -	(at your option) any later version. - -	This program is distributed in the hope that it will be useful, -	but WITHOUT ANY WARRANTY; without even the implied warranty of -	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the -	GNU Affero General Public License for more details. - -	You should have received a copy of the GNU Affero General Public License -	along with this program.  If not, see <http://www.gnu.org/licenses/>. -*/ - -"use strict"; - -const React = require("react"); -const { Switch, Route, Redirect, useLocation } = require("wouter"); - -const query = require("../../lib/query"); - -const { -	useTextInput, -	useBoolInput, -	useRadioInput, -	useCheckListInput -} = require("../../lib/form"); - -const useFormSubmit = require("../../lib/form/submit"); - -const { -	TextInput, -	TextArea, -	Checkbox, -	Select, -	RadioGroup -} = require("../../components/form/inputs"); - -const CheckList = require("../../components/check-list"); -const MutationButton = require("../../components/form/mutation-button"); -const isValidDomain = require("is-valid-domain"); -const FormWithData = require("../../lib/form/form-with-data"); -const { Error } = require("../../components/error"); - -const baseUrl = "/settings/admin/federation/import-export"; - -module.exports = function ImportExport() { -	const [updateFromFile, setUpdateFromFile] = React.useState(false); -	const form = { -		domains: useTextInput("domains"), -		exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true }) -	}; - -	const [submitParse, parseResult] = useFormSubmit(form, query.useProcessDomainListMutation()); -	const [submitExport, exportResult] = useFormSubmit(form, query.useExportDomainListMutation()); - -	function fileChanged(e) { -		const reader = new FileReader(); -		reader.onload = function (read) { -			form.domains.setter(read.target.result); -			setUpdateFromFile(true); -		}; -		reader.readAsText(e.target.files[0]); -	} - -	React.useEffect(() => { -		if (exportResult.isSuccess) { -			form.domains.setter(exportResult.data); -		} -		/* eslint-disable-next-line react-hooks/exhaustive-deps */ -	}, [exportResult]); - -	const [_location, setLocation] = useLocation(); - -	if (updateFromFile) { -		setUpdateFromFile(false); -		submitParse(); -	} - -	return ( -		<Switch> -			<Route path={`${baseUrl}/list`}> -				{!parseResult.isSuccess && <Redirect to={baseUrl} />} - -				<h1> -					<span className="button" onClick={() => { -						parseResult.reset(); -						setLocation(baseUrl); -					}}> -						< back -					</span> Confirm import: -				</h1> -				<FormWithData -					dataQuery={query.useInstanceBlocksQuery} -					DataForm={ImportList} -					list={parseResult.data} -				/> -			</Route> - -			<Route> -				{parseResult.isSuccess && <Redirect to={`${baseUrl}/list`} />} -				<h2>Import / Export suspended domains</h2> - -				<div> -					<form onSubmit={submitParse}> -						<TextArea -							field={form.domains} -							label="Domains, one per line (plaintext) or JSON" -							placeholder={`google.com\nfacebook.com`} -							rows={8} -						/> - -						<div className="row"> -							<MutationButton label="Import" result={parseResult} showError={false} /> -							<button type="button" className="with-padding"> -								<label> -									Import file -									<input className="hidden" type="file" onChange={fileChanged} accept="application/json,text/plain" /> -								</label> -							</button> -						</div> -					</form> -					<form onSubmit={submitExport}> -						<div className="row"> -							<MutationButton name="export" label="Export" result={exportResult} showError={false} /> -							<MutationButton name="export-file" label="Export file" result={exportResult} showError={false} /> -							<Select -								field={form.exportType} -								options={<> -									<option value="plain">Text</option> -									<option value="json">JSON</option> -								</>} -							/> -						</div> -					</form> -					{parseResult.error && <Error error={parseResult.error} />} -					{exportResult.error && <Error error={exportResult.error} />} -				</div> -			</Route> -		</Switch> -	); -}; - -function ImportList({ list, data: blockedInstances }) { -	const hasComment = React.useMemo(() => { -		let hasPublic = false; -		let hasPrivate = false; - -		list.some((entry) => { -			if (entry.public_comment?.length > 0) { -				hasPublic = true; -			} - -			if (entry.private_comment?.length > 0) { -				hasPrivate = true; -			} - -			return hasPublic && hasPrivate; -		}); - -		if (hasPublic && hasPrivate) { -			return { both: true }; -		} else if (hasPublic) { -			return { type: "public_comment" }; -		} else if (hasPrivate) { -			return { type: "private_comment" }; -		} else { -			return {}; -		} -	}, [list]); - -	const showComment = useTextInput("showComment", { defaultValue: hasComment.type ?? "public_comment" }); -	let commentName = ""; -	if (showComment.value == "public_comment") { commentName = "Public comment"; } -	if (showComment.value == "private_comment") { commentName = "Private comment"; } - -	const form = { -		domains: useCheckListInput("domains", { -			entries: list, -			uniqueKey: "domain" -		}), -		obfuscate: useBoolInput("obfuscate"), -		privateComment: useTextInput("private_comment", { -			defaultValue: `Imported on ${new Date().toLocaleString()}` -		}), -		privateCommentBehavior: useRadioInput("private_comment_behavior", { -			defaultValue: "append", -			options: { -				append: "Append to", -				replace: "Replace" -			} -		}), -		publicComment: useTextInput("public_comment"), -		publicCommentBehavior: useRadioInput("public_comment_behavior", { -			defaultValue: "append", -			options: { -				append: "Append to", -				replace: "Replace" -			} -		}), -	}; - -	const [importDomains, importResult] = useFormSubmit(form, query.useImportDomainListMutation(), { changedOnly: false }); - -	return ( -		<> -			<form onSubmit={importDomains} className="suspend-import-list"> -				<span>{list.length} domain{list.length != 1 ? "s" : ""} in this list</span> - -				{hasComment.both && -					<Select field={showComment} options={ -						<> -							<option value="public_comment">Show public comments</option> -							<option value="private_comment">Show private comments</option> -						</> -					} /> -				} - -				<CheckList -					field={form.domains} -					Component={DomainEntry} -					header={ -						<> -							<b>Domain</b> -							<b></b> -							<b>{commentName}</b> -						</> -					} -					blockedInstances={blockedInstances} -					commentType={showComment.value} -				/> - -				<TextArea -					field={form.privateComment} -					label="Private comment" -					rows={3} -				/> -				<RadioGroup -					field={form.privateCommentBehavior} -					label="imported private comment" -				/> - -				<TextArea -					field={form.publicComment} -					label="Public comment" -					rows={3} -				/> -				<RadioGroup -					field={form.publicCommentBehavior} -					label="imported public comment" -				/> - -				<Checkbox -					field={form.obfuscate} -					label="Obfuscate domains in public lists" -				/> - -				<MutationButton label="Import" result={importResult} /> -			</form> -		</> -	); -} - -function DomainEntry({ entry, onChange, blockedInstances, commentType }) { -	const domainField = useTextInput("domain", { -		defaultValue: entry.domain, -		validator: (value) => { -			return (entry.checked && !isValidDomain(value, { wildcard: true, allowUnicode: true })) -				? "Invalid domain" -				: ""; -		} -	}); - -	React.useEffect(() => { -		onChange({ valid: domainField.valid }); -		/* eslint-disable-next-line react-hooks/exhaustive-deps */ -	}, [domainField.valid]); - -	let icon = null; - -	if (blockedInstances[domainField.value] != undefined) { -		icon = ( -			<> -				<i className="fa fa-history already-blocked" aria-hidden="true" title="Domain block already exists"></i> -				<span className="sr-only">Domain block already exists.</span> -			</> -		); -	} - -	return ( -		<> -			<TextInput -				field={domainField} -				onChange={(e) => { -					domainField.onChange(e); -					onChange({ domain: e.target.value, checked: true }); -				}} -			/> -			<span id="icon">{icon}</span> -			<p>{entry[commentType]}</p> -		</> -	); -}
\ No newline at end of file diff --git a/web/source/settings/admin/federation/import-export/export-format-table.jsx b/web/source/settings/admin/federation/import-export/export-format-table.jsx new file mode 100644 index 000000000..72b2db04f --- /dev/null +++ b/web/source/settings/admin/federation/import-export/export-format-table.jsx @@ -0,0 +1,64 @@ +/* +	GoToSocial +	Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +"use strict"; + +const React = require("react"); + +module.exports = function ExportFormatTable() { +	return ( +		<table className="export-format-table"> +			<thead> +				<tr> +					<th rowSpan={2} /> +					<th colSpan={2}>Includes</th> +					<th colSpan={2}>Importable by</th> +				</tr> +				<tr> +					<th>Domain</th> +					<th>Public comment</th> +					<th>GoToSocial</th> +					<th>Mastodon</th> +				</tr> +			</thead> +			<tbody> +				<Format name="Text" info={[true, false, true, false]} /> +				<Format name="JSON" info={[true, true, true, false]} /> +				<Format name="CSV" info={[true, true, true, true]} /> +			</tbody> +		</table> +	); +}; + +function Format({ name, info }) { +	return ( +		<tr> +			<td><b>{name}</b></td> +			{info.map((b, key) => <td key={key} className="bool">{bool(b)}</td>)} +		</tr> +	); +} + +function bool(val) { +	return ( +		<> +			<i className={`fa fa-${val ? "check" : "times"}`} aria-hidden="true"></i> +			<span className="sr-only">{val ? "Yes" : "No"}</span> +		</> +	); +}
\ No newline at end of file diff --git a/web/source/settings/admin/federation/import-export/form.jsx b/web/source/settings/admin/federation/import-export/form.jsx new file mode 100644 index 000000000..afd2d775d --- /dev/null +++ b/web/source/settings/admin/federation/import-export/form.jsx @@ -0,0 +1,123 @@ +/* +	GoToSocial +	Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +"use strict"; + +const React = require("react"); + +const query = require("../../../lib/query"); +const useFormSubmit = require("../../../lib/form/submit"); + +const { +	TextArea, +	Select, +} = require("../../../components/form/inputs"); + +const MutationButton = require("../../../components/form/mutation-button"); + +const { Error } = require("../../../components/error"); +const ExportFormatTable = require("./export-format-table"); + +module.exports = function ImportExportForm({ form, submitParse, parseResult }) { +	const [submitExport, exportResult] = useFormSubmit(form, query.useExportDomainListMutation()); + +	const [updateFromFile, setUpdateFromFile] = React.useState(false); + +	function fileChanged(e) { +		const reader = new FileReader(); +		reader.onload = function (read) { +			form.domains.setter(read.target.result); +			setUpdateFromFile(true); +		}; +		reader.readAsText(e.target.files[0]); +	} + +	React.useEffect(() => { +		if (exportResult.isSuccess) { +			form.domains.setter(exportResult.data); +		} +		/* eslint-disable-next-line react-hooks/exhaustive-deps */ +	}, [exportResult]); + +	if (updateFromFile) { +		setUpdateFromFile(false); +		submitParse(); +	} +	return ( +		<> +			<h1>Import / Export suspended domains</h1> +			<p> +				This page can be used to import and export lists of domains to suspend. +				Exports can be done in various formats, with varying functionality and support in other software. +				Imports will automatically detect what format is being processed. +			</p> +			<ExportFormatTable /> +			<div className="import-export"> +				<TextArea +					field={form.domains} +					label="Domains" +					placeholder={`google.com\nfacebook.com`} +					rows={8} +				/> + +				<div className="button-grid"> +					<MutationButton +						label="Import" +						type="button" +						onClick={() => submitParse()} +						result={parseResult} +						showError={false} +					/> +					<label className="button"> +						Import file +						<input +							type="file" +							className="hidden" +							onChange={fileChanged} +							accept="application/json,text/plain,text/csv" +						/> +					</label> +					<b /> {/* grid filler */} +					<MutationButton +						label="Export" +						type="button" +						onClick={() => submitExport("export")} +						result={exportResult} showError={false} +					/> +					<MutationButton label="Export to file" type="button" onClick={() => submitExport("export-file")} result={exportResult} showError={false} /> +					<div className="export-file"> +						<span> +							as +						</span> +						<Select +							field={form.exportType} +							options={<> +								<option value="plain">Text</option> +								<option value="json">JSON</option> +								<option value="csv">CSV</option> +							</>} +						/> +					</div> +				</div> + +				{parseResult.error && <Error error={parseResult.error} />} +				{exportResult.error && <Error error={exportResult.error} />} +			</div> +		</> +	); +};
\ No newline at end of file diff --git a/web/source/settings/admin/federation/import-export/index.jsx b/web/source/settings/admin/federation/import-export/index.jsx new file mode 100644 index 000000000..3039b98f3 --- /dev/null +++ b/web/source/settings/admin/federation/import-export/index.jsx @@ -0,0 +1,78 @@ +/* +	GoToSocial +	Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +"use strict"; + +const React = require("react"); +const { Switch, Route, Redirect, useLocation } = require("wouter"); + +const query = require("../../../lib/query"); + +const { +	useTextInput, +} = require("../../../lib/form"); + +const useFormSubmit = require("../../../lib/form/submit"); + +const ProcessImport = require("./process"); +const ImportExportForm = require("./form"); + +const baseUrl = "/settings/admin/federation/import-export"; + +module.exports = function ImportExport() { +	const form = { +		domains: useTextInput("domains"), +		exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true }) +	}; + +	const [submitParse, parseResult] = useFormSubmit(form, query.useProcessDomainListMutation()); + +	const [_location, setLocation] = useLocation(); + +	return ( +		<Switch> +			<Route path={`${baseUrl}/process`}> +				{parseResult.isSuccess ? ( +					<> +						<h1> +							<span className="button" onClick={() => { +								parseResult.reset(); +								setLocation(baseUrl); +							}}> +								< back +							</span> Confirm import: +						</h1> +						<ProcessImport +							list={parseResult.data} +						/> +					</> +				) : <Redirect to={baseUrl} />} +			</Route> + +			<Route> +				{!parseResult.isSuccess ? ( +					<ImportExportForm +						form={form} +						submitParse={submitParse} +						parseResult={parseResult} +					/> +				) : <Redirect to={`${baseUrl}/process`} />} +			</Route> +		</Switch> +	); +};
\ No newline at end of file diff --git a/web/source/settings/admin/federation/import-export/process.jsx b/web/source/settings/admin/federation/import-export/process.jsx new file mode 100644 index 000000000..0b2d10099 --- /dev/null +++ b/web/source/settings/admin/federation/import-export/process.jsx @@ -0,0 +1,327 @@ +/* +	GoToSocial +	Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org + +	This program is free software: you can redistribute it and/or modify +	it under the terms of the GNU Affero General Public License as published by +	the Free Software Foundation, either version 3 of the License, or +	(at your option) any later version. + +	This program is distributed in the hope that it will be useful, +	but WITHOUT ANY WARRANTY; without even the implied warranty of +	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +	GNU Affero General Public License for more details. + +	You should have received a copy of the GNU Affero General Public License +	along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +"use strict"; + +const React = require("react"); + +const query = require("../../../lib/query"); +const { isValidDomainBlock, hasBetterScope } = require("../../../lib/domain-block"); + +const { +	useTextInput, +	useBoolInput, +	useRadioInput, +	useCheckListInput +} = require("../../../lib/form"); + +const useFormSubmit = require("../../../lib/form/submit"); + +const { +	TextInput, +	TextArea, +	Checkbox, +	Select, +	RadioGroup +} = require("../../../components/form/inputs"); + +const CheckList = require("../../../components/check-list"); +const MutationButton = require("../../../components/form/mutation-button"); +const FormWithData = require("../../../lib/form/form-with-data"); + +module.exports = React.memo( +	function ProcessImport({ list }) { +		return ( +			<div className="without-border"> +				<FormWithData +					dataQuery={query.useInstanceBlocksQuery} +					DataForm={ImportList} +					list={list} +				/> +			</div> +		); +	} +); + +function ImportList({ list, data: blockedInstances }) { +	const hasComment = React.useMemo(() => { +		let hasPublic = false; +		let hasPrivate = false; + +		list.some((entry) => { +			if (entry.public_comment?.length > 0) { +				hasPublic = true; +			} + +			if (entry.private_comment?.length > 0) { +				hasPrivate = true; +			} + +			return hasPublic && hasPrivate; +		}); + +		if (hasPublic && hasPrivate) { +			return { both: true }; +		} else if (hasPublic) { +			return { type: "public_comment" }; +		} else if (hasPrivate) { +			return { type: "private_comment" }; +		} else { +			return {}; +		} +	}, [list]); + +	const showComment = useTextInput("showComment", { defaultValue: hasComment.type ?? "public_comment" }); + +	const form = { +		domains: useCheckListInput("domains", { entries: list }), +		obfuscate: useBoolInput("obfuscate"), +		privateComment: useTextInput("private_comment", { +			defaultValue: `Imported on ${new Date().toLocaleString()}` +		}), +		privateCommentBehavior: useRadioInput("private_comment_behavior", { +			defaultValue: "append", +			options: { +				append: "Append to", +				replace: "Replace" +			} +		}), +		publicComment: useTextInput("public_comment"), +		publicCommentBehavior: useRadioInput("public_comment_behavior", { +			defaultValue: "append", +			options: { +				append: "Append to", +				replace: "Replace" +			} +		}), +	}; + +	const [importDomains, importResult] = useFormSubmit(form, query.useImportDomainListMutation(), { changedOnly: false }); + +	return ( +		<> +			<form onSubmit={importDomains} className="suspend-import-list"> +				<span>{list.length} domain{list.length != 1 ? "s" : ""} in this list</span> + +				{hasComment.both && +					<Select field={showComment} options={ +						<> +							<option value="public_comment">Show public comments</option> +							<option value="private_comment">Show private comments</option> +						</> +					} /> +				} + +				<DomainCheckList +					field={form.domains} +					blockedInstances={blockedInstances} +					commentType={showComment.value} +				/> + +				<TextArea +					field={form.privateComment} +					label="Private comment" +					rows={3} +				/> +				<RadioGroup +					field={form.privateCommentBehavior} +					label="imported private comment" +				/> + +				<TextArea +					field={form.publicComment} +					label="Public comment" +					rows={3} +				/> +				<RadioGroup +					field={form.publicCommentBehavior} +					label="imported public comment" +				/> + +				<Checkbox +					field={form.obfuscate} +					label="Obfuscate domains in public lists" +				/> + +				<MutationButton label="Import" result={importResult} /> +			</form> +		</> +	); +} + +function DomainCheckList({ field, blockedInstances, commentType }) { +	const getExtraProps = React.useCallback((entry) => { +		return { +			comment: entry[commentType], +			alreadyExists: blockedInstances[entry.domain] != undefined +		}; +	}, [blockedInstances, commentType]); + +	const entriesWithSuggestions = React.useMemo(() => ( +		Object.values(field.value).filter((entry) => entry.suggest) +	), [field.value]); + +	return ( +		<> +			<CheckList +				field={field} +				header={<> +					<b>Domain</b> +					<b></b> +					<b> +						{commentType == "public_comment" && "Public comment"} +						{commentType == "private_comment" && "Private comment"} +					</b> +				</>} +				EntryComponent={DomainEntry} +				getExtraProps={getExtraProps} +			/> +			<UpdateHint +				entries={entriesWithSuggestions} +				updateEntry={field.onChange} +				updateMultiple={field.updateMultiple} +			/> +		</> +	); +} + +const UpdateHint = React.memo( +	function UpdateHint({ entries, updateEntry, updateMultiple }) { +		if (entries.length == 0) { +			return null; +		} + +		function changeAll() { +			updateMultiple( +				entries.map((entry) => [entry.key, { domain: entry.suggest, suggest: null }]) +			); +		} + +		return ( +			<div className="update-hints"> +				<p> +					{entries.length} {entries.length == 1 ? "entry uses" : "entries use"} a specific subdomain, +					which you might want to change to the main domain, as that includes all it's (future) subdomains. +				</p> +				<div className="hints"> +					{entries.map((entry) => ( +						<UpdateableEntry key={entry.key} entry={entry} updateEntry={updateEntry} /> +					))} +				</div> +				{entries.length > 0 && <a onClick={changeAll}>change all</a>} +			</div> +		); +	} +); + +const UpdateableEntry = React.memo( +	function UpdateableEntry({ entry, updateEntry }) { +		return ( +			<> +				<span className="text-cutoff">{entry.domain}</span> +				<i class="fa fa-long-arrow-right" aria-hidden="true"></i> +				<span>{entry.suggest}</span> +				<a role="button" onClick={() => +					updateEntry(entry.key, { domain: entry.suggest, suggest: null }) +				}>change</a> +			</> +		); +	} +); + +function domainValidationError(isValid) { +	return isValid ? "" : "Invalid domain"; +} + +function DomainEntry({ entry, onChange, extraProps: { alreadyExists, comment } }) { +	const domainField = useTextInput("domain", { +		defaultValue: entry.domain, +		showValidation: entry.checked, +		initValidation: domainValidationError(entry.valid), +		validator: (value) => domainValidationError(isValidDomainBlock(value)) +	}); + +	React.useEffect(() => { +		if (entry.valid != domainField.valid) { +			onChange({ valid: domainField.valid }); +		} +	}, [onChange, entry.valid, domainField.valid]); + +	React.useEffect(() => { +		if (entry.domain != domainField.value) { +			domainField.setter(entry.domain); +		} +		// domainField.setter is enough, eslint wants domainField +		// eslint-disable-next-line react-hooks/exhaustive-deps +	}, [entry.domain, domainField.setter]); + +	React.useEffect(() => { +		onChange({ suggest: hasBetterScope(domainField.value) }); +		// only need this update if it's the entry.checked that updated, not onChange +		// eslint-disable-next-line react-hooks/exhaustive-deps +	}, [domainField.value]); + +	function clickIcon(e) { +		if (entry.suggest) { +			e.stopPropagation(); +			e.preventDefault(); +			domainField.setter(entry.suggest); +			onChange({ domain: entry.suggest, checked: true }); +		} +	} + +	return ( +		<> +			<TextInput +				field={domainField} +				onChange={(e) => { +					domainField.onChange(e); +					onChange({ domain: e.target.value, checked: true }); +				}} +			/> +			<span id="icon" onClick={clickIcon}> +				<DomainEntryIcon alreadyExists={alreadyExists} suggestion={entry.suggest} onChange={onChange} /> +			</span> +			<p>{comment}</p> +		</> +	); +} + +function DomainEntryIcon({ alreadyExists, suggestion }) { +	let icon; +	let text; + +	if (suggestion) { +		icon = "fa-info-circle suggest-changes"; +		text = `Entry targets a specific subdomain, consider changing it to '${suggestion}'.`; +	} else if (alreadyExists) { +		icon = "fa-history already-blocked"; +		text = "Domain block already exists."; +	} + +	if (!icon) { +		return null; +	} + +	return ( +		<> +			<i className={`fa ${icon}`} aria-hidden="true" title={text}></i> +			<span className="sr-only">{text}</span> +		</> +	); +}
\ No newline at end of file | 
