[bugfix] Reset emoji fields on upload error (#2905)

This commit is contained in:
tobi 2024-05-07 19:48:12 +02:00 committed by GitHub
parent f24ce34c3a
commit 578a4e0cf5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 206 additions and 122 deletions

View file

@ -17,7 +17,9 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import React from "react"; import { SerializedError } from "@reduxjs/toolkit";
import { FetchBaseQueryError } from "@reduxjs/toolkit/query";
import React, { ReactNode } from "react";
function ErrorFallback({ error, resetErrorBoundary }) { function ErrorFallback({ error, resetErrorBoundary }) {
return ( return (
@ -44,39 +46,70 @@ function ErrorFallback({ error, resetErrorBoundary }) {
); );
} }
function Error({ error }) { interface GtsError {
/* eslint-disable-next-line no-console */ /**
console.error("Rendering error:", error); * Error message returned from the API.
let message; */
error: string;
if (error.data != undefined) { // RTK Query error with data /**
if (error.status) { * For OAuth errors: description of the error.
message = (<> */
<b>{error.status}:</b> {error.data.error} error_description?: string;
{error.data.error_description && }
<p>
{error.data.error_description} interface ErrorProps {
</p> error: FetchBaseQueryError | SerializedError | Error | undefined;
}
</>); /**
} else { * Optional function to clear the error.
message = error.data.error; * If provided, rendered error will have
} * a "dismiss" button.
} else if (error.name != undefined || error.type != undefined) { // JS error */
message = (<> reset?: () => void;
<b>{error.type && error.name}:</b> {error.message} }
</>);
} else if (error.status && typeof error.error == "string") { function Error({ error, reset }: ErrorProps) {
message = (<> if (error === undefined) {
<b>{error.status}:</b> {error.error} return null;
</>); }
/* eslint-disable-next-line no-console */
console.error("caught error: ", error);
let message: ReactNode;
if ("status" in error) {
// RTK Query error with data.
const gtsError = error.data as GtsError;
const errMsg = gtsError.error_description ?? gtsError.error;
message = <>Code {error.status} {errMsg}</>;
} else { } else {
message = error.message ?? error; // SerializedError or Error.
const errMsg = error.message ?? JSON.stringify(error);
message = (
<>{error.name && `${error.name}: `}{errMsg}</>
);
}
let className = "error";
if (reset) {
className += " with-dismiss";
} }
return ( return (
<div className="error"> <div className={className}>
{message} <span>{message}</span>
{ reset &&
<span
className="dismiss"
onClick={reset}
role="button"
tabIndex={0}
>
<span>Dismiss</span>
<i className="fa fa-fw fa-close" aria-hidden="true" />
</span>
}
</div> </div>
); );
} }

View file

@ -29,6 +29,7 @@ import type {
RadioFormInputHook, RadioFormInputHook,
TextFormInputHook, TextFormInputHook,
} from "../../lib/form/types"; } from "../../lib/form/types";
import { nanoid } from "nanoid";
export interface TextInputProps extends React.DetailedHTMLProps< export interface TextInputProps extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>, React.InputHTMLAttributes<HTMLInputElement>,
@ -92,22 +93,25 @@ export interface FileInputProps extends React.DetailedHTMLProps<
export function FileInput({ label, field, ...props }: FileInputProps) { export function FileInput({ label, field, ...props }: FileInputProps) {
const { onChange, ref, infoComponent } = field; const { onChange, ref, infoComponent } = field;
const id = nanoid();
return ( return (
<div className="form-field file"> <div className="form-field file">
<label> <label className="label-label" htmlFor={id}>
<div className="label">{label}</div> {label}
<div className="file-input button">Browse</div>
{infoComponent}
{/* <a onClick={removeFile("header")}>remove</a> */}
<input
type="file"
className="hidden"
onChange={onChange}
ref={ref ? ref as RefObject<HTMLInputElement> : undefined}
{...props}
/>
</label> </label>
<label className="label-button" htmlFor={id}>
<div className="file-input button">Browse</div>
</label>
<input
id={id}
type="file"
className="hidden"
onChange={onChange}
ref={ref ? ref as RefObject<HTMLInputElement> : undefined}
{...props}
/>
{infoComponent}
</div> </div>
); );
} }

View file

@ -51,9 +51,9 @@ export default function MutationButton({
} }
return ( return (
<div className={wrapperClassName}> <div className={wrapperClassName ? wrapperClassName : "mutation-button"}>
{(showError && targetsThisButton && result.error) && {(showError && targetsThisButton && result.error) &&
<Error error={result.error} /> <Error error={result.error} reset={result.reset} />
} }
<button <button
type="submit" type="submit"

View file

@ -27,6 +27,7 @@ import type {
HookOpts, HookOpts,
FileFormInputHook, FileFormInputHook,
} from "./types"; } from "./types";
import { Error as ErrorC } from "../../components/error";
const _default = undefined; const _default = undefined;
export default function useFileInput( export default function useFileInput(
@ -41,6 +42,15 @@ export default function useFileInput(
const [imageURL, setImageURL] = useState<string>(); const [imageURL, setImageURL] = useState<string>();
const [info, setInfo] = useState<React.JSX.Element>(); const [info, setInfo] = useState<React.JSX.Element>();
function reset() {
if (imageURL) {
URL.revokeObjectURL(imageURL);
}
setImageURL(undefined);
setFile(undefined);
setInfo(undefined);
}
function onChange(e: React.ChangeEvent<HTMLInputElement>) { function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const files = e.target.files; const files = e.target.files;
if (!files) { if (!files) {
@ -59,25 +69,18 @@ export default function useFileInput(
setImageURL(URL.createObjectURL(file)); setImageURL(URL.createObjectURL(file));
} }
let size = prettierBytes(file.size); const sizePrettier = prettierBytes(file.size);
if (maxSize && file.size > maxSize) { if (maxSize && file.size > maxSize) {
size = <span className="error-text">{size}</span>; const maxSizePrettier = prettierBytes(maxSize);
setInfo(
<ErrorC
error={new Error(`file size ${sizePrettier} is larger than max size ${maxSizePrettier}`)}
reset={(reset)}
/>
);
} else {
setInfo(<>{file.name} ({sizePrettier})</>);
} }
setInfo(
<>
{file.name} ({size})
</>
);
}
function reset() {
if (imageURL) {
URL.revokeObjectURL(imageURL);
}
setImageURL(undefined);
setFile(undefined);
setInfo(undefined);
} }
const infoComponent = ( const infoComponent = (

View file

@ -257,33 +257,37 @@ input, select, textarea {
overflow: auto; overflow: auto;
margin: 0; margin: 0;
} }
&.with-dismiss {
display: flex;
gap: 1rem;
justify-content: space-between;
align-items: center;
align-items: center;
flex-wrap: wrap;
align-items: center;
flex-wrap: wrap;
.dismiss {
display: flex;
flex-shrink: 0;
align-items: center;
align-self: stretch;
gap: 0.25rem;
}
}
}
.mutation-button {
display: flex;
flex-direction: column;
gap: 1rem;
} }
.hidden { .hidden {
display: none; display: none;
} }
.messagebutton, .messagebutton > div {
display: flex;
align-items: center;
flex-wrap: wrap;
div.padded {
margin-left: 1rem;
}
button, .button {
white-space: nowrap;
margin-right: 1rem;
}
}
.messagebutton > div {
button, .button {
margin-top: 1rem;
}
}
.notImplemented { .notImplemented {
border: 2px solid rgb(70, 79, 88); border: 2px solid rgb(70, 79, 88);
background: repeating-linear-gradient( background: repeating-linear-gradient(
@ -500,12 +504,29 @@ form {
font-weight: bold; font-weight: bold;
} }
.form-field.file label { .form-field.file {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
grid-template-areas:
"label-label label-label"
"label-button file-info"
;
.label-label {
grid-area: label-label;
}
.label { .label-button {
grid-column: 1 / span 2; grid-area: label-button;
}
.form-info {
grid-area: file-info;
.error {
padding: 0.1rem;
line-height: 1.4rem;
}
} }
} }

View file

@ -17,7 +17,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import React, { useMemo, useEffect } from "react"; import React, { useMemo, useEffect, ReactNode } from "react";
import { useFileInput, useComboBoxInput } from "../../../../lib/form"; import { useFileInput, useComboBoxInput } from "../../../../lib/form";
import useShortcode from "./use-shortcode"; import useShortcode from "./use-shortcode";
import useFormSubmit from "../../../../lib/form/submit"; import useFormSubmit from "../../../../lib/form/submit";
@ -27,52 +27,74 @@ import FakeToot from "../../../../components/fake-toot";
import MutationButton from "../../../../components/form/mutation-button"; import MutationButton from "../../../../components/form/mutation-button";
import { useAddEmojiMutation } from "../../../../lib/query/admin/custom-emoji"; import { useAddEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
import { useInstanceV1Query } from "../../../../lib/query/gts-api"; import { useInstanceV1Query } from "../../../../lib/query/gts-api";
import prettierBytes from "prettier-bytes";
export default function NewEmojiForm() { export default function NewEmojiForm() {
const shortcode = useShortcode();
const { data: instance } = useInstanceV1Query(); const { data: instance } = useInstanceV1Query();
const emojiMaxSize = useMemo(() => { const emojiMaxSize = useMemo(() => {
return instance?.configuration?.emojis?.emoji_size_limit ?? 50 * 1024; return instance?.configuration?.emojis?.emoji_size_limit ?? 50 * 1024;
}, [instance]); }, [instance]);
const image = useFileInput("image", { const prettierMaxSize = useMemo(() => {
withPreview: true, return prettierBytes(emojiMaxSize);
maxSize: emojiMaxSize }, [emojiMaxSize]);
});
const category = useComboBoxInput("category"); const form = {
shortcode: useShortcode(),
image: useFileInput("image", {
withPreview: true,
maxSize: emojiMaxSize
}),
category: useComboBoxInput("category"),
};
const [submitForm, result] = useFormSubmit({ const [submitForm, result] = useFormSubmit(
shortcode, image, category form,
}, useAddEmojiMutation()); useAddEmojiMutation(),
{
changedOnly: false,
// On submission, reset form values
// no matter what the result was.
onFinish: (_res) => {
form.shortcode.reset();
form.image.reset();
form.category.reset();
}
},
);
useEffect(() => { useEffect(() => {
if (shortcode.value === undefined || shortcode.value.length == 0) { // If shortcode has not been entered yet, but an image file
if (image.value != undefined) { // has been submitted, suggest a shortcode based on filename.
let [name, _ext] = image.value.name.split("."); if (
shortcode.setter(name); (form.shortcode.value === undefined || form.shortcode.value.length === 0) &&
} form.image.value !== undefined
) {
let [name, _ext] = form.image.value.name.split(".");
form.shortcode.setter(name);
} }
/* We explicitly don't want to have 'shortcode' as a dependency here // We explicitly don't want to have 'shortcode' as a
because we only want to change the shortcode to the filename if the field is empty // dependency here because we only want to change the
at the moment the file is selected, not some time after when the field is emptied // shortcode to the filename if the field is empty at
*/ // the moment the file is selected, not some time after
/* eslint-disable-next-line react-hooks/exhaustive-deps */ // when the field is emptied.
}, [image.value]); //
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form.image.value]);
let emojiOrShortcode; let emojiOrShortcode: ReactNode;
if (form.image.previewValue !== undefined) {
if (image.previewValue != undefined) { emojiOrShortcode = (
emojiOrShortcode = <img <img
className="emoji" className="emoji"
src={image.previewValue} src={form.image.previewValue}
title={`:${shortcode.value}:`} title={`:${form.shortcode.value}:`}
alt={shortcode.value} alt={form.shortcode.value}
/>; />
} else if (shortcode.value !== undefined && shortcode.value.length > 0) { );
emojiOrShortcode = `:${shortcode.value}:`; } else if (form.shortcode.value !== undefined && form.shortcode.value.length > 0) {
emojiOrShortcode = `:${form.shortcode.value}:`;
} else { } else {
emojiOrShortcode = `:your_emoji_here:`; emojiOrShortcode = `:your_emoji_here:`;
} }
@ -87,22 +109,23 @@ export default function NewEmojiForm() {
<form onSubmit={submitForm} className="form-flex"> <form onSubmit={submitForm} className="form-flex">
<FileInput <FileInput
field={image} field={form.image}
label={`Image file: png, gif, or static webp; max size ${prettierMaxSize}`}
accept="image/png,image/gif,image/webp" accept="image/png,image/gif,image/webp"
/> />
<TextInput <TextInput
field={shortcode} field={form.shortcode}
label="Shortcode, must be unique among the instance's local emoji" label="Shortcode, must be unique among the instance's local emoji"
{...{pattern: "^\\w{2,30}$"}}
/> />
<CategorySelect <CategorySelect
field={category} field={form.category}
children={[]}
/> />
<MutationButton <MutationButton
disabled={image.previewValue === undefined} disabled={form.image.previewValue === undefined || form.shortcode.value?.length === 0}
label="Upload emoji" label="Upload emoji"
result={result} result={result}
/> />