[Frontend] Settings for profile fields (#1885)

* get max emoji size from instance settings

* expose (hardcoded) max amount of profile fields in instance api

* basic profile field setting

* fix profile field hook structure for updates

* *twirls mustache* fix ze tests

---------

Co-authored-by: tsmethurst <tobi.smethurst@protonmail.com>
This commit is contained in:
f0x52 2023-06-13 12:21:26 +02:00 committed by GitHub
parent 4990099fde
commit 8fb5a7e7f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 261 additions and 52 deletions

View File

@ -118,7 +118,8 @@ func (suite *InstancePatchTestSuite) TestInstancePatch1() {
}, },
"accounts": { "accounts": {
"allow_custom_css": true, "allow_custom_css": true,
"max_featured_tags": 10 "max_featured_tags": 10,
"max_profile_fields": 6
}, },
"emojis": { "emojis": {
"emoji_size_limit": 51200 "emoji_size_limit": 51200
@ -221,7 +222,8 @@ func (suite *InstancePatchTestSuite) TestInstancePatch2() {
}, },
"accounts": { "accounts": {
"allow_custom_css": true, "allow_custom_css": true,
"max_featured_tags": 10 "max_featured_tags": 10,
"max_profile_fields": 6
}, },
"emojis": { "emojis": {
"emoji_size_limit": 51200 "emoji_size_limit": 51200
@ -324,7 +326,8 @@ func (suite *InstancePatchTestSuite) TestInstancePatch3() {
}, },
"accounts": { "accounts": {
"allow_custom_css": true, "allow_custom_css": true,
"max_featured_tags": 10 "max_featured_tags": 10,
"max_profile_fields": 6
}, },
"emojis": { "emojis": {
"emoji_size_limit": 51200 "emoji_size_limit": 51200
@ -478,7 +481,8 @@ func (suite *InstancePatchTestSuite) TestInstancePatch6() {
}, },
"accounts": { "accounts": {
"allow_custom_css": true, "allow_custom_css": true,
"max_featured_tags": 10 "max_featured_tags": 10,
"max_profile_fields": 6
}, },
"emojis": { "emojis": {
"emoji_size_limit": 51200 "emoji_size_limit": 51200
@ -603,7 +607,8 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() {
}, },
"accounts": { "accounts": {
"allow_custom_css": true, "allow_custom_css": true,
"max_featured_tags": 10 "max_featured_tags": 10,
"max_profile_fields": 6
}, },
"emojis": { "emojis": {
"emoji_size_limit": 51200 "emoji_size_limit": 51200
@ -743,7 +748,8 @@ func (suite *InstancePatchTestSuite) TestInstancePatch9() {
}, },
"accounts": { "accounts": {
"allow_custom_css": true, "allow_custom_css": true,
"max_featured_tags": 10 "max_featured_tags": 10,
"max_profile_fields": 6
}, },
"emojis": { "emojis": {
"emoji_size_limit": 51200 "emoji_size_limit": 51200

View File

@ -54,6 +54,9 @@ type InstanceConfigurationAccounts struct {
// The maximum number of featured tags allowed for each account. // The maximum number of featured tags allowed for each account.
// Currently not implemented, so this is hardcoded to 10. // Currently not implemented, so this is hardcoded to 10.
MaxFeaturedTags int `json:"max_featured_tags"` MaxFeaturedTags int `json:"max_featured_tags"`
// The maximum number of profile fields allowed for each account.
// Currently not configurable, so this is hardcoded to 6. (https://github.com/superseriousbusiness/gotosocial/issues/1876)
MaxProfileFields int `json:"max_profile_fields"`
} }
// InstanceConfigurationStatuses models instance status config parameters. // InstanceConfigurationStatuses models instance status config parameters.

View File

@ -43,6 +43,7 @@ const (
instancePollsMinExpiration = 300 // seconds instancePollsMinExpiration = 300 // seconds
instancePollsMaxExpiration = 2629746 // seconds instancePollsMaxExpiration = 2629746 // seconds
instanceAccountsMaxFeaturedTags = 10 instanceAccountsMaxFeaturedTags = 10
instanceAccountsMaxProfileFields = 6 // FIXME: https://github.com/superseriousbusiness/gotosocial/issues/1876
instanceSourceURL = "https://github.com/superseriousbusiness/gotosocial" instanceSourceURL = "https://github.com/superseriousbusiness/gotosocial"
) )
@ -756,6 +757,7 @@ func (c *converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
instance.Configuration.Polls.MaxExpiration = instancePollsMaxExpiration instance.Configuration.Polls.MaxExpiration = instancePollsMaxExpiration
instance.Configuration.Accounts.AllowCustomCSS = config.GetAccountsAllowCustomCSS() instance.Configuration.Accounts.AllowCustomCSS = config.GetAccountsAllowCustomCSS()
instance.Configuration.Accounts.MaxFeaturedTags = instanceAccountsMaxFeaturedTags instance.Configuration.Accounts.MaxFeaturedTags = instanceAccountsMaxFeaturedTags
instance.Configuration.Accounts.MaxProfileFields = instanceAccountsMaxProfileFields
instance.Configuration.Emojis.EmojiSizeLimit = int(config.GetMediaEmojiLocalMaxSize()) instance.Configuration.Emojis.EmojiSizeLimit = int(config.GetMediaEmojiLocalMaxSize())
// URLs // URLs
@ -882,6 +884,7 @@ func (c *converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
instance.Configuration.Polls.MaxExpiration = instancePollsMaxExpiration instance.Configuration.Polls.MaxExpiration = instancePollsMaxExpiration
instance.Configuration.Accounts.AllowCustomCSS = config.GetAccountsAllowCustomCSS() instance.Configuration.Accounts.AllowCustomCSS = config.GetAccountsAllowCustomCSS()
instance.Configuration.Accounts.MaxFeaturedTags = instanceAccountsMaxFeaturedTags instance.Configuration.Accounts.MaxFeaturedTags = instanceAccountsMaxFeaturedTags
instance.Configuration.Accounts.MaxProfileFields = instanceAccountsMaxProfileFields
instance.Configuration.Emojis.EmojiSizeLimit = int(config.GetMediaEmojiLocalMaxSize()) instance.Configuration.Emojis.EmojiSizeLimit = int(config.GetMediaEmojiLocalMaxSize())
// registrations // registrations

View File

@ -647,7 +647,8 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV1ToFrontend() {
}, },
"accounts": { "accounts": {
"allow_custom_css": true, "allow_custom_css": true,
"max_featured_tags": 10 "max_featured_tags": 10,
"max_profile_fields": 6
}, },
"emojis": { "emojis": {
"emoji_size_limit": 51200 "emoji_size_limit": 51200
@ -730,7 +731,8 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV2ToFrontend() {
}, },
"accounts": { "accounts": {
"allow_custom_css": true, "allow_custom_css": true,
"max_featured_tags": 10 "max_featured_tags": 10,
"max_profile_fields": 6
}, },
"statuses": { "statuses": {
"max_characters": 5000, "max_characters": 5000,

View File

@ -21,6 +21,7 @@
"match-sorter": "^6.3.1", "match-sorter": "^6.3.1",
"modern-normalize": "^1.1.0", "modern-normalize": "^1.1.0",
"nanoid": "^4.0.0", "nanoid": "^4.0.0",
"object-to-formdata": "^4.4.2",
"papaparse": "^5.3.2", "papaparse": "^5.3.2",
"photoswipe": "^5.3.3", "photoswipe": "^5.3.3",
"photoswipe-dynamic-caption-plugin": "^1.2.7", "photoswipe-dynamic-caption-plugin": "^1.2.7",

View File

@ -42,9 +42,14 @@ const MutationButton = require("../../../components/form/mutation-button");
module.exports = function NewEmojiForm() { module.exports = function NewEmojiForm() {
const shortcode = useShortcode(); const shortcode = useShortcode();
const { data: instance } = query.useInstanceQuery();
const emojiMaxSize = React.useMemo(() => {
return instance?.configuration?.emojis?.emoji_size_limit ?? 50 * 1024;
}, [instance]);
const image = useFileInput("image", { const image = useFileInput("image", {
withPreview: true, withPreview: true,
maxSize: 50 * 1024 // TODO: get from instance api? maxSize: emojiMaxSize
}); });
const category = useComboBoxInput("category"); const category = useComboBoxInput("category");

View File

@ -0,0 +1,33 @@
/*
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/>.
*/
"use strict";
const React = require("react");
const FormContext = React.createContext({});
module.exports = {
FormContext,
useWithFormContext(index, form) {
const formContainer = React.useContext(FormContext);
formContainer[index] = form;
return form;
}
};

View File

@ -0,0 +1,65 @@
/*
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/>.
*/
"use strict";
const React = require("react");
const getFormMutations = require("./get-form-mutations");
function parseFields(entries, length) {
const fields = [];
for (let i = 0; i < length; i++) {
if (entries[i] != undefined) {
fields[i] = Object.assign({}, entries[i]);
} else {
fields[i] = {};
}
}
return fields;
}
module.exports = function useArrayInput({ name, _Name }, { initialValue, length = 0 }) {
const fields = React.useRef({});
const value = React.useMemo(() => parseFields(initialValue, length), [initialValue, length]);
return {
name,
value,
ctx: fields.current,
maxLength: length,
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) {
return Object.values(fields.current).map((fieldSet) => {
return getFormMutations(fieldSet, { changedOnly: false }).mutationData;
});
} else {
return [];
}
}
};
};

View File

@ -0,0 +1,47 @@
/*
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/>.
*/
"use strict";
const syncpipe = require("syncpipe");
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(_)
])
};
};

View File

@ -74,6 +74,7 @@ module.exports = {
useRadioInput: makeHook(require("./radio")), useRadioInput: makeHook(require("./radio")),
useComboBoxInput: makeHook(require("./combo-box")), useComboBoxInput: makeHook(require("./combo-box")),
useCheckListInput: makeHook(require("./check-list")), useCheckListInput: makeHook(require("./check-list")),
useFieldArrayInput: makeHook(require("./field-array")),
useValue: function (name, value) { useValue: function (name, value) {
return { return {
name, name,

View File

@ -21,7 +21,7 @@
const Promise = require("bluebird"); const Promise = require("bluebird");
const React = require("react"); const React = require("react");
const syncpipe = require("syncpipe"); const getFormMutations = require("./get-form-mutations");
module.exports = function useFormSubmit(form, mutationQuery, { changedOnly = true, onFinish } = {}) { module.exports = function useFormSubmit(form, mutationQuery, { changedOnly = true, onFinish } = {}) {
if (!Array.isArray(mutationQuery)) { if (!Array.isArray(mutationQuery)) {
@ -44,25 +44,12 @@ module.exports = function useFormSubmit(form, mutationQuery, { changedOnly = tru
} }
usedAction.current = action; usedAction.current = action;
// transform the field definitions into an object with just their values // transform the field definitions into an object with just their values
let updatedFields = [];
const mutationData = syncpipe(form, [ const { mutationData, updatedFields } = getFormMutations(form, { changedOnly });
(_) => Object.values(_),
(_) => _.map((field) => { if (updatedFields.length == 0) {
if (field.selectedValues != undefined) { return;
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.action = action; mutationData.action = action;

View File

@ -20,23 +20,7 @@
"use strict"; "use strict";
const { createApi, fetchBaseQuery } = require("@reduxjs/toolkit/query/react"); const { createApi, fetchBaseQuery } = require("@reduxjs/toolkit/query/react");
const { isPlainObject } = require("is-plain-object"); const { serialize: serializeForm } = require("object-to-formdata");
function convertToForm(obj) {
const formData = new FormData();
Object.entries(obj).forEach(([key, val]) => {
if (isPlainObject(val)) {
Object.entries(val).forEach(([key2, val2]) => {
if (val2 != undefined) {
formData.set(`${key}[${key2}]`, val2);
}
});
} else if (val != undefined) {
formData.set(key, val);
}
});
return formData;
}
function instanceBasedQuery(args, api, extraOptions) { function instanceBasedQuery(args, api, extraOptions) {
const state = api.getState(); const state = api.getState();
@ -55,7 +39,9 @@ function instanceBasedQuery(args, api, extraOptions) {
if (args.asForm) { if (args.asForm) {
delete args.asForm; delete args.asForm;
args.body = convertToForm(args.body); args.body = serializeForm(args.body, {
indices: true, // Array indices, for profile fields
});
} }
return fetchBaseQuery({ return fetchBaseQuery({

View File

@ -439,6 +439,17 @@ section.with-sidebar > div, section.with-sidebar > form {
} }
} }
} }
.fields {
display: flex;
flex-direction: column;
gap: 0.5rem;
.entry {
display: flex;
gap: 0.5rem;
}
}
} }
form { form {

View File

@ -26,10 +26,12 @@ const query = require("../lib/query");
const { const {
useTextInput, useTextInput,
useFileInput, useFileInput,
useBoolInput useBoolInput,
useFieldArrayInput
} = require("../lib/form"); } = require("../lib/form");
const useFormSubmit = require("../lib/form/submit"); const useFormSubmit = require("../lib/form/submit");
const { useWithFormContext, FormContext } = require("../lib/form/context");
const { const {
TextInput, TextInput,
@ -65,8 +67,11 @@ function UserProfileForm({ data: profile }) {
*/ */
const { data: instance } = query.useInstanceQuery(); const { data: instance } = query.useInstanceQuery();
const allowCustomCSS = React.useMemo(() => { const instanceConfig = React.useMemo(() => {
return instance?.configuration?.accounts?.allow_custom_css === true; return {
allowCustomCSS: instance?.configuration?.accounts?.allow_custom_css === true,
maxPinnedFields: instance?.configuration?.accounts?.max_profile_fields ?? 6
};
}, [instance]); }, [instance]);
const form = { const form = {
@ -78,9 +83,18 @@ function UserProfileForm({ data: profile }) {
bot: useBoolInput("bot", { source: profile }), bot: useBoolInput("bot", { source: profile }),
locked: useBoolInput("locked", { source: profile }), locked: useBoolInput("locked", { source: profile }),
enableRSS: useBoolInput("enable_rss", { source: profile }), enableRSS: useBoolInput("enable_rss", { source: profile }),
fields: useFieldArrayInput("fields_attributes", {
defaultValue: profile?.source?.fields,
length: instanceConfig.maxPinnedFields
}),
}; };
const [submitForm, result] = useFormSubmit(form, query.useUpdateCredentialsMutation()); const [submitForm, result] = useFormSubmit(form, query.useUpdateCredentialsMutation(), {
onFinish: () => {
form.avatar.reset();
form.header.reset();
}
});
return ( return (
<form className="user-profile" onSubmit={submitForm}> <form className="user-profile" onSubmit={submitForm}>
@ -129,7 +143,11 @@ function UserProfileForm({ data: profile }) {
field={form.enableRSS} field={form.enableRSS}
label="Enable RSS feed of Public posts" label="Enable RSS feed of Public posts"
/> />
{!allowCustomCSS ? null : <b>Profile fields</b>
<ProfileFields
field={form.fields}
/>
{!instanceConfig.allowCustomCSS ? null :
<TextArea <TextArea
field={form.customCSS} field={form.customCSS}
label="Custom CSS" label="Custom CSS"
@ -143,3 +161,39 @@ function UserProfileForm({ data: profile }) {
</form> </form>
); );
} }
function ProfileFields({ field: formField }) {
return (
<div className="fields">
<FormContext.Provider value={formField.ctx}>
{formField.value.map((data, i) => (
<Field
key={i}
index={i}
data={data}
/>
))}
</FormContext.Provider>
</div>
);
}
function Field({ index, data }) {
const form = useWithFormContext(index, {
name: useTextInput("name", { defaultValue: data.name }),
value: useTextInput("value", { defaultValue: data.value })
});
return (
<div className="entry">
<TextInput
field={form.name}
placeholder="Name"
/>
<TextInput
field={form.value}
placeholder="Value"
/>
</div>
);
}

View File

@ -4137,6 +4137,11 @@ object-keys@^1.1.1:
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
object-to-formdata@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/object-to-formdata/-/object-to-formdata-4.4.2.tgz#f89013f90493c58cb5f6ab9f50b7aeec30745ea6"
integrity sha512-fu6UDjsqIfFUu/B3GXJ2IFnNAL/YbsC1PPzqDIFXcfkhdYjTD3K4zqhyD/lZ6+KdP9O/64YIPckIOiS5ouXwLA==
object.assign@^4.1.3, object.assign@^4.1.4: object.assign@^4.1.3, object.assign@^4.1.4:
version "4.1.4" version "4.1.4"
resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.4.tgz#9673c7c7c351ab8c4d0b516f4343ebf4dfb7799f" resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.4.tgz#9673c7c7c351ab8c4d0b516f4343ebf4dfb7799f"