diff options
author | 2023-10-17 12:46:06 +0200 | |
---|---|---|
committer | 2023-10-17 12:46:06 +0200 | |
commit | 637f188ebec71fe4b0b80bbab4592d4c269d7d93 (patch) | |
tree | 6e1136dee4d854af021e0a571a67038d32083e4b /web/source/settings/lib/form | |
parent | [chore]: Bump github.com/microcosm-cc/bluemonday from 1.0.25 to 1.0.26 (#2266) (diff) | |
download | gotosocial-637f188ebec71fe4b0b80bbab4592d4c269d7d93.tar.xz |
[feature] Allow import/export/creation of domain allows via admin panel (#2264)v0.12.0-rc1
* 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
Diffstat (limited to 'web/source/settings/lib/form')
-rw-r--r-- | web/source/settings/lib/form/bool.tsx (renamed from web/source/settings/lib/form/bool.jsx) | 17 | ||||
-rw-r--r-- | web/source/settings/lib/form/check-list.tsx (renamed from web/source/settings/lib/form/check-list.jsx) | 158 | ||||
-rw-r--r-- | web/source/settings/lib/form/combo-box.tsx (renamed from web/source/settings/lib/form/combo-box.jsx) | 23 | ||||
-rw-r--r-- | web/source/settings/lib/form/field-array.tsx (renamed from web/source/settings/lib/form/field-array.jsx) | 50 | ||||
-rw-r--r-- | web/source/settings/lib/form/file.tsx (renamed from web/source/settings/lib/form/file.jsx) | 88 | ||||
-rw-r--r-- | web/source/settings/lib/form/form-with-data.tsx (renamed from web/source/settings/lib/form/form-with-data.jsx) | 33 | ||||
-rw-r--r-- | web/source/settings/lib/form/get-form-mutations.ts (renamed from web/source/settings/lib/form/get-form-mutations.js) | 46 | ||||
-rw-r--r-- | web/source/settings/lib/form/index.js | 83 | ||||
-rw-r--r-- | web/source/settings/lib/form/index.ts | 114 | ||||
-rw-r--r-- | web/source/settings/lib/form/radio.tsx (renamed from web/source/settings/lib/form/radio.jsx) | 18 | ||||
-rw-r--r-- | web/source/settings/lib/form/submit.js | 67 | ||||
-rw-r--r-- | web/source/settings/lib/form/submit.ts | 140 | ||||
-rw-r--r-- | web/source/settings/lib/form/text.tsx (renamed from web/source/settings/lib/form/text.jsx) | 51 | ||||
-rw-r--r-- | web/source/settings/lib/form/types.ts | 264 |
14 files changed, 831 insertions, 321 deletions
diff --git a/web/source/settings/lib/form/bool.jsx b/web/source/settings/lib/form/bool.tsx index 47a4bbd1b..815b17bd3 100644 --- a/web/source/settings/lib/form/bool.jsx +++ b/web/source/settings/lib/form/bool.tsx @@ -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 }); -};
\ No newline at end of file +} diff --git a/web/source/settings/lib/form/check-list.jsx b/web/source/settings/lib/form/check-list.tsx index 2f649dba6..c08e5022f 100644 --- a/web/source/settings/lib/form/check-list.jsx +++ b/web/source/settings/lib/form/check-list.tsx @@ -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"; + +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>, +} -enableMapSet(); // for use in reducers +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(); - return { - entries: syncpipe(entries, [ - (_) => _.map((entry) => { - let key = entry[uniqueKey]; - let checked = entry.checked ?? initialValue; - - if (checked) { - selectedEntries.add(key); - } else { - selectedEntries.delete(key); - } +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 [ - key, - { - ...entry, - key, - checked - } - ]; - }), - (_) => Object.fromEntries(_) - ]), + return { + 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]); -};
\ No newline at end of file +} diff --git a/web/source/settings/lib/form/combo-box.jsx b/web/source/settings/lib/form/combo-box.tsx index 985c262d8..e558d298a 100644 --- a/web/source/settings/lib/form/combo-box.jsx +++ b/web/source/settings/lib/form/combo-box.tsx @@ -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 }); -};
\ No newline at end of file +} diff --git a/web/source/settings/lib/form/field-array.jsx b/web/source/settings/lib/form/field-array.tsx index f2d7bc7ce..275bf2b1b 100644 --- a/web/source/settings/lib/form/field-array.jsx +++ b/web/source/settings/lib/form/field-array.tsx @@ -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 = useMemo( + () => parseFields(initialValue, length), + [initialValue, length], + ); - const value = React.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 } } }; -};
\ No newline at end of file +} diff --git a/web/source/settings/lib/form/file.jsx b/web/source/settings/lib/form/file.tsx index a9e96dc97..944d77ae1 100644 --- a/web/source/settings/lib/form/file.jsx +++ b/web/source/settings/lib/form/file.tsx @@ -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]; - setFile(file); +import type { + CreateHookNames, + HookOpts, + FileFormInputHook, +} from "./types"; - URL.revokeObjectURL(imageURL); +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>(); - if (file != undefined) { - if (withPreview) { - setImageURL(URL.createObjectURL(file)); - } + function onChange(e: React.ChangeEvent<HTMLInputElement>) { + const files = e.target.files; + if (!files) { + setInfo(undefined); + return; + } - let size = prettierBytes(file.size); - if (maxSize && file.size > maxSize) { - size = <span className="error-text">{size}</span>; - } + let file = files[0]; + setFile(file); - 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, }); -};
\ No newline at end of file +} diff --git a/web/source/settings/lib/form/form-with-data.jsx b/web/source/settings/lib/form/form-with-data.tsx index ef05c46c0..70a162fb0 100644 --- a/web/source/settings/lib/form/form-with-data.jsx +++ b/web/source/settings/lib/form/form-with-data.tsx @@ -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} />; } -};
\ No newline at end of file +} diff --git a/web/source/settings/lib/form/get-form-mutations.js b/web/source/settings/lib/form/get-form-mutations.ts index b0ae6e9b0..6e1bfa02d 100644 --- a/web/source/settings/lib/form/get-form-mutations.js +++ b/web/source/settings/lib/form/get-form-mutations.ts @@ -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), }; -};
\ No newline at end of file +} diff --git a/web/source/settings/lib/form/index.js b/web/source/settings/lib/form/index.js deleted file mode 100644 index 99537ae7f..000000000 --- a/web/source/settings/lib/form/index.js +++ /dev/null @@ -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 - }; - } -};
\ No newline at end of file diff --git a/web/source/settings/lib/form/index.ts b/web/source/settings/lib/form/index.ts new file mode 100644 index 000000000..20de33eda --- /dev/null +++ b/web/source/settings/lib/form/index.ts @@ -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>; diff --git a/web/source/settings/lib/form/radio.jsx b/web/source/settings/lib/form/radio.tsx index 4bb061f4b..164abab9d 100644 --- a/web/source/settings/lib/form/radio.jsx +++ b/web/source/settings/lib/form/radio.tsx @@ -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 }); -};
\ No newline at end of file +} diff --git a/web/source/settings/lib/form/submit.js b/web/source/settings/lib/form/submit.js deleted file mode 100644 index ab2945812..000000000 --- a/web/source/settings/lib/form/submit.js +++ /dev/null @@ -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 - } - ]; -};
\ No newline at end of file diff --git a/web/source/settings/lib/form/submit.ts b/web/source/settings/lib/form/submit.ts new file mode 100644 index 000000000..d5636a587 --- /dev/null +++ b/web/source/settings/lib/form/submit.ts @@ -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 + } + ]; +} diff --git a/web/source/settings/lib/form/text.jsx b/web/source/settings/lib/form/text.tsx index f9c096ac8..c0b9b93c6 100644 --- a/web/source/settings/lib/form/text.jsx +++ b/web/source/settings/lib/form/text.tsx @@ -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 }); -};
\ No newline at end of file +} diff --git a/web/source/settings/lib/form/types.ts b/web/source/settings/lib/form/types.ts new file mode 100644 index 000000000..45db9e0b8 --- /dev/null +++ b/web/source/settings/lib/form/types.ts @@ -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; +} |