[bugfix] Reset emoji fields on upload error (#2905)
This commit is contained in:
parent
f24ce34c3a
commit
578a4e0cf5
|
@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional function to clear the error.
|
||||||
|
* If provided, rendered error will have
|
||||||
|
* a "dismiss" button.
|
||||||
|
*/
|
||||||
|
reset?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Error({ error, reset }: ErrorProps) {
|
||||||
|
if (error === undefined) {
|
||||||
|
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.data.error;
|
// SerializedError or Error.
|
||||||
|
const errMsg = error.message ?? JSON.stringify(error);
|
||||||
|
message = (
|
||||||
|
<>{error.name && `${error.name}: `}{errMsg}</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (error.name != undefined || error.type != undefined) { // JS error
|
|
||||||
message = (<>
|
let className = "error";
|
||||||
<b>{error.type && error.name}:</b> {error.message}
|
if (reset) {
|
||||||
</>);
|
className += " with-dismiss";
|
||||||
} else if (error.status && typeof error.error == "string") {
|
|
||||||
message = (<>
|
|
||||||
<b>{error.status}:</b> {error.error}
|
|
||||||
</>);
|
|
||||||
} else {
|
|
||||||
message = error.message ?? error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
</label>
|
||||||
|
<label className="label-button" htmlFor={id}>
|
||||||
<div className="file-input button">Browse</div>
|
<div className="file-input button">Browse</div>
|
||||||
{infoComponent}
|
</label>
|
||||||
{/* <a onClick={removeFile("header")}>remove</a> */}
|
|
||||||
<input
|
<input
|
||||||
|
id={id}
|
||||||
type="file"
|
type="file"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
ref={ref ? ref as RefObject<HTMLInputElement> : undefined}
|
ref={ref ? ref as RefObject<HTMLInputElement> : undefined}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</label>
|
{infoComponent}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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(
|
setInfo(
|
||||||
<>
|
<ErrorC
|
||||||
{file.name} ({size})
|
error={new Error(`file size ${sizePrettier} is larger than max size ${maxSizePrettier}`)}
|
||||||
</>
|
reset={(reset)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
setInfo(<>{file.name} ({sizePrettier})</>);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
|
||||||
if (imageURL) {
|
|
||||||
URL.revokeObjectURL(imageURL);
|
|
||||||
}
|
|
||||||
setImageURL(undefined);
|
|
||||||
setFile(undefined);
|
|
||||||
setInfo(undefined);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const infoComponent = (
|
const infoComponent = (
|
||||||
|
|
|
@ -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-label {
|
||||||
grid-column: 1 / span 2;
|
grid-area: label-label;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-button {
|
||||||
|
grid-area: label-button;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-info {
|
||||||
|
grid-area: file-info;
|
||||||
|
.error {
|
||||||
|
padding: 0.1rem;
|
||||||
|
line-height: 1.4rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
return prettierBytes(emojiMaxSize);
|
||||||
|
}, [emojiMaxSize]);
|
||||||
|
|
||||||
|
const form = {
|
||||||
|
shortcode: useShortcode(),
|
||||||
|
image: useFileInput("image", {
|
||||||
withPreview: true,
|
withPreview: true,
|
||||||
maxSize: emojiMaxSize
|
maxSize: emojiMaxSize
|
||||||
});
|
}),
|
||||||
|
category: useComboBoxInput("category"),
|
||||||
|
};
|
||||||
|
|
||||||
const category = useComboBoxInput("category");
|
const [submitForm, result] = useFormSubmit(
|
||||||
|
form,
|
||||||
const [submitForm, result] = useFormSubmit({
|
useAddEmojiMutation(),
|
||||||
shortcode, image, category
|
{
|
||||||
}, 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}
|
||||||
/>
|
/>
|
||||||
|
|
Loading…
Reference in New Issue