refactor federation import-export interface

This commit is contained in:
f0x 2023-01-13 23:18:27 +00:00
parent c91c218a6e
commit 5a96f23ff7
21 changed files with 610 additions and 214 deletions

View file

@ -49,6 +49,8 @@ $error2: #ff9796; /* Error background text, can be used with $error1 (5.0), $gra
$error3: #dd2c2c; /* Error button background text, can be used with $white1 (4.51) */
$error-link: #01318C; /* Error link text, can be used with $error2 (5.56) */
$green1: #94E749; /* Green for positive/confirmation, similar contrast (luminance) as $blue2 */
$info-fg: $gray1;
$info-bg: #b3ddff;
$info-link: $error-link;

View file

@ -45,7 +45,7 @@ module.exports = function EmojiDetailRoute() {
return (
<div className="emoji-detail">
<Link to={base}><a>&lt; go back</a></Link>
<FormWithData dataQuery={query.useGetEmojiQuery} arg={params.emojiId} DataForm={EmojiDetailForm} />
<FormWithData dataQuery={query.useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} />
</div>
);
}
@ -91,7 +91,7 @@ function EmojiDetailForm({ data: emoji }) {
label="Delete"
type="button"
onClick={() => deleteEmoji(emoji.id)}
className="danger button-inline"
className="danger"
showError={false}
result={deleteResult}
/>
@ -126,7 +126,6 @@ function EmojiDetailForm({ data: emoji }) {
name="image"
label="Replace image"
showError={false}
className="button-inline"
result={result}
/>

View file

@ -28,13 +28,11 @@ const base = "/settings/custom-emoji/local";
module.exports = function CustomEmoji() {
return (
<>
<Switch>
<Route path={`${base}/:emojiId`}>
<EmojiDetail baseUrl={base} />
</Route>
<EmojiOverview baseUrl={base} />
</Switch>
</>
<Switch>
<Route path={`${base}/:emojiId`}>
<EmojiDetail baseUrl={base} />
</Route>
<EmojiOverview baseUrl={base} />
</Switch>
);
};

View file

@ -65,7 +65,7 @@ module.exports = function ParseFromToot({ emojiCodes }) {
onChange={onURLChange}
value={url}
/>
<button className="button-inline" disabled={result.isLoading}>
<button disabled={result.isLoading}>
<i className={[
"fa fa-fw",
(result.isLoading
@ -121,7 +121,6 @@ function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
};
const [formSubmit, result] = useFormSubmit(form, query.usePatchRemoteEmojisMutation(), { changedOnly: false });
console.log("action:", result.action);
const buttonsInactive = form.selectedEmoji.someSelected
? {}
@ -130,41 +129,6 @@ function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
title: "No emoji selected, cannot perform any actions"
};
// function submit(action) {
// Promise.try(() => {
// setError(null);
// const selectedShortcodes = emojiCheckList.selectedValues.map(([shortcode, entry]) => {
// if (action == "copy" && !entry.valid) {
// throw `One or more selected emoji have non-unique shortcodes (${shortcode}), unselect them or pick a different local shortcode`;
// }
// return {
// shortcode,
// localShortcode: entry.shortcode
// };
// });
// return patchRemoteEmojis({
// action,
// domain,
// list: selectedShortcodes,
// category
// }).unwrap();
// }).then(() => {
// emojiCheckList.reset();
// resetCategory();
// }).catch((e) => {
// if (Array.isArray(e)) {
// setError(e.map(([shortcode, msg]) => (
// <div key={shortcode}>
// {shortcode}: <span style={{ fontWeight: "initial" }}>{msg}</span>
// </div>
// )));
// } else {
// setError(e);
// }
// });
// }
return (
<div className="parsed">
<span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>

View file

@ -34,7 +34,7 @@ const BackButton = require("../../components/back-button");
const MutationButton = require("../../components/form/mutation-button");
module.exports = function InstanceDetail({ baseUrl }) {
const { data: blockedInstances = [], isLoading } = query.useInstanceBlocksQuery();
const { data: blockedInstances = {}, isLoading } = query.useInstanceBlocksQuery();
let [_match, { domain }] = useRoute(`${baseUrl}/:domain`);
@ -43,7 +43,7 @@ module.exports = function InstanceDetail({ baseUrl }) {
}
const existingBlock = React.useMemo(() => {
return blockedInstances.find((block) => block.domain == domain);
return blockedInstances[domain];
}, [blockedInstances, domain]);
if (domain == undefined) {

View file

@ -19,14 +19,15 @@
"use strict";
const React = require("react");
const { Switch, Route, Redirect, useLocation } = require("wouter");
const query = require("../../lib/query");
const processDomainList = require("../../lib/import-export");
const {
useTextInput,
useBoolInput,
useFileInput
useRadioInput,
useCheckListInput
} = require("../../lib/form");
const useFormSubmit = require("../../lib/form/submit");
@ -35,93 +36,263 @@ const {
TextInput,
TextArea,
Checkbox,
FileInput,
useCheckListInput
Select,
RadioGroup
} = require("../../components/form/inputs");
const FormWithData = require("../../lib/form/form-with-data");
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 [parsedList, setParsedList] = React.useState();
const [updateFromFile, setUpdateFromFile] = React.useState(false);
const form = {
domains: useTextInput("domains"),
obfuscate: useBoolInput("obfuscate"),
commentPrivate: useTextInput("private_comment"),
commentPublic: useTextInput("public_comment"),
// json: useFileInput("json")
exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true })
};
function submitImport(e) {
e.preventDefault();
const [submitParse, parseResult] = useFormSubmit(form, query.useProcessDomainListMutation());
const [submitExport, exportResult] = useFormSubmit(form, query.useExportDomainListMutation());
Promise.try(() => {
return processDomainList(form.domains.value);
}).then((processed) => {
setParsedList(processed);
}).catch((e) => {
console.error(e);
});
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]);
}
const [_location, setLocation] = useLocation();
if (updateFromFile) {
setUpdateFromFile(false);
submitParse();
}
return (
<div className="import-export">
<h2>Import / Export</h2>
<div>
{
parsedList
? <ImportExportList list={parsedList} />
: <ImportExportForm form={form} submitImport={submitImport} />
}
</div>
</div>
<Switch>
<Route path={`${baseUrl}/list`}>
{!parseResult.isSuccess && <Redirect to={baseUrl} />}
<h1>
<span className="button" onClick={() => {
parseResult.reset();
setLocation(baseUrl);
}}>
&lt; 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>
<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">
<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>
<div className="row">
<MutationButton form="export-form" name="export" label="Export" result={exportResult} showError={false} />
<MutationButton form="export-form" 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>
</div>
{parseResult.error && <Error error={parseResult.error} />}
</form>
<form id="export-form" onSubmit={submitExport} />
</Route>
</Switch>
);
};
function ImportExportList({ list }) {
const entryCheckList = useCheckListInput("selectedDomains", {
entries: list,
uniqueKey: "domain"
});
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 (
<CheckList
/>
<>
<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 ImportExportForm({ form, submitImport }) {
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 (
<form onSubmit={submitImport}>
<TextArea
field={form.domains}
label="Domains, one per line (plaintext) or JSON"
placeholder={`google.com\nfacebook.com`}
rows={8}
<>
<TextInput
field={domainField}
onChange={(e) => {
domainField.onChange(e);
onChange({ domain: e.target.value, checked: true });
}}
/>
<TextArea
field={form.commentPrivate}
label="Private comment"
rows={3}
/>
<TextArea
field={form.commentPublic}
label="Public comment"
rows={3}
/>
<Checkbox
field={form.obfuscate}
label="Obfuscate domain in public lists"
/>
<div>
<MutationButton label="Import" result={importResult} /> {/* default form action */}
</div>
</form>
<span id="icon">{icon}</span>
<p>{entry[commentType]}</p>
</>
);
}

View file

@ -30,7 +30,7 @@ const InstanceImportExport = require("./import-export");
module.exports = function Federation({ }) {
return (
<Switch>
<Route path={`${baseUrl}/import-export`}>
<Route path={`${baseUrl}/import-export/:list?`}>
<InstanceImportExport />
</Route>

View file

@ -29,7 +29,6 @@ const { TextInput } = require("../../components/form/inputs");
const query = require("../../lib/query");
const Loading = require("../../components/loading");
const ImportExport = require("./import-export");
module.exports = function InstanceOverview({ baseUrl }) {
const { data: blockedInstances = [], isLoading } = query.useInstanceBlocksQuery();
@ -39,11 +38,15 @@ module.exports = function InstanceOverview({ baseUrl }) {
const filterField = useTextInput("filter");
const filter = filterField.value;
const filteredInstances = React.useMemo(() => {
return matchSorter(Object.values(blockedInstances), filter, { keys: ["domain"] });
}, [blockedInstances, filter]);
const blockedInstancesList = React.useMemo(() => {
return Object.values(blockedInstances);
}, [blockedInstances]);
let filtered = blockedInstances.length - filteredInstances.length;
const filteredInstances = React.useMemo(() => {
return matchSorter(blockedInstancesList, filter, { keys: ["domain"] });
}, [blockedInstancesList, filter]);
let filtered = blockedInstancesList.length - filteredInstances.length;
function filterFormSubmit(e) {
e.preventDefault();
@ -70,7 +73,7 @@ module.exports = function InstanceOverview({ baseUrl }) {
</form>
<div>
<span>
{blockedInstances.length} blocked instance{blockedInstances.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered)`}
{blockedInstancesList.length} blocked instance{blockedInstancesList.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered by search)`}
</span>
<div className="list scrolling">
{filteredInstances.map((entry) => {

View file

@ -20,7 +20,7 @@
const React = require("react");
module.exports = function CheckList({ field, Component, ...componentProps }) {
module.exports = function CheckList({ field, Component, header = " All", ...componentProps }) {
return (
<div className="checkbox-list list">
<label className="header">
@ -29,7 +29,7 @@ module.exports = function CheckList({ field, Component, ...componentProps }) {
type="checkbox"
onChange={field.toggleAll.onChange}
checked={field.toggleAll.value === 1}
/> All
/> {header}
</label>
{Object.values(field.value).map((entry) => (
<CheckListEntry

View file

@ -110,10 +110,32 @@ function Select({ label, field, options, ...inputProps }) {
);
}
function RadioGroup({ field, label, ...inputProps }) {
return (
<div className="form-field radio">
{Object.entries(field.options).map(([value, radioLabel]) => (
<label key={value}>
<input
type="radio"
name={field.name}
value={value}
checked={field.value == value}
onChange={field.onChange}
{...inputProps}
/>
{radioLabel}
</label>
))}
{label}
</div>
);
}
module.exports = {
TextInput,
TextArea,
FileInput,
Checkbox,
Select
Select,
RadioGroup
};

View file

@ -6,8 +6,8 @@ const Loading = require("../../components/loading");
// Wrap Form component inside component that fires the RTK Query call,
// so Form will only be rendered when data is available to generate form-fields for
module.exports = function FormWithData({ dataQuery, DataForm, arg }) {
const { data, isLoading } = dataQuery(arg);
module.exports = function FormWithData({ dataQuery, DataForm, queryArg, ...formProps }) {
const { data, isLoading } = dataQuery(queryArg);
if (isLoading) {
return (
@ -16,6 +16,6 @@ module.exports = function FormWithData({ dataQuery, DataForm, arg }) {
</div>
);
} else {
return <DataForm data={data} />;
return <DataForm data={data} {...formProps} />;
}
};

View file

@ -33,6 +33,7 @@ module.exports = {
useTextInput: makeHook(require("./text")),
useFileInput: makeHook(require("./file")),
useBoolInput: makeHook(require("./bool")),
useRadioInput: makeHook(require("./radio")),
useComboBoxInput: makeHook(require("./combo-box")),
useCheckListInput: makeHook(require("./check-list")),
useValue: function (name, value) {

View file

@ -18,7 +18,34 @@
"use strict";
module.exports = (build) => ({
// importInstanceBlocks: build.mutation({
// })
});
const React = require("react");
module.exports = function useRadioInput({ name, Name }, { defaultValue, options } = {}) {
const [value, setValue] = React.useState(defaultValue);
function onChange(e) {
setValue(e.target.value);
}
function reset() {
setValue(defaultValue);
}
// Array / Object hybrid, for easier access in different contexts
return Object.assign([
onChange,
reset,
{
[name]: value,
[`set${Name}`]: setValue
}
], {
name,
onChange,
reset,
value,
setter: setValue,
options,
hasChanged: () => value != defaultValue
});
};

View file

@ -33,6 +33,10 @@ module.exports = function useFormSubmit(form, [mutationQuery, result], { changed
} else {
action = e;
}
if (action == "") {
action = undefined;
}
setUsedAction(action);
// transform the field definitions into an object with just their values
let updatedFields = [];
@ -42,6 +46,7 @@ module.exports = function useFormSubmit(form, [mutationQuery, result], { changed
if (field.selectedValues != undefined) {
let selected = field.selectedValues();
if (!changedOnly || selected.length > 0) {
updatedFields.push(field);
return [field.name, selected];
}
} else if (!changedOnly || field.hasChanged()) {

View file

@ -20,7 +20,7 @@
const React = require("react");
module.exports = function useTextInput({name, Name}, {validator, defaultValue=""} = {}) {
module.exports = function useTextInput({ name, Name }, { validator, defaultValue = "", dontReset = false } = {}) {
const [text, setText] = React.useState(defaultValue);
const [valid, setValid] = React.useState(true);
const textRef = React.useRef(null);
@ -31,7 +31,9 @@ module.exports = function useTextInput({name, Name}, {validator, defaultValue=""
}
function reset() {
setText("");
if (!dontReset) {
setText(defaultValue);
}
}
React.useEffect(() => {

View file

@ -64,7 +64,7 @@ module.exports = function getViews(struct) {
}
panelRouterEl.push((
<Route path={`${url}/:page?`} key={url}>
<Route path={`${url}/:page*`} key={url}>
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => { }}>
{/* FIXME: implement onReset */}
<ViewComponent />

View file

@ -1,65 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2022 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 Promise = require("bluebird");
const isValidDomain = require("is-valid-domain");
function parseDomainList(list) {
if (list[0] == "[") {
return JSON.parse(list);
} else {
return list.split("\n").map((line) => {
let trimmed = line.trim();
return trimmed.length > 0
? { domain: trimmed }
: null;
}).filter((a) => a); // not `null`
}
}
function validateDomainList(list) {
list.forEach((entry) => {
entry.valid = isValidDomain(entry.domain, { wildcard: true, allowUnicode: true });
});
return list;
}
function deduplicateDomainList(list) {
let domains = new Set();
return list.filter((entry) => {
if (domains.has(entry.domain)) {
return false;
} else {
domains.add(entry.domain);
return true;
}
});
}
module.exports = function processDomainList(data) {
return Promise.try(() => {
return parseDomainList(data);
}).then((parsed) => {
return deduplicateDomainList(parsed);
}).then((deduped) => {
return validateDomainList(deduped);
});
};

View file

@ -0,0 +1,212 @@
/*
GoToSocial
Copyright (C) 2021-2022 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 Promise = require("bluebird");
const isValidDomain = require("is-valid-domain");
const fileDownload = require("js-file-download");
const {
replaceCacheOnMutation,
domainListToObject,
unwrapRes
} = require("../lib");
function parseDomainList(list) {
if (list[0] == "[") {
return JSON.parse(list);
} else {
return list.split("\n").map((line) => {
let domain = line.trim();
let valid = true;
if (domain.startsWith("http")) {
try {
domain = new URL(domain).hostname;
} catch (e) {
valid = false;
}
}
return domain.length > 0
? { domain, valid }
: null;
}).filter((a) => a); // not `null`
}
}
function validateDomainList(list) {
list.forEach((entry) => {
entry.valid = (entry.valid !== false) && isValidDomain(entry.domain, { wildcard: true, allowUnicode: true });
entry.checked = entry.valid;
});
return list;
}
function deduplicateDomainList(list) {
let domains = new Set();
return list.filter((entry) => {
if (domains.has(entry.domain)) {
return false;
} else {
domains.add(entry.domain);
return true;
}
});
}
module.exports = (build) => ({
processDomainList: build.mutation({
queryFn: (formData) => {
return Promise.try(() => {
if (formData.domains == undefined || formData.domains.length == 0) {
throw "No domains entered";
}
return parseDomainList(formData.domains);
}).then((parsed) => {
return deduplicateDomainList(parsed);
}).then((deduped) => {
return validateDomainList(deduped);
}).then((data) => {
return { data };
}).catch((e) => {
return { error: e.toString() };
});
}
}),
exportDomainList: build.mutation({
queryFn: (formData, api, _extraOpts, baseQuery) => {
return Promise.try(() => {
return baseQuery({
url: `/api/v1/admin/domain_blocks`
});
}).then(unwrapRes).then((blockedInstances) => {
return blockedInstances.map((entry) => {
if (formData.exportType == "json") {
return {
domain: entry.domain,
public_comment: entry.public_comment
};
} else {
return entry.domain;
}
});
}).then((exportList) => {
if (formData.exportType == "json") {
return JSON.stringify(exportList);
} else {
return exportList.join("\n");
}
}).then((exportAsString) => {
if (formData.action == "export") {
return {
data: exportAsString
};
} else if (formData.action == "export-file") {
let domain = new URL(api.getState().oauth.instance).host;
let date = new Date();
let mime;
let filename = [
domain,
"blocklist",
date.getFullYear(),
(date.getMonth() + 1).toString().padStart(2, "0"),
date.getDate().toString().padStart(2, "0"),
].join("-");
if (formData.exportType == "json") {
filename += ".json";
mime = "application/json";
} else {
filename += ".txt";
mime = "text/plain";
}
fileDownload(exportAsString, filename, mime);
}
return { data: null };
}).catch((e) => {
return { error: e };
});
}
}),
importDomainList: build.mutation({
query: (formData) => {
const { domains } = formData;
// add/replace comments, obfuscation data
let process = entryProcessor(formData);
domains.forEach((entry) => {
process(entry);
});
return {
method: "POST",
url: `/api/v1/admin/domain_blocks?import=true`,
asForm: true,
discardEmpty: true,
body: {
domains: new Blob([JSON.stringify(domains)], { type: "application/json" })
}
};
},
transformResponse: domainListToObject,
...replaceCacheOnMutation("instanceBlocks")
})
});
function entryProcessor(formData) {
let funcs = [];
["private_comment", "public_comment"].forEach((type) => {
let text = formData[type].trim();
if (text.length > 0) {
let behavior = formData[`${type}_behavior`];
if (behavior == "append") {
funcs.push(function appendComment(entry) {
if (entry[type] == undefined) {
entry[type] = text;
} else {
entry[type] = [entry[type], text].join("\n");
}
});
} else if (behavior == "replace") {
funcs.push(function replaceComment(entry) {
entry[type] = text;
});
}
}
});
return function process(entry) {
funcs.forEach((func) => {
func(entry);
});
entry.obfuscate = formData.obfuscate;
Object.entries(entry).forEach(([key, val]) => {
if (val == undefined) {
delete entry[key];
}
});
};
}

View file

@ -21,7 +21,8 @@
const {
replaceCacheOnMutation,
appendCacheOnMutation,
spliceCacheOnMutation
removeFromCacheOnMutation,
domainListToObject
} = require("../lib");
const base = require("../base");
@ -48,7 +49,8 @@ const endpoints = (build) => ({
instanceBlocks: build.query({
query: () => ({
url: `/api/v1/admin/domain_blocks`
})
}),
transformResponse: domainListToObject
}),
addInstanceBlock: build.mutation({
query: (formData) => ({
@ -65,13 +67,13 @@ const endpoints = (build) => ({
method: "DELETE",
url: `/api/v1/admin/domain_blocks/${id}`,
}),
...spliceCacheOnMutation("instanceBlocks", {
findKey: (draft, newData) => {
return draft.findIndex((block) => block.id == newData.id);
...removeFromCacheOnMutation("instanceBlocks", {
findKey: (_draft, newData) => {
return newData.domain;
}
})
}),
...require("./federation-bulk")(build)
...require("./import-export")(build)
});
module.exports = base.injectEndpoints({ endpoints });

View file

@ -18,6 +18,7 @@
"use strict";
const syncpipe = require("syncpipe");
const base = require("./base");
module.exports = {
@ -28,26 +29,36 @@ module.exports = {
return res.data;
}
},
domainListToObject: (data) => {
// Turn flat Array into Object keyed by block's domain
return syncpipe(data, [
(_) => _.map((entry) => [entry.domain, entry]),
(_) => Object.fromEntries(_)
]);
},
replaceCacheOnMutation: makeCacheMutation((draft, newData) => {
Object.assign(draft, newData);
}),
appendCacheOnMutation: makeCacheMutation((draft, newData) => {
draft.push(newData);
}),
spliceCacheOnMutation: makeCacheMutation((draft, newData, key) => {
spliceCacheOnMutation: makeCacheMutation((draft, newData, { key }) => {
draft.splice(key, 1);
}),
updateCacheOnMutation: makeCacheMutation((draft, newData, key) => {
updateCacheOnMutation: makeCacheMutation((draft, newData, { key }) => {
draft[key] = newData;
}),
removeFromCacheOnMutation: makeCacheMutation((draft, newData, key) => {
removeFromCacheOnMutation: makeCacheMutation((draft, newData, { key }) => {
delete draft[key];
}),
editCacheOnMutation: makeCacheMutation((draft, newData, { update }) => {
update(draft, newData);
})
};
// https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates#pessimistic-updates
function makeCacheMutation(action) {
return function cacheMutation(queryName, { key, findKey, arg } = {}) {
return function cacheMutation(queryName, { key, findKey, arg, ...opts } = {}) {
return {
onQueryStarted: (_, { dispatch, queryFulfilled }) => {
queryFulfilled.then(({ data: newData }) => {
@ -55,7 +66,7 @@ function makeCacheMutation(action) {
if (findKey != undefined) {
key = findKey(draft, newData);
}
action(draft, newData, key);
action(draft, newData, { key, ...opts });
}));
});
}

View file

@ -220,14 +220,15 @@ section.with-sidebar > div, section.with-sidebar > form {
flex-direction: column;
gap: 1rem;
input, textarea, button {
input, textarea {
width: 100%;
line-height: 1.5rem;
}
.button-inline {
button {
width: auto;
align-self: flex-start;
line-height: 1.5rem;
}
input[type=checkbox] {
@ -702,6 +703,10 @@ button.with-icon {
}
}
button.with-padding {
padding: 0.5rem calc(0.5rem + $fa-fw);
}
.loading-icon {
align-self: flex-start;
}
@ -713,6 +718,43 @@ button.with-icon {
animation-fill-mode: forwards;
}
.suspend-import-list {
.checkbox-list {
.header, .entry {
display: grid;
grid-template-columns: auto 25ch auto 1fr;
}
}
.entry {
#icon {
margin-left: -0.5rem;
align-self: center;
}
#icon .already-blocked {
color: $green1;
}
p {
align-self: center;
margin: 0;
}
}
}
.form-field.radio {
&, label {
display: flex;
gap: 0.5rem;
}
input {
width: auto;
place-self: center;
}
}
@keyframes fadeout {
from {
opacity: 1;