[feature] Allow import/export/creation of domain allows via admin panel (#2264)

* it's happening!

* aaa

* fix silly whoopsie

* it's working pa! it's working ma!

* model report parameters

* shuffle some more stuff around

* getting there

* oo hoo

* finish tidying up for now

* aaa

* fix use form submit errors

* peepee poo poo

* aaaaa

* ffff

* they see me typin', they hatin'

* boop

* aaa

* oooo

* typing typing tappa tappa

* almost done typing

* weee

* alright

* push it push it real good doo doo doo doo doo doo

* thingy no worky

* almost done

* mutation modifers not quite right

* hmm

* it works

* view blocks + allows nicely

* it works!

* typia install

* the old linterino

* linter plz
This commit is contained in:
tobi
2023-10-17 12:46:06 +02:00
committed by GitHub
parent 48725f7228
commit 637f188ebe
77 changed files with 4154 additions and 1690 deletions

View File

@@ -17,11 +17,19 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
import { useState } from "react";
import type {
BoolFormInputHook,
CreateHookNames,
HookOpts,
} from "./types";
const _default = false;
module.exports = function useBoolInput({ name, Name }, { initialValue = _default }) {
const [value, setValue] = React.useState(initialValue);
export default function useBoolInput(
{ name, Name }: CreateHookNames,
{ initialValue = _default }: HookOpts<boolean>
): BoolFormInputHook {
const [value, setValue] = useState(initialValue);
function onChange(e) {
setValue(e.target.checked);
@@ -41,6 +49,7 @@ module.exports = function useBoolInput({ name, Name }, { initialValue = _default
}
], {
name,
Name: "",
onChange,
reset,
value,
@@ -48,4 +57,4 @@ module.exports = function useBoolInput({ name, Name }, { initialValue = _default
hasChanged: () => value != initialValue,
_default
});
};
}

View File

@@ -17,37 +17,58 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
const syncpipe = require("syncpipe");
const { createSlice } = require("@reduxjs/toolkit");
const { enableMapSet } = require("immer");
import {
useReducer,
useRef,
useEffect,
useCallback,
useMemo,
} from "react";
enableMapSet(); // for use in reducers
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import type {
Checkable,
ChecklistInputHook,
CreateHookNames,
HookOpts,
} from "./types";
// https://immerjs.github.io/immer/installation#pick-your-immer-version
import { enableMapSet } from "immer";
enableMapSet();
interface ChecklistState {
entries: { [k: string]: Checkable },
selectedEntries: Set<string>,
}
const initialState: ChecklistState = {
entries: {},
selectedEntries: new Set(),
};
const { reducer, actions } = createSlice({
name: "checklist",
initialState: {}, // not handled by slice itself
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
};
updateAll: (state, { payload: checked }: PayloadAction<boolean>) => {
const selectedEntries = new Set<string>();
const entries = Object.fromEntries(
Object.values(state.entries).map((entry) => {
if (checked) {
// Cheekily add this to selected
// entries while we're here.
selectedEntries.add(entry.key);
}
return [entry.key, { ...entry, checked } ];
})
);
return { entries, selectedEntries };
},
update: (state, { payload: { key, value } }) => {
update: (state, { payload: { key, value } }: PayloadAction<{key: string, value: Checkable}>) => {
if (value.checked !== undefined) {
if (value.checked === true) {
state.selectedEntries.add(key);
@@ -61,7 +82,7 @@ const { reducer, actions } = createSlice({
...value
};
},
updateMultiple: (state, { payload }) => {
updateMultiple: (state, { payload }: PayloadAction<Array<[key: string, value: Checkable]>>) => {
payload.forEach(([key, value]) => {
if (value.checked !== undefined) {
if (value.checked === true) {
@@ -80,43 +101,57 @@ const { reducer, actions } = createSlice({
}
});
function initialState({ entries, uniqueKey, initialValue }) {
const selectedEntries = new Set();
function initialHookState({
entries,
uniqueKey,
initialValue,
}: {
entries: Checkable[],
uniqueKey: string,
initialValue: boolean,
}): ChecklistState {
const selectedEntries = new Set<string>();
const mappedEntries = Object.fromEntries(
entries.map((entry) => {
const key = entry[uniqueKey];
const checked = entry.checked ?? initialValue;
if (checked) {
selectedEntries.add(key);
} else {
selectedEntries.delete(key);
}
return [ key, { ...entry, key, checked } ];
})
);
return {
entries: syncpipe(entries, [
(_) => _.map((entry) => {
let key = entry[uniqueKey];
let checked = entry.checked ?? initialValue;
if (checked) {
selectedEntries.add(key);
} else {
selectedEntries.delete(key);
}
return [
key,
{
...entry,
key,
checked
}
];
}),
(_) => Object.fromEntries(_)
]),
entries: mappedEntries,
selectedEntries
};
}
module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "key", initialValue = false }) {
const [state, dispatch] = React.useReducer(reducer, null,
() => initialState({ entries, uniqueKey, initialValue }) // initial state
const _default: { [k: string]: Checkable } = {};
export default function useCheckListInput(
/* eslint-disable no-unused-vars */
{ name, Name }: CreateHookNames,
{
entries = [],
uniqueKey = "key",
initialValue = false,
}: HookOpts<boolean>
): ChecklistInputHook {
const [state, dispatch] = useReducer(
reducer,
initialState,
(_) => initialHookState({ entries, uniqueKey, initialValue }) // initial state
);
const toggleAllRef = React.useRef(null);
const toggleAllRef = useRef<any>(null);
React.useEffect(() => {
useEffect(() => {
if (toggleAllRef.current != null) {
let some = state.selectedEntries.size > 0;
let all = false;
@@ -130,22 +165,22 @@ module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "ke
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.selectedEntries]);
const reset = React.useCallback(
const reset = useCallback(
() => dispatch(actions.updateAll(initialValue)),
[initialValue]
);
const onChange = React.useCallback(
const onChange = useCallback(
(key, value) => dispatch(actions.update({ key, value })),
[]
);
const updateMultiple = React.useCallback(
const updateMultiple = useCallback(
(entries) => dispatch(actions.updateMultiple(entries)),
[]
);
return React.useMemo(() => {
return useMemo(() => {
function toggleAll(e) {
let checked = e.target.checked;
if (e.target.indeterminate) {
@@ -165,7 +200,10 @@ module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "ke
reset,
{ name }
], {
_default,
hasChanged: () => true,
name,
Name: "",
value: state.entries,
onChange,
selectedValues,
@@ -178,4 +216,4 @@ module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "ke
}
});
}, [state, reset, name, onChange, updateMultiple]);
};
}

View File

@@ -17,13 +17,21 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
import { useState } from "react";
const { useComboboxState } = require("ariakit/combobox");
import { useComboboxState } from "ariakit/combobox";
import {
ComboboxFormInputHook,
CreateHookNames,
HookOpts,
} from "./types";
const _default = "";
module.exports = function useComboBoxInput({ name, Name }, { initialValue = _default }) {
const [isNew, setIsNew] = React.useState(false);
export default function useComboBoxInput(
{ name, Name }: CreateHookNames,
{ initialValue = _default }: HookOpts<string>
): ComboboxFormInputHook {
const [isNew, setIsNew] = useState(false);
const state = useComboboxState({
defaultValue: initialValue,
@@ -45,14 +53,15 @@ module.exports = function useComboBoxInput({ name, Name }, { initialValue = _def
[`set${Name}IsNew`]: setIsNew
}
], {
reset,
name,
Name: "", // Will be set by inputHook function.
state,
value: state.value,
setter: (val) => state.setValue(val),
setter: (val: string) => state.setValue(val),
hasChanged: () => state.value != initialValue,
isNew,
setIsNew,
reset,
_default
});
};
}

View File

@@ -17,12 +17,19 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
import { useRef, useMemo } from "react";
const getFormMutations = require("./get-form-mutations");
import getFormMutations from "./get-form-mutations";
function parseFields(entries, length) {
const fields = [];
import type {
CreateHookNames,
HookOpts,
FieldArrayInputHook,
HookedForm,
} from "./types";
function parseFields(entries: HookedForm[], length: number): HookedForm[] {
const fields: HookedForm[] = [];
for (let i = 0; i < length; i++) {
if (entries[i] != undefined) {
@@ -35,23 +42,38 @@ function parseFields(entries, length) {
return fields;
}
module.exports = function useArrayInput({ name, _Name }, { initialValue, length = 0 }) {
const fields = React.useRef({});
export default function useArrayInput(
{ name }: CreateHookNames,
{
initialValue,
length = 0,
}: HookOpts,
): FieldArrayInputHook {
const _default: HookedForm[] = Array(length);
const fields = useRef<HookedForm[]>(_default);
const value = React.useMemo(() => parseFields(initialValue, length), [initialValue, length]);
const value = useMemo(
() => parseFields(initialValue, length),
[initialValue, length],
);
function hasUpdate() {
return Object.values(fields.current).some((fieldSet) => {
const { updatedFields } = getFormMutations(fieldSet, { changedOnly: true });
return updatedFields.length > 0;
});
}
return {
_default,
name,
Name: "",
value,
ctx: fields.current,
maxLength: length,
hasChanged: hasUpdate,
selectedValues() {
// if any form field changed, we need to re-send everything
const hasUpdate = Object.values(fields.current).some((fieldSet) => {
const { updatedFields } = getFormMutations(fieldSet, { changedOnly: true });
return updatedFields.length > 0;
});
if (hasUpdate) {
if (hasUpdate()) {
return Object.values(fields.current).map((fieldSet) => {
return getFormMutations(fieldSet, { changedOnly: false }).mutationData;
});
@@ -60,4 +82,4 @@ module.exports = function useArrayInput({ name, _Name }, { initialValue, length
}
}
};
};
}

View File

@@ -17,47 +17,67 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
const prettierBytes = require("prettier-bytes");
import React from "react";
module.exports = function useFileInput({ name, _Name }, {
withPreview,
maxSize,
initialInfo = "no file selected"
} = {}) {
const [file, setFile] = React.useState();
const [imageURL, setImageURL] = React.useState();
const [info, setInfo] = React.useState();
import { useState } from "react";
import prettierBytes from "prettier-bytes";
function onChange(e) {
let file = e.target.files[0];
import type {
CreateHookNames,
HookOpts,
FileFormInputHook,
} from "./types";
const _default = undefined;
export default function useFileInput(
{ name }: CreateHookNames,
{
withPreview,
maxSize,
initialInfo = "no file selected"
}: HookOpts<File>
): FileFormInputHook {
const [file, setFile] = useState<File>();
const [imageURL, setImageURL] = useState<string>();
const [info, setInfo] = useState<React.JSX.Element>();
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const files = e.target.files;
if (!files) {
setInfo(undefined);
return;
}
let file = files[0];
setFile(file);
URL.revokeObjectURL(imageURL);
if (file != undefined) {
if (withPreview) {
setImageURL(URL.createObjectURL(file));
}
let size = prettierBytes(file.size);
if (maxSize && file.size > maxSize) {
size = <span className="error-text">{size}</span>;
}
setInfo(<>
{file.name} ({size})
</>);
} else {
setInfo();
if (imageURL) {
URL.revokeObjectURL(imageURL);
}
if (withPreview) {
setImageURL(URL.createObjectURL(file));
}
let size = prettierBytes(file.size);
if (maxSize && file.size > maxSize) {
size = <span className="error-text">{size}</span>;
}
setInfo(
<>
{file.name} ({size})
</>
);
}
function reset() {
URL.revokeObjectURL(imageURL);
setImageURL();
setFile();
setInfo();
if (imageURL) {
URL.revokeObjectURL(imageURL);
}
setImageURL(undefined);
setFile(undefined);
setInfo(undefined);
}
const infoComponent = (
@@ -82,9 +102,11 @@ module.exports = function useFileInput({ name, _Name }, {
onChange,
reset,
name,
Name: "", // Will be set by inputHook function.
value: file,
previewValue: imageURL,
hasChanged: () => file != undefined,
infoComponent
infoComponent,
_default,
});
};
}

View File

@@ -17,14 +17,31 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
const { Error } = require("../../components/error");
/* eslint-disable no-unused-vars */
const Loading = require("../../components/loading");
import React from "react";
// 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, queryArg, ...formProps }) {
import { Error } from "../../components/error";
import Loading from "../../components/loading";
import { NoArg } from "../types/query";
import { FormWithDataQuery } from "./types";
export interface FormWithDataProps {
dataQuery: FormWithDataQuery,
DataForm: ({ data, ...props }) => React.JSX.Element,
queryArg?: any,
}
/**
* 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.
*/
export default function FormWithData({ dataQuery, DataForm, queryArg, ...props }: FormWithDataProps) {
if (!queryArg) {
queryArg = NoArg;
}
// Trigger provided query.
const { data, isLoading, isError, error } = dataQuery(queryArg);
if (isLoading) {
@@ -38,6 +55,6 @@ module.exports = function FormWithData({ dataQuery, DataForm, queryArg, ...formP
<Error error={error} />
);
} else {
return <DataForm data={data} {...formProps} />;
return <DataForm data={data} {...props} />;
}
};
}

View File

@@ -17,29 +17,31 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const syncpipe = require("syncpipe");
import { FormInputHook, HookedForm } from "./types";
export default function getFormMutations(
form: HookedForm,
{ changedOnly }: { changedOnly: boolean },
) {
const updatedFields: FormInputHook[] = [];
const mutationData: Array<[string, any]> = [];
Object.values(form).forEach((field) => {
if ("selectedValues" in field) {
// FieldArrayInputHook.
const selected = field.selectedValues();
if (!changedOnly || selected.length > 0) {
updatedFields.push(field);
mutationData.push([field.name, selected]);
}
} else if (!changedOnly || field.hasChanged()) {
updatedFields.push(field);
mutationData.push([field.name, field.value]);
}
});
module.exports = function getFormMutations(form, { changedOnly }) {
let updatedFields = [];
return {
updatedFields,
mutationData: syncpipe(form, [
(_) => Object.values(_),
(_) => _.map((field) => {
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()) {
updatedFields.push(field);
return [field.name, field.value];
}
return null;
}),
(_) => _.filter((value) => value != null),
(_) => Object.fromEntries(_)
])
mutationData: Object.fromEntries(mutationData),
};
};
}

View File

@@ -1,83 +0,0 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
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/>.
*/
const React = require("react");
const getByDot = require("get-by-dot").default;
function capitalizeFirst(str) {
return str.slice(0, 1).toUpperCase + str.slice(1);
}
function selectorByKey(key) {
if (key.includes("[")) {
// get-by-dot does not support 'nested[deeper][key]' notation, convert to 'nested.deeper.key'
key = key
.replace(/\[/g, ".") // nested.deeper].key]
.replace(/\]/g, ""); // nested.deeper.key
}
return function selector(obj) {
if (obj == undefined) {
return undefined;
} else {
return getByDot(obj, key);
}
};
}
function makeHook(hookFunction) {
return function (name, opts = {}) {
// for dynamically generating attributes like 'setName'
const Name = React.useMemo(() => capitalizeFirst(name), [name]);
const selector = React.useMemo(() => selectorByKey(name), [name]);
const valueSelector = opts.valueSelector ?? selector;
opts.initialValue = React.useMemo(() => {
if (opts.source == undefined) {
return opts.defaultValue;
} else {
return valueSelector(opts.source) ?? opts.defaultValue;
}
}, [opts.source, opts.defaultValue, valueSelector]);
const hook = hookFunction({ name, Name }, opts);
return Object.assign(hook, {
name, Name,
});
};
}
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")),
useFieldArrayInput: makeHook(require("./field-array")),
useValue: function (name, value) {
return {
name,
value,
hasChanged: () => true // always included
};
}
};

View File

@@ -0,0 +1,114 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
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/>.
*/
import { useMemo } from "react";
import getByDot from "get-by-dot";
import text from "./text";
import file from "./file";
import bool from "./bool";
import radio from "./radio";
import combobox from "./combo-box";
import checklist from "./check-list";
import fieldarray from "./field-array";
import type {
CreateHook,
FormInputHook,
HookOpts,
TextFormInputHook,
RadioFormInputHook,
FileFormInputHook,
BoolFormInputHook,
ComboboxFormInputHook,
FieldArrayInputHook,
ChecklistInputHook,
} from "./types";
function capitalizeFirst(str: string) {
return str.slice(0, 1).toUpperCase + str.slice(1);
}
function selectorByKey(key: string) {
if (key.includes("[")) {
// get-by-dot does not support 'nested[deeper][key]' notation, convert to 'nested.deeper.key'
key = key
.replace(/\[/g, ".") // nested.deeper].key]
.replace(/\]/g, ""); // nested.deeper.key
}
return function selector(obj) {
if (obj == undefined) {
return undefined;
} else {
return getByDot(obj, key);
}
};
}
/**
* Memoized hook generator function. Take a createHook
* function and use it to return a new FormInputHook function.
*
* @param createHook
* @returns
*/
function inputHook(createHook: CreateHook): (_name: string, _opts: HookOpts) => FormInputHook {
return (name: string, opts?: HookOpts): FormInputHook => {
// for dynamically generating attributes like 'setName'
const Name = useMemo(() => capitalizeFirst(name), [name]);
const selector = useMemo(() => selectorByKey(name), [name]);
const valueSelector = opts?.valueSelector?? selector;
if (opts) {
opts.initialValue = useMemo(() => {
if (opts.source == undefined) {
return opts.defaultValue;
} else {
return valueSelector(opts.source) ?? opts.defaultValue;
}
}, [opts.source, opts.defaultValue, valueSelector]);
}
const hook = createHook({ name, Name }, opts ?? {});
return Object.assign(hook, { name, Name });
};
}
/**
* Simplest form hook type in town.
*/
function value<T>(name: string, initialValue: T) {
return {
_default: initialValue,
name,
Name: "",
value: initialValue,
hasChanged: () => true, // always included
};
}
export const useTextInput = inputHook(text) as (_name: string, _opts?: HookOpts<string>) => TextFormInputHook;
export const useFileInput = inputHook(file) as (_name: string, _opts?: HookOpts<File>) => FileFormInputHook;
export const useBoolInput = inputHook(bool) as (_name: string, _opts?: HookOpts<boolean>) => BoolFormInputHook;
export const useRadioInput = inputHook(radio) as (_name: string, _opts?: HookOpts<string>) => RadioFormInputHook;
export const useComboBoxInput = inputHook(combobox) as (_name: string, _opts?: HookOpts<string>) => ComboboxFormInputHook;
export const useCheckListInput = inputHook(checklist) as (_name: string, _opts?: HookOpts<boolean>) => ChecklistInputHook;
export const useFieldArrayInput = inputHook(fieldarray) as (_name: string, _opts?: HookOpts<string>) => FieldArrayInputHook;
export const useValue = value as <T>(_name: string, _initialValue: T) => FormInputHook<T>;

View File

@@ -17,11 +17,18 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
import { useState } from "react";
import { CreateHookNames, HookOpts, RadioFormInputHook } from "./types";
const _default = "";
module.exports = function useRadioInput({ name, Name }, { initialValue = _default, options }) {
const [value, setValue] = React.useState(initialValue);
export default function useRadioInput(
{ name, Name }: CreateHookNames,
{
initialValue = _default,
options = {},
}: HookOpts<string>
): RadioFormInputHook {
const [value, setValue] = useState(initialValue);
function onChange(e) {
setValue(e.target.value);
@@ -40,13 +47,14 @@ module.exports = function useRadioInput({ name, Name }, { initialValue = _defaul
[`set${Name}`]: setValue
}
], {
name,
onChange,
reset,
name,
Name: "",
value,
setter: setValue,
options,
hasChanged: () => value != initialValue,
_default
});
};
}

View File

@@ -1,67 +0,0 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
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/>.
*/
const Promise = require("bluebird");
const React = require("react");
const getFormMutations = require("./get-form-mutations");
module.exports = function useFormSubmit(form, mutationQuery, { changedOnly = true, onFinish } = {}) {
if (!Array.isArray(mutationQuery)) {
throw new ("useFormSubmit: mutationQuery was not an Array. Is a valid useMutation RTK Query provided?");
}
const [runMutation, result] = mutationQuery;
const usedAction = React.useRef(null);
return [
function submitForm(e) {
let action;
if (e?.preventDefault) {
e.preventDefault();
action = e.nativeEvent.submitter.name;
} else {
action = e;
}
if (action == "") {
action = undefined;
}
usedAction.current = action;
// transform the field definitions into an object with just their values
const { mutationData, updatedFields } = getFormMutations(form, { changedOnly });
if (updatedFields.length == 0) {
return;
}
mutationData.action = action;
return Promise.try(() => {
return runMutation(mutationData);
}).then((res) => {
if (onFinish) {
return onFinish(res);
}
});
},
{
...result,
action: usedAction.current
}
];
};

View File

@@ -0,0 +1,140 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
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/>.
*/
import getFormMutations from "./get-form-mutations";
import { useRef } from "react";
import type {
MutationTrigger,
UseMutationStateResult,
} from "@reduxjs/toolkit/dist/query/react/buildHooks";
import type {
FormSubmitEvent,
FormSubmitFunction,
FormSubmitResult,
HookedForm,
} from "./types";
interface UseFormSubmitOptions {
changedOnly: boolean;
onFinish?: ((_res: any) => void);
}
/**
* Parse changed values from the hooked form into a request
* body, and submit it using the given mutation trigger.
*
* This function basically wraps RTK Query's submit methods to
* work with our hooked form interface.
*
* An `onFinish` callback function can be provided, which will
* be executed on a **successful** run of the given MutationTrigger,
* with the mutation result passed into it.
*
* If `changedOnly` is false, then **all** fields of the given HookedForm
* will be submitted to the mutation endpoint, not just changed ones.
*
* The returned function and result can be triggered and read
* from just like an RTK Query mutation hook result would be.
*
* See: https://redux-toolkit.js.org/rtk-query/usage/mutations#mutation-hook-behavior
*/
export default function useFormSubmit(
form: HookedForm,
mutationQuery: readonly [MutationTrigger<any>, UseMutationStateResult<any, any>],
opts: UseFormSubmitOptions = { changedOnly: true }
): [ FormSubmitFunction, FormSubmitResult ] {
if (!Array.isArray(mutationQuery)) {
throw "useFormSubmit: mutationQuery was not an Array. Is a valid useMutation RTK Query provided?";
}
const { changedOnly, onFinish } = opts;
const [runMutation, mutationResult] = mutationQuery;
const usedAction = useRef<FormSubmitEvent>(undefined);
const submitForm = async(e: FormSubmitEvent) => {
let action: FormSubmitEvent;
if (typeof e === "string") {
if (e !== "") {
// String action name was provided.
action = e;
} else {
// Empty string action name was provided.
action = undefined;
}
} else if (e) {
// Submit event action was provided.
e.preventDefault();
if (e.nativeEvent.submitter) {
// We want the name of the element that was invoked to submit this form,
// which will be something that extends HTMLElement, though we don't know
// what at this point.
//
// See: https://developer.mozilla.org/en-US/docs/Web/API/SubmitEvent/submitter
action = (e.nativeEvent.submitter as Object as { name: string }).name;
} else {
// No submitter defined. Fall back
// to just use the FormSubmitEvent.
action = e;
}
} else {
// Void or null or something
// else was provided.
action = undefined;
}
usedAction.current = action;
// Transform the hooked form into an object.
const {
mutationData,
updatedFields,
} = getFormMutations(form, { changedOnly });
// If there were no updated fields according to
// the form parsing then there's nothing for us
// to do, since remote and desired state match.
if (updatedFields.length == 0) {
return;
}
mutationData.action = action;
try {
const res = await runMutation(mutationData);
if (onFinish) {
onFinish(res);
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(`caught error running mutation: ${e}`);
}
};
return [
submitForm,
{
...mutationResult,
action: usedAction.current
}
];
}

View File

@@ -17,26 +17,40 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
import React, {
useState,
useRef,
useTransition,
useEffect,
} from "react";
import type {
CreateHookNames,
HookOpts,
TextFormInputHook,
} from "./types";
const _default = "";
module.exports = function useTextInput({ name, Name }, {
initialValue = _default,
dontReset = false,
validator,
showValidation = true,
initValidation
} = {}) {
const [text, setText] = React.useState(initialValue);
const textRef = React.useRef(null);
export default function useTextInput(
{ name, Name }: CreateHookNames,
{
initialValue = _default,
dontReset = false,
validator,
showValidation = true,
initValidation
}: HookOpts<string>
): TextFormInputHook {
const [text, setText] = useState(initialValue);
const textRef = useRef<HTMLInputElement>(null);
const [validation, setValidation] = React.useState(initValidation ?? "");
const [_isValidating, startValidation] = React.useTransition();
let valid = validation == "";
const [validation, setValidation] = useState(initValidation ?? "");
const [_isValidating, startValidation] = useTransition();
const valid = validation == "";
function onChange(e) {
let input = e.target.value;
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const input = e.target.value;
setText(input);
if (validator) {
@@ -52,7 +66,7 @@ module.exports = function useTextInput({ name, Name }, {
}
}
React.useEffect(() => {
useEffect(() => {
if (validator && textRef.current) {
if (showValidation) {
textRef.current.setCustomValidity(validation);
@@ -76,12 +90,13 @@ module.exports = function useTextInput({ name, Name }, {
onChange,
reset,
name,
Name: "", // Will be set by inputHook function.
value: text,
ref: textRef,
setter: setText,
valid,
validate: () => setValidation(validator(text)),
validate: () => setValidation(validator ? validator(text): ""),
hasChanged: () => text != initialValue,
_default
});
};
}

View File

@@ -0,0 +1,264 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
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/>.
*/
/* eslint-disable no-unused-vars */
import { ComboboxState } from "ariakit";
import React from "react";
import {
ChangeEventHandler,
Dispatch,
RefObject,
SetStateAction,
SyntheticEvent,
} from "react";
export interface CreateHookNames {
name: string;
Name: string;
}
export interface HookOpts<T = any> {
initialValue?: T,
defaultValue?: T,
dontReset?: boolean,
validator?,
showValidation?: boolean,
initValidation?: string,
length?: number;
options?: { [_: string]: string },
withPreview?: boolean,
maxSize?,
initialInfo?: string;
valueSelector?: Function,
source?,
// checklist input types
entries?: any[];
uniqueKey?: string;
}
export type CreateHook = (
name: CreateHookNames,
opts: HookOpts,
) => FormInputHook;
export interface FormInputHook<T = any> {
/**
* Name of this FormInputHook, as provided
* in the UseFormInputHook options.
*/
name: string;
/**
* `name` with first letter capitalized.
*/
Name: string;
/**
* Current value of this FormInputHook.
*/
value?: T;
/**
* Default value of this FormInputHook.
*/
_default: T;
/**
* Return true if the values of this hook is considered
* to have been changed from the default / initial value.
*/
hasChanged: () => boolean;
}
interface _withReset {
reset: () => void;
}
interface _withOnChange {
onChange: ChangeEventHandler;
}
interface _withSetter<T> {
setter: Dispatch<SetStateAction<T>>;
}
interface _withValidate {
valid: boolean;
validate: () => void;
}
interface _withRef {
ref: RefObject<HTMLElement>;
}
interface _withFile {
previewValue?: string;
infoComponent: React.JSX.Element;
}
interface _withComboboxState {
state: ComboboxState;
}
interface _withNew {
isNew: boolean;
setIsNew: Dispatch<SetStateAction<boolean>>;
}
interface _withSelectedValues {
selectedValues: () => {
[_: string]: any;
}[]
}
interface _withCtx {
ctx
}
interface _withMaxLength {
maxLength: number;
}
interface _withOptions {
options: { [_: string]: string };
}
interface _withToggleAll {
toggleAll: _withRef & _withOnChange
}
interface _withSomeSelected {
someSelected: boolean;
}
interface _withUpdateMultiple {
updateMultiple: (_entries: any) => void;
}
export interface TextFormInputHook extends FormInputHook<string>,
_withSetter<string>,
_withOnChange,
_withReset,
_withValidate,
_withRef {}
export interface RadioFormInputHook extends FormInputHook<string>,
_withSetter<string>,
_withOnChange,
_withOptions,
_withReset {}
export interface FileFormInputHook extends FormInputHook<File | undefined>,
_withOnChange,
_withReset,
Partial<_withRef>,
_withFile {}
export interface BoolFormInputHook extends FormInputHook<boolean>,
_withSetter<boolean>,
_withOnChange,
_withReset {}
export interface ComboboxFormInputHook extends FormInputHook<string>,
_withSetter<string>,
_withComboboxState,
_withNew,
_withReset {}
export interface FieldArrayInputHook extends FormInputHook<HookedForm[]>,
_withSelectedValues,
_withMaxLength,
_withCtx {}
export interface Checkable {
key: string;
checked?: boolean;
}
export interface ChecklistInputHook<T = Checkable> extends FormInputHook<{[k: string]: T}>,
_withReset,
_withToggleAll,
_withSelectedValues,
_withSomeSelected,
_withUpdateMultiple {
// Uses its own funky onChange handler.
onChange: (key: any, value: any) => void
}
export type AnyFormInputHook =
FormInputHook |
TextFormInputHook |
RadioFormInputHook |
FileFormInputHook |
BoolFormInputHook |
ComboboxFormInputHook |
FieldArrayInputHook |
ChecklistInputHook;
export interface HookedForm {
[_: string]: AnyFormInputHook
}
/**
* Parameters for FormSubmitFunction.
*/
export type FormSubmitEvent = (string | SyntheticEvent<HTMLFormElement, Partial<SubmitEvent>> | undefined | void)
/**
* Shadows "trigger" function for useMutation, but can also
* be passed to onSubmit property of forms as a handler.
*
* See: https://redux-toolkit.js.org/rtk-query/usage/mutations#mutation-hook-behavior
*/
export type FormSubmitFunction = ((_e: FormSubmitEvent) => void)
/**
* Shadows redux mutation hook return values.
*
* See: https://redux-toolkit.js.org/rtk-query/usage/mutations#frequently-used-mutation-hook-return-values
*/
export interface FormSubmitResult {
/**
* Action used to submit the form, if any.
*/
action: FormSubmitEvent;
data: any;
error: any;
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
reset: () => void;
}
/**
* Shadows redux query hook return values.
*
* See: https://redux-toolkit.js.org/rtk-query/usage/queries#frequently-used-query-hook-return-values
*/
export type FormWithDataQuery = (_queryArg: any) => {
data?: any;
isLoading: boolean;
isError: boolean;
error?: any;
}