diff --git a/extensions/flags/js/src/forum/components/FlagPostModal.js b/extensions/flags/js/src/forum/components/FlagPostModal.js index 204dad1dc..560b99ec2 100644 --- a/extensions/flags/js/src/forum/components/FlagPostModal.js +++ b/extensions/flags/js/src/forum/components/FlagPostModal.js @@ -1,5 +1,5 @@ import app from 'flarum/forum/app'; -import Modal from 'flarum/common/components/Modal'; +import FormModal from 'flarum/common/components/FormModal'; import Form from 'flarum/common/components/Form'; import Button from 'flarum/common/components/Button'; @@ -7,7 +7,7 @@ import Stream from 'flarum/common/utils/Stream'; import withAttr from 'flarum/common/utils/withAttr'; import ItemList from 'flarum/common/utils/ItemList'; -export default class FlagPostModal extends Modal { +export default class FlagPostModal extends FormModal { oninit(vnode) { super.oninit(vnode); diff --git a/extensions/nicknames/js/src/forum/components/NicknameModal.js b/extensions/nicknames/js/src/forum/components/NicknameModal.js index cf6484e05..2304a78bf 100644 --- a/extensions/nicknames/js/src/forum/components/NicknameModal.js +++ b/extensions/nicknames/js/src/forum/components/NicknameModal.js @@ -1,10 +1,10 @@ import app from 'flarum/forum/app'; -import Modal from 'flarum/common/components/Modal'; +import FormModal from 'flarum/common/components/FormModal'; import Button from 'flarum/common/components/Button'; import Stream from 'flarum/common/utils/Stream'; import Form from '@flarum/core/src/common/components/Form'; -export default class NicknameModal extends Modal { +export default class NicknameModal extends FormModal { oninit(vnode) { super.oninit(vnode); this.nickname = Stream(app.session.user.displayName()); diff --git a/extensions/statistics/js/src/admin/components/StatisticsWidgetDateSelectionModal.tsx b/extensions/statistics/js/src/admin/components/StatisticsWidgetDateSelectionModal.tsx index 060d10c63..da4deaa90 100644 --- a/extensions/statistics/js/src/admin/components/StatisticsWidgetDateSelectionModal.tsx +++ b/extensions/statistics/js/src/admin/components/StatisticsWidgetDateSelectionModal.tsx @@ -1,7 +1,7 @@ import app from 'flarum/admin/app'; import ItemList from 'flarum/common/utils/ItemList'; import generateElementId from 'flarum/admin/utils/generateElementId'; -import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; +import FormModal, { IFormModalAttrs } from 'flarum/common/components/FormModal'; import Mithril from 'mithril'; import Button from 'flarum/common/components/Button'; @@ -22,7 +22,7 @@ export interface IDateSelection { end: number; } -export interface IStatisticsWidgetDateSelectionModalAttrs extends IInternalModalAttrs { +export interface IStatisticsWidgetDateSelectionModalAttrs extends IFormModalAttrs { onModalSubmit: (dates: IDateSelection) => void; value?: IDateSelection; } @@ -38,7 +38,7 @@ interface IStatisticsWidgetDateSelectionModalState { }; } -export default class StatisticsWidgetDateSelectionModal extends Modal { +export default class StatisticsWidgetDateSelectionModal extends FormModal { /* @ts-expect-error core typings don't allow us to set the type of the state attr :( */ state: IStatisticsWidgetDateSelectionModalState = { inputs: { diff --git a/extensions/suspend/js/src/forum/components/SuspendUserModal.js b/extensions/suspend/js/src/forum/components/SuspendUserModal.js index c581c803e..eaeb90622 100644 --- a/extensions/suspend/js/src/forum/components/SuspendUserModal.js +++ b/extensions/suspend/js/src/forum/components/SuspendUserModal.js @@ -1,5 +1,5 @@ import app from 'flarum/forum/app'; -import Modal from 'flarum/common/components/Modal'; +import FormModal from 'flarum/common/components/FormModal'; import Button from 'flarum/common/components/Button'; import Stream from 'flarum/common/utils/Stream'; import withAttr from 'flarum/common/utils/withAttr'; @@ -9,7 +9,7 @@ import { getPermanentSuspensionDate } from '../helpers/suspensionHelper'; import Form from '@flarum/core/src/common/components/Form'; import FieldSet from '@flarum/core/src/common/components/FieldSet'; -export default class SuspendUserModal extends Modal { +export default class SuspendUserModal extends FormModal { oninit(vnode) { super.oninit(vnode); diff --git a/extensions/tags/js/src/admin/components/EditTagModal.tsx b/extensions/tags/js/src/admin/components/EditTagModal.tsx index 62c412ebe..ffc76c288 100644 --- a/extensions/tags/js/src/admin/components/EditTagModal.tsx +++ b/extensions/tags/js/src/admin/components/EditTagModal.tsx @@ -1,5 +1,5 @@ import app from 'flarum/admin/app'; -import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; +import FormModal, { IFormModalAttrs } from 'flarum/common/components/FormModal'; import Button from 'flarum/common/components/Button'; import ColorPreviewInput from 'flarum/common/components/ColorPreviewInput'; import ItemList from 'flarum/common/utils/ItemList'; @@ -12,7 +12,7 @@ import type Mithril from 'mithril'; import tagLabel from '../../common/helpers/tagLabel'; import type Tag from '../../common/models/Tag'; -export interface EditTagModalAttrs extends IInternalModalAttrs { +export interface EditTagModalAttrs extends IFormModalAttrs { primary?: boolean; model?: Tag; } @@ -21,7 +21,7 @@ export interface EditTagModalAttrs extends IInternalModalAttrs { * The `EditTagModal` component shows a modal dialog which allows the user * to create or edit a tag. */ -export default class EditTagModal extends Modal { +export default class EditTagModal extends FormModal { tag!: Tag; name!: Stream; diff --git a/extensions/tags/js/src/common/components/TagSelectionModal.tsx b/extensions/tags/js/src/common/components/TagSelectionModal.tsx index 496a958c8..68e8affb4 100644 --- a/extensions/tags/js/src/common/components/TagSelectionModal.tsx +++ b/extensions/tags/js/src/common/components/TagSelectionModal.tsx @@ -5,7 +5,7 @@ import extractText from 'flarum/common/utils/extractText'; import highlight from 'flarum/common/helpers/highlight'; import KeyboardNavigatable from 'flarum/common/utils/KeyboardNavigatable'; import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; -import Modal from 'flarum/common/components/Modal'; +import FormModal from 'flarum/common/components/FormModal'; import Stream from 'flarum/common/utils/Stream'; import sortTags from '../utils/sortTags'; @@ -14,7 +14,7 @@ import tagIcon from '../helpers/tagIcon'; import ToggleButton from '../../forum/components/ToggleButton'; import type Tag from '../models/Tag'; -import type { IInternalModalAttrs } from 'flarum/common/components/Modal'; +import type { IFormModalAttrs } from 'flarum/common/components/FormModal'; import type Mithril from 'mithril'; export interface ITagSelectionModalLimits { @@ -34,7 +34,7 @@ export interface ITagSelectionModalLimits { }; } -export interface ITagSelectionModalAttrs extends IInternalModalAttrs { +export interface ITagSelectionModalAttrs extends IFormModalAttrs { /** Custom modal className to use. */ className?: string; /** Modal title, defaults to 'Choose Tags'. */ @@ -64,7 +64,7 @@ export type ITagSelectionModalState = undefined; export default class TagSelectionModal< CustomAttrs extends ITagSelectionModalAttrs = ITagSelectionModalAttrs, CustomState extends ITagSelectionModalState = ITagSelectionModalState -> extends Modal { +> extends FormModal { protected loading = true; protected tags!: Tag[]; protected selected: Tag[] = []; @@ -108,7 +108,7 @@ export default class TagSelectionModal< .onSelect(this.select.bind(this)) .onRemove(() => this.selected.splice(this.selected.length - 1, 1)); - app.tagList.load(['parent']).then((tags) => { + app.tagList.load(['parent']).then((tags: Tag[]) => { this.loading = false; if (this.attrs.selectableTags) { diff --git a/extensions/tags/js/src/common/states/TagListState.ts b/extensions/tags/js/src/common/states/TagListState.ts index e0db4a215..06661a4c1 100644 --- a/extensions/tags/js/src/common/states/TagListState.ts +++ b/extensions/tags/js/src/common/states/TagListState.ts @@ -21,7 +21,7 @@ export default class TagListState { async query(includes: string[] = []): Promise { this.loadedIncludes ??= new Set(); - return app.store.find('tags', { include: includes.join(',') }).then((val) => { + return app.store.find('tags', { include: includes.join(',') }).then((val: Tag) => { includes.forEach((include) => this.loadedIncludes!.add(include)); return val; }); diff --git a/framework/core/js/src/admin/components/CreateUserModal.tsx b/framework/core/js/src/admin/components/CreateUserModal.tsx index 7b8dfa960..a351364c4 100644 --- a/framework/core/js/src/admin/components/CreateUserModal.tsx +++ b/framework/core/js/src/admin/components/CreateUserModal.tsx @@ -1,5 +1,5 @@ import app from '../../admin/app'; -import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; +import FormModal, { IFormModalAttrs } from '../../common/components/FormModal'; import Button from '../../common/components/Button'; import extractText from '../../common/utils/extractText'; import ItemList from '../../common/utils/ItemList'; @@ -9,7 +9,7 @@ import Switch from '../../common/components/Switch'; import { generateRandomString } from '../../common/utils/string'; import Form from '../../common/components/Form'; -export interface ICreateUserModalAttrs extends IInternalModalAttrs { +export interface ICreateUserModalAttrs extends IFormModalAttrs { username?: string; email?: string; password?: string; @@ -24,7 +24,7 @@ export type SignupBody = { password: string; }; -export default class CreateUserModal extends Modal { +export default class CreateUserModal extends FormModal { /** * The value of the username input. */ diff --git a/framework/core/js/src/admin/components/EditGroupModal.tsx b/framework/core/js/src/admin/components/EditGroupModal.tsx index a77e684df..123d40e11 100644 --- a/framework/core/js/src/admin/components/EditGroupModal.tsx +++ b/framework/core/js/src/admin/components/EditGroupModal.tsx @@ -1,5 +1,5 @@ import app from '../../admin/app'; -import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; +import FormModal, { IFormModalAttrs } from '../../common/components/FormModal'; import Button from '../../common/components/Button'; import Badge from '../../common/components/Badge'; import Group from '../../common/models/Group'; @@ -11,7 +11,7 @@ import extractText from '../../common/utils/extractText'; import ColorPreviewInput from '../../common/components/ColorPreviewInput'; import Form from '../../common/components/Form'; -export interface IEditGroupModalAttrs extends IInternalModalAttrs { +export interface IEditGroupModalAttrs extends IFormModalAttrs { group?: Group; } @@ -19,7 +19,7 @@ export interface IEditGroupModalAttrs extends IInternalModalAttrs { * The `EditGroupModal` component shows a modal dialog which allows the user * to create or edit a group. */ -export default class EditGroupModal extends Modal { +export default class EditGroupModal extends FormModal { group!: Group; nameSingular!: Stream; namePlural!: Stream; diff --git a/framework/core/js/src/admin/components/LoadingModal.tsx b/framework/core/js/src/admin/components/LoadingModal.tsx index f0bd33508..485c00c0d 100644 --- a/framework/core/js/src/admin/components/LoadingModal.tsx +++ b/framework/core/js/src/admin/components/LoadingModal.tsx @@ -19,8 +19,4 @@ export default class LoadingModal extends Modal { +export default abstract class SettingsModal extends FormModal { settings: MutableSettings = {}; loading: boolean = false; diff --git a/framework/core/js/src/common/common.ts b/framework/core/js/src/common/common.ts index 4e1d8c4e2..13c450dc5 100644 --- a/framework/core/js/src/common/common.ts +++ b/framework/core/js/src/common/common.ts @@ -65,6 +65,7 @@ import './components/SelectDropdown'; import './components/ModalManager'; import './components/Button'; import './components/Modal'; +import './components/FormModal'; import './components/GroupBadge'; import './components/TextEditor'; import './components/TextEditorButton'; diff --git a/framework/core/js/src/common/components/EditUserModal.tsx b/framework/core/js/src/common/components/EditUserModal.tsx index d23e07c92..a8dc5b6e2 100644 --- a/framework/core/js/src/common/components/EditUserModal.tsx +++ b/framework/core/js/src/common/components/EditUserModal.tsx @@ -1,5 +1,5 @@ import app from '../../common/app'; -import Modal, { IInternalModalAttrs } from './Modal'; +import FormModal, { IFormModalAttrs } from '../../common/components/FormModal'; import Button from './Button'; import GroupBadge from './GroupBadge'; import Group from '../models/Group'; @@ -11,11 +11,11 @@ import type User from '../models/User'; import type { SaveAttributes, SaveRelationships } from '../Model'; import Form from './Form'; -export interface IEditUserModalAttrs extends IInternalModalAttrs { +export interface IEditUserModalAttrs extends IFormModalAttrs { user: User; } -export default class EditUserModal extends Modal { +export default class EditUserModal extends FormModal { protected username!: Stream; protected email!: Stream; protected isEmailConfirmed!: Stream; diff --git a/framework/core/js/src/common/components/FormModal.tsx b/framework/core/js/src/common/components/FormModal.tsx new file mode 100644 index 000000000..40d3e8e55 --- /dev/null +++ b/framework/core/js/src/common/components/FormModal.tsx @@ -0,0 +1,51 @@ +import Modal from './Modal'; +import type { IInternalModalAttrs } from './Modal'; +import RequestError from '../utils/RequestError'; +import Mithril from 'mithril'; + +export interface IFormModalAttrs extends IInternalModalAttrs {} + +/** + * The `FormModal` component displays a modal dialog, wrapped in a form. + * Subclasses should implement the `className`, `title`, and `content` methods. + */ +export default abstract class FormModal extends Modal< + ModalAttrs, + CustomState +> { + wrapper(children: Mithril.Children): Mithril.Children { + return
{children}
; + } + + /** + * Handle the modal form's submit event. + */ + onsubmit(e: SubmitEvent): void { + // ... + } + + /** + * Callback executed when the modal is shown and ready to be interacted with. + * + * @remark Focuses the first input in the modal. + */ + onready(): void { + this.$().find('input, select, textarea').first().trigger('focus').trigger('select'); + } + + /** + * Shows an alert describing an error returned from the API, and gives focus to + * the first relevant field involved in the error. + */ + onerror(error: RequestError): void { + this.alertAttrs = error.alert; + + m.redraw(); + + if (error.status === 422 && error.response?.errors) { + this.$('form [name=' + (error.response.errors as any[])[0].source.pointer.replace('/data/attributes/', '') + ']').trigger('select'); + } else { + this.onready(); + } + } +} diff --git a/framework/core/js/src/common/components/Modal.tsx b/framework/core/js/src/common/components/Modal.tsx index 3e6e0275c..7b1787798 100644 --- a/framework/core/js/src/common/components/Modal.tsx +++ b/framework/core/js/src/common/components/Modal.tsx @@ -5,7 +5,6 @@ import Button from './Button'; import type Mithril from 'mithril'; import type ModalManagerState from '../states/ModalManagerState'; -import type RequestError from '../utils/RequestError'; import type ModalManager from './ModalManager'; import fireDebugWarning from '../helpers/fireDebugWarning'; import classList from '../utils/classList'; @@ -101,25 +100,34 @@ export default abstract class Modal )} - -
-
-

{this.title()}

-
- - {!!this.alertAttrs && ( -
- -
- )} - - {this.content()} -
+ {this.wrapper(this.inner())} ); } + protected wrapper(children: Mithril.Children): Mithril.Children { + return <>{children}; + } + + protected inner(): Mithril.Children { + return ( + <> +
+

{this.title()}

+
+ + {!!this.alertAttrs && ( +
+ +
+ )} + + {this.content()} + + ); + } + /** * Get the class name to apply to the modal. */ @@ -135,20 +143,11 @@ export default abstract class Modal extends Modal { +export default class ChangeEmailModal extends FormModal { /** * The value of the email input. */ diff --git a/framework/core/js/src/forum/components/ChangePasswordModal.tsx b/framework/core/js/src/forum/components/ChangePasswordModal.tsx index 9e92102f1..f84c9a203 100644 --- a/framework/core/js/src/forum/components/ChangePasswordModal.tsx +++ b/framework/core/js/src/forum/components/ChangePasswordModal.tsx @@ -1,5 +1,5 @@ import app from '../../forum/app'; -import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; +import FormModal, { IFormModalAttrs } from '../../common/components/FormModal'; import Button from '../../common/components/Button'; import Mithril from 'mithril'; import ItemList from '../../common/utils/ItemList'; @@ -9,7 +9,7 @@ import Form from '../../common/components/Form'; * The `ChangePasswordModal` component shows a modal dialog which allows the * user to send themself a password reset email. */ -export default class ChangePasswordModal extends Modal { +export default class ChangePasswordModal extends FormModal { className() { return 'ChangePasswordModal Modal--small'; } diff --git a/framework/core/js/src/forum/components/ForgotPasswordModal.tsx b/framework/core/js/src/forum/components/ForgotPasswordModal.tsx index 947527353..c9c32b7d6 100644 --- a/framework/core/js/src/forum/components/ForgotPasswordModal.tsx +++ b/framework/core/js/src/forum/components/ForgotPasswordModal.tsx @@ -1,5 +1,5 @@ import app from '../../forum/app'; -import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; +import FormModal, { IFormModalAttrs } from '../../common/components/FormModal'; import Button from '../../common/components/Button'; import extractText from '../../common/utils/extractText'; import Stream from '../../common/utils/Stream'; @@ -8,7 +8,7 @@ import RequestError from '../../common/utils/RequestError'; import ItemList from '../../common/utils/ItemList'; import Form from '../../common/components/Form'; -export interface IForgotPasswordModalAttrs extends IInternalModalAttrs { +export interface IForgotPasswordModalAttrs extends IFormModalAttrs { email?: string; } @@ -16,7 +16,7 @@ export interface IForgotPasswordModalAttrs extends IInternalModalAttrs { * The `ForgotPasswordModal` component displays a modal which allows the user to * enter their email address and request a link to reset their password. */ -export default class ForgotPasswordModal extends Modal { +export default class ForgotPasswordModal extends FormModal { /** * The value of the email input. */ diff --git a/framework/core/js/src/forum/components/LogInModal.tsx b/framework/core/js/src/forum/components/LogInModal.tsx index c087e6431..dfe8d8c23 100644 --- a/framework/core/js/src/forum/components/LogInModal.tsx +++ b/framework/core/js/src/forum/components/LogInModal.tsx @@ -1,5 +1,5 @@ import app from '../../forum/app'; -import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; +import FormModal, { IFormModalAttrs } from '../../common/components/FormModal'; import Button from '../../common/components/Button'; import LogInButtons from './LogInButtons'; import extractText from '../../common/utils/extractText'; @@ -9,13 +9,13 @@ import type Mithril from 'mithril'; import RequestError from '../../common/utils/RequestError'; import type { LoginParams } from '../../common/Session'; -export interface ILoginModalAttrs extends IInternalModalAttrs { +export interface ILoginModalAttrs extends IFormModalAttrs { identification?: string; password?: string; remember?: boolean; } -export default class LogInModal extends Modal { +export default class LogInModal extends FormModal { /** * The value of the identification input. */ diff --git a/framework/core/js/src/forum/components/NewAccessTokenModal.tsx b/framework/core/js/src/forum/components/NewAccessTokenModal.tsx index 3f54cd98d..fd242d9e2 100644 --- a/framework/core/js/src/forum/components/NewAccessTokenModal.tsx +++ b/framework/core/js/src/forum/components/NewAccessTokenModal.tsx @@ -1,5 +1,5 @@ import app from '../app'; -import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; +import FormModal, { IFormModalAttrs } from '../../common/components/FormModal'; import Button from '../../common/components/Button'; import Stream from '../../common/utils/Stream'; import type AccessToken from '../../common/models/AccessToken'; @@ -7,11 +7,11 @@ import type { SaveAttributes } from '../../common/Model'; import type Mithril from 'mithril'; import Form from '../../common/components/Form'; -export interface INewAccessTokenModalAttrs extends IInternalModalAttrs { +export interface INewAccessTokenModalAttrs extends IFormModalAttrs { onsuccess: (token: AccessToken) => void; } -export default class NewAccessTokenModal extends Modal { +export default class NewAccessTokenModal extends FormModal { protected titleInput = Stream(''); className(): string { diff --git a/framework/core/js/src/forum/components/RenameDiscussionModal.tsx b/framework/core/js/src/forum/components/RenameDiscussionModal.tsx index 12a0e865a..7857fe41c 100644 --- a/framework/core/js/src/forum/components/RenameDiscussionModal.tsx +++ b/framework/core/js/src/forum/components/RenameDiscussionModal.tsx @@ -1,12 +1,12 @@ import app from '../../forum/app'; -import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; +import FormModal, { IFormModalAttrs } from '../../common/components/FormModal'; import Button from '../../common/components/Button'; import Stream from '../../common/utils/Stream'; import Mithril from 'mithril'; import Discussion from '../../common/models/Discussion'; import Form from '../../common/components/Form'; -export interface IRenameDiscussionModalAttrs extends IInternalModalAttrs { +export interface IRenameDiscussionModalAttrs extends IFormModalAttrs { discussion: Discussion; currentTitle: string; } @@ -14,7 +14,9 @@ export interface IRenameDiscussionModalAttrs extends IInternalModalAttrs { /** * The 'RenameDiscussionModal' displays a modal dialog with an input to rename a discussion */ -export default class RenameDiscussionModal extends Modal { +export default class RenameDiscussionModal< + CustomAttrs extends IRenameDiscussionModalAttrs = IRenameDiscussionModalAttrs +> extends FormModal { discussion!: Discussion; currentTitle!: string; newTitle!: Stream; diff --git a/framework/core/js/src/forum/components/SignUpModal.tsx b/framework/core/js/src/forum/components/SignUpModal.tsx index c456cbb7f..f989e1e15 100644 --- a/framework/core/js/src/forum/components/SignUpModal.tsx +++ b/framework/core/js/src/forum/components/SignUpModal.tsx @@ -1,5 +1,5 @@ import app from '../../forum/app'; -import Modal, { IInternalModalAttrs } from '../../common/components/Modal'; +import FormModal, { IFormModalAttrs } from '../../common/components/FormModal'; import Button from '../../common/components/Button'; import LogInButtons from './LogInButtons'; import extractText from '../../common/utils/extractText'; @@ -7,7 +7,7 @@ import ItemList from '../../common/utils/ItemList'; import Stream from '../../common/utils/Stream'; import type Mithril from 'mithril'; -export interface ISignupModalAttrs extends IInternalModalAttrs { +export interface ISignupModalAttrs extends IFormModalAttrs { username?: string; email?: string; password?: string; @@ -20,7 +20,7 @@ export type SignupBody = { email: string; } & ({ token: string } | { password: string }); -export default class SignUpModal extends Modal { +export default class SignUpModal extends FormModal { /** * The value of the username input. */