From 659c1863946a396d533d3e16d2aa931f93f17fe4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 3 Apr 2023 15:06:20 -0500 Subject: [PATCH] ManageGroupModal: use internal state instead of Redux --- app/soapbox/actions/groups.ts | 25 --- .../entity-store/hooks/useCreateEntity.ts | 4 +- .../entity-store/hooks/useEntityActions.ts | 2 +- .../group/components/group-avatar-picker.tsx | 2 +- .../group/components/group-header-picker.tsx | 2 +- .../manage-group-modal/manage-group-modal.tsx | 59 +++--- .../manage-group-modal/steps/details-step.tsx | 175 +++++------------- .../manage-group-modal/steps/privacy-step.tsx | 24 +-- .../hooks/api/groups/useCreateGroup.ts | 33 ++++ app/soapbox/hooks/api/index.ts | 1 + app/soapbox/reducers/index.ts | 2 - 11 files changed, 120 insertions(+), 209 deletions(-) create mode 100644 app/soapbox/hooks/api/groups/useCreateGroup.ts diff --git a/app/soapbox/actions/groups.ts b/app/soapbox/actions/groups.ts index 8a6ad065e..1808286e5 100644 --- a/app/soapbox/actions/groups.ts +++ b/app/soapbox/actions/groups.ts @@ -783,30 +783,6 @@ const resetGroupEditor = () => ({ type: GROUP_EDITOR_RESET, }); -const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => { - const groupId = getState().group_editor.groupId; - const displayName = getState().group_editor.displayName; - const note = getState().group_editor.note; - const avatar = getState().group_editor.avatar; - const header = getState().group_editor.header; - const visibility = getState().group_editor.locked ? 'members_only' : 'everyone'; // Truth Social - - const params: Record = { - display_name: displayName, - group_visibility: visibility, - note, - }; - - if (avatar) params.avatar = avatar; - if (header) params.header = header; - - if (groupId === null) { - return dispatch(createGroup(params, shouldReset)); - } else { - return dispatch(updateGroup(groupId, params, shouldReset)); - } -}; - export { GROUP_EDITOR_SET, GROUP_CREATE_REQUEST, @@ -960,5 +936,4 @@ export { changeGroupEditorPrivacy, changeGroupEditorMedia, resetGroupEditor, - submitGroupEditor, }; diff --git a/app/soapbox/entity-store/hooks/useCreateEntity.ts b/app/soapbox/entity-store/hooks/useCreateEntity.ts index ba9dd802b..31299344e 100644 --- a/app/soapbox/entity-store/hooks/useCreateEntity.ts +++ b/app/soapbox/entity-store/hooks/useCreateEntity.ts @@ -20,7 +20,7 @@ function useCreateEntity( ) { const dispatch = useAppDispatch(); - const [isLoading, setPromise] = useLoading(); + const [isSubmitting, setPromise] = useLoading(); const { entityType, listKey } = parseEntitiesPath(expandedPath); async function createEntity(data: Data, callbacks: EntityCallbacks = {}): Promise { @@ -44,7 +44,7 @@ function useCreateEntity( return { createEntity, - isLoading, + isSubmitting, }; } diff --git a/app/soapbox/entity-store/hooks/useEntityActions.ts b/app/soapbox/entity-store/hooks/useEntityActions.ts index dab6f7f77..13fda06ac 100644 --- a/app/soapbox/entity-store/hooks/useEntityActions.ts +++ b/app/soapbox/entity-store/hooks/useEntityActions.ts @@ -27,7 +27,7 @@ function useEntityActions( const { deleteEntity, isLoading: deleteLoading } = useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replaceAll(':id', entityId))); - const { createEntity, isLoading: createLoading } = + const { createEntity, isSubmitting: createLoading } = useCreateEntity(path, (data) => api.post(endpoints.post!, data), opts); return { diff --git a/app/soapbox/features/group/components/group-avatar-picker.tsx b/app/soapbox/features/group/components/group-avatar-picker.tsx index 4e2851e33..b13dfe80e 100644 --- a/app/soapbox/features/group/components/group-avatar-picker.tsx +++ b/app/soapbox/features/group/components/group-avatar-picker.tsx @@ -8,7 +8,7 @@ interface IMediaInput { src: string | undefined accept: string onChange: React.ChangeEventHandler - disabled: boolean + disabled?: boolean } const AvatarPicker = React.forwardRef(({ src, onChange, accept, disabled }, ref) => { diff --git a/app/soapbox/features/group/components/group-header-picker.tsx b/app/soapbox/features/group/components/group-header-picker.tsx index 7fcdae688..d2457ac1e 100644 --- a/app/soapbox/features/group/components/group-header-picker.tsx +++ b/app/soapbox/features/group/components/group-header-picker.tsx @@ -9,7 +9,7 @@ interface IMediaInput { src: string | undefined accept: string onChange: React.ChangeEventHandler - disabled: boolean + disabled?: boolean } const HeaderPicker = React.forwardRef(({ src, onChange, accept, disabled }, ref) => { diff --git a/app/soapbox/features/ui/components/modals/manage-group-modal/manage-group-modal.tsx b/app/soapbox/features/ui/components/modals/manage-group-modal/manage-group-modal.tsx index fcc7c14da..3486c8e6e 100644 --- a/app/soapbox/features/ui/components/modals/manage-group-modal/manage-group-modal.tsx +++ b/app/soapbox/features/ui/components/modals/manage-group-modal/manage-group-modal.tsx @@ -1,10 +1,10 @@ import React, { useMemo, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { submitGroupEditor } from 'soapbox/actions/groups'; import { Modal, Stack } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector, useDebounce } from 'soapbox/hooks'; -import { useGroupValidation } from 'soapbox/hooks/api'; +import { useDebounce } from 'soapbox/hooks'; +import { useCreateGroup, useGroupValidation, type CreateGroupParams } from 'soapbox/hooks/api'; +import { type Group } from 'soapbox/schemas'; import ConfirmationStep from './steps/confirmation-step'; import DetailsStep from './steps/details-step'; @@ -13,7 +13,6 @@ import PrivacyStep from './steps/privacy-step'; const messages = defineMessages({ next: { id: 'manage_group.next', defaultMessage: 'Next' }, create: { id: 'manage_group.create', defaultMessage: 'Create' }, - update: { id: 'manage_group.update', defaultMessage: 'Update' }, done: { id: 'manage_group.done', defaultMessage: 'Done' }, }); @@ -23,12 +22,6 @@ enum Steps { THREE = 'THREE', } -const manageGroupSteps = { - ONE: PrivacyStep, - TWO: DetailsStep, - THREE: ConfirmationStep, -}; - interface IManageGroupModal { onClose: (type?: string) => void } @@ -36,34 +29,26 @@ interface IManageGroupModal { const ManageGroupModal: React.FC = ({ onClose }) => { const intl = useIntl(); const debounce = useDebounce; - const dispatch = useAppDispatch(); - const id = useAppSelector((state) => state.group_editor.groupId); - const [group, setGroup] = useState(null); + const [group, setGroup] = useState(null); + const [params, setParams] = useState({}); + const [currentStep, setCurrentStep] = useState(Steps.ONE); - const isSubmitting = useAppSelector((state) => state.group_editor.isSubmitting); - - const [currentStep, setCurrentStep] = useState(id ? Steps.TWO : Steps.ONE); - - const name = useAppSelector((state) => state.group_editor.displayName); - const debouncedName = debounce(name, 300); + const { createGroup, isSubmitting } = useCreateGroup(); + const debouncedName = debounce(params.display_name || '', 300); const { data: { isValid } } = useGroupValidation(debouncedName); const handleClose = () => { onClose('MANAGE_GROUP'); }; - const handleSubmit = () => { - return dispatch(submitGroupEditor(true)); - }; - const confirmationText = useMemo(() => { switch (currentStep) { case Steps.THREE: return intl.formatMessage(messages.done); case Steps.TWO: - return intl.formatMessage(id ? messages.update : messages.create); + return intl.formatMessage(messages.create); default: return intl.formatMessage(messages.next); } @@ -75,12 +60,12 @@ const ManageGroupModal: React.FC = ({ onClose }) => { setCurrentStep(Steps.TWO); break; case Steps.TWO: - handleSubmit() - .then((group) => { + createGroup(params, { + onSuccess(group) { setCurrentStep(Steps.THREE); setGroup(group); - }) - .catch(() => {}); + }, + }); break; case Steps.THREE: handleClose(); @@ -90,13 +75,20 @@ const ManageGroupModal: React.FC = ({ onClose }) => { } }; - const StepToRender = manageGroupSteps[currentStep]; + const renderStep = () => { + switch (currentStep) { + case Steps.ONE: + return ; + case Steps.TWO: + return ; + case Steps.THREE: + return ; + } + }; return ( - : } + title={} confirmationAction={handleNextStep} confirmationText={confirmationText} confirmationDisabled={isSubmitting || (currentStep === Steps.TWO && !isValid)} @@ -104,8 +96,7 @@ const ManageGroupModal: React.FC = ({ onClose }) => { onClose={handleClose} > - {/* @ts-ignore */} - + {renderStep()} ); diff --git a/app/soapbox/features/ui/components/modals/manage-group-modal/steps/details-step.tsx b/app/soapbox/features/ui/components/modals/manage-group-modal/steps/details-step.tsx index 59b59b2ec..66b7b4b1d 100644 --- a/app/soapbox/features/ui/components/modals/manage-group-modal/steps/details-step.tsx +++ b/app/soapbox/features/ui/components/modals/manage-group-modal/steps/details-step.tsx @@ -1,162 +1,73 @@ -import clsx from 'clsx'; -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { - changeGroupEditorTitle, - changeGroupEditorDescription, - changeGroupEditorMedia, -} from 'soapbox/actions/groups'; -import { Avatar, Form, FormGroup, HStack, Icon, Input, Text, Textarea } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector, useDebounce, useInstance } from 'soapbox/hooks'; -import { useGroupValidation } from 'soapbox/hooks/api'; -import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts'; +import { Form, FormGroup, Input, Textarea } from 'soapbox/components/ui'; +import AvatarPicker from 'soapbox/features/group/components/group-avatar-picker'; +import HeaderPicker from 'soapbox/features/group/components/group-header-picker'; +import { useAppSelector, useDebounce, useInstance } from 'soapbox/hooks'; +import { CreateGroupParams, useGroupValidation } from 'soapbox/hooks/api'; +import { usePreview } from 'soapbox/hooks/forms'; import resizeImage from 'soapbox/utils/resize-image'; import type { List as ImmutableList } from 'immutable'; -interface IMediaInput { - src: string | null - accept: string - onChange: React.ChangeEventHandler - disabled: boolean -} - const messages = defineMessages({ groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' }, groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' }, }); -const HeaderPicker: React.FC = ({ src, onChange, accept, disabled }) => { - return ( - - ); -}; - -const AvatarPicker: React.FC = ({ src, onChange, accept, disabled }) => { - return ( - - ); -}; - -const DetailsStep = () => { +const DetailsStep: React.FC = ({ params, onChange }) => { const intl = useIntl(); const debounce = useDebounce; - const dispatch = useAppDispatch(); const instance = useInstance(); - const groupId = useAppSelector((state) => state.group_editor.groupId); - const isUploading = useAppSelector((state) => state.group_editor.isUploading); - const name = useAppSelector((state) => state.group_editor.displayName); - const description = useAppSelector((state) => state.group_editor.note); - - const debouncedName = debounce(name, 300); + const { + display_name: displayName = '', + note = '', + } = params; + const debouncedName = debounce(displayName, 300); const { data: { isValid, message: errorMessage } } = useGroupValidation(debouncedName); - const [avatarSrc, setAvatarSrc] = useState(null); - const [headerSrc, setHeaderSrc] = useState(null); + const avatarSrc = usePreview(params.avatar); + const headerSrc = usePreview(params.header); const attachmentTypes = useAppSelector( state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList, )?.filter(type => type.startsWith('image/')).toArray().join(','); - const onChangeName: React.ChangeEventHandler = ({ target }) => { - dispatch(changeGroupEditorTitle(target.value)); + const handleTextChange = (property: keyof CreateGroupParams): React.ChangeEventHandler => { + return (e) => { + onChange({ + ...params, + [property]: e.target.value, + }); + }; }; - const onChangeDescription: React.ChangeEventHandler = ({ target }) => { - dispatch(changeGroupEditorDescription(target.value)); + const handleImageChange = (property: keyof CreateGroupParams, maxPixels?: number): React.ChangeEventHandler => { + return async ({ target: { files } }) => { + const file = files ? files[0] : undefined; + if (file) { + const resized = await resizeImage(file, maxPixels); + onChange({ + ...params, + [property]: resized, + }); + } + }; }; - const handleFileChange: React.ChangeEventHandler = e => { - const rawFile = e.target.files?.item(0); - - if (!rawFile) return; - - if (e.target.name === 'avatar') { - resizeImage(rawFile, 400 * 400).then(file => { - dispatch(changeGroupEditorMedia('avatar', file)); - setAvatarSrc(URL.createObjectURL(file)); - }).catch(console.error); - } else { - resizeImage(rawFile, 1920 * 1080).then(file => { - dispatch(changeGroupEditorMedia('header', file)); - setHeaderSrc(URL.createObjectURL(file)); - }).catch(console.error); - } - }; - - useEffect(() => { - if (!groupId) return; - - dispatch((_, getState) => { - const group = getState().groups.items.get(groupId); - if (!group) return; - if (group.avatar && !isDefaultAvatar(group.avatar)) setAvatarSrc(group.avatar); - if (group.header && !isDefaultHeader(group.header)) setHeaderSrc(group.header); - }); - }, [groupId]); - return (
- - + +
{ @@ -179,8 +90,8 @@ const DetailsStep = () => {