ManageGroupModal: use internal state instead of Redux

This commit is contained in:
Alex Gleason 2023-04-03 15:06:20 -05:00
parent 1b9c070a20
commit 659c186394
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
11 changed files with 120 additions and 209 deletions

View File

@ -783,30 +783,6 @@ const resetGroupEditor = () => ({
type: GROUP_EDITOR_RESET, 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<string, any> = {
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 { export {
GROUP_EDITOR_SET, GROUP_EDITOR_SET,
GROUP_CREATE_REQUEST, GROUP_CREATE_REQUEST,
@ -960,5 +936,4 @@ export {
changeGroupEditorPrivacy, changeGroupEditorPrivacy,
changeGroupEditorMedia, changeGroupEditorMedia,
resetGroupEditor, resetGroupEditor,
submitGroupEditor,
}; };

View File

@ -20,7 +20,7 @@ function useCreateEntity<TEntity extends Entity = Entity, Data = unknown>(
) { ) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [isLoading, setPromise] = useLoading(); const [isSubmitting, setPromise] = useLoading();
const { entityType, listKey } = parseEntitiesPath(expandedPath); const { entityType, listKey } = parseEntitiesPath(expandedPath);
async function createEntity(data: Data, callbacks: EntityCallbacks<TEntity> = {}): Promise<void> { async function createEntity(data: Data, callbacks: EntityCallbacks<TEntity> = {}): Promise<void> {
@ -44,7 +44,7 @@ function useCreateEntity<TEntity extends Entity = Entity, Data = unknown>(
return { return {
createEntity, createEntity,
isLoading, isSubmitting,
}; };
} }

View File

@ -27,7 +27,7 @@ function useEntityActions<TEntity extends Entity = Entity, Data = any>(
const { deleteEntity, isLoading: deleteLoading } = const { deleteEntity, isLoading: deleteLoading } =
useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replaceAll(':id', entityId))); useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replaceAll(':id', entityId)));
const { createEntity, isLoading: createLoading } = const { createEntity, isSubmitting: createLoading } =
useCreateEntity<TEntity, Data>(path, (data) => api.post(endpoints.post!, data), opts); useCreateEntity<TEntity, Data>(path, (data) => api.post(endpoints.post!, data), opts);
return { return {

View File

@ -8,7 +8,7 @@ interface IMediaInput {
src: string | undefined src: string | undefined
accept: string accept: string
onChange: React.ChangeEventHandler<HTMLInputElement> onChange: React.ChangeEventHandler<HTMLInputElement>
disabled: boolean disabled?: boolean
} }
const AvatarPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onChange, accept, disabled }, ref) => { const AvatarPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onChange, accept, disabled }, ref) => {

View File

@ -9,7 +9,7 @@ interface IMediaInput {
src: string | undefined src: string | undefined
accept: string accept: string
onChange: React.ChangeEventHandler<HTMLInputElement> onChange: React.ChangeEventHandler<HTMLInputElement>
disabled: boolean disabled?: boolean
} }
const HeaderPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onChange, accept, disabled }, ref) => { const HeaderPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onChange, accept, disabled }, ref) => {

View File

@ -1,10 +1,10 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { submitGroupEditor } from 'soapbox/actions/groups';
import { Modal, Stack } from 'soapbox/components/ui'; import { Modal, Stack } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useDebounce } from 'soapbox/hooks'; import { useDebounce } from 'soapbox/hooks';
import { useGroupValidation } from 'soapbox/hooks/api'; import { useCreateGroup, useGroupValidation, type CreateGroupParams } from 'soapbox/hooks/api';
import { type Group } from 'soapbox/schemas';
import ConfirmationStep from './steps/confirmation-step'; import ConfirmationStep from './steps/confirmation-step';
import DetailsStep from './steps/details-step'; import DetailsStep from './steps/details-step';
@ -13,7 +13,6 @@ import PrivacyStep from './steps/privacy-step';
const messages = defineMessages({ const messages = defineMessages({
next: { id: 'manage_group.next', defaultMessage: 'Next' }, next: { id: 'manage_group.next', defaultMessage: 'Next' },
create: { id: 'manage_group.create', defaultMessage: 'Create' }, create: { id: 'manage_group.create', defaultMessage: 'Create' },
update: { id: 'manage_group.update', defaultMessage: 'Update' },
done: { id: 'manage_group.done', defaultMessage: 'Done' }, done: { id: 'manage_group.done', defaultMessage: 'Done' },
}); });
@ -23,12 +22,6 @@ enum Steps {
THREE = 'THREE', THREE = 'THREE',
} }
const manageGroupSteps = {
ONE: PrivacyStep,
TWO: DetailsStep,
THREE: ConfirmationStep,
};
interface IManageGroupModal { interface IManageGroupModal {
onClose: (type?: string) => void onClose: (type?: string) => void
} }
@ -36,34 +29,26 @@ interface IManageGroupModal {
const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => { const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
const intl = useIntl(); const intl = useIntl();
const debounce = useDebounce; const debounce = useDebounce;
const dispatch = useAppDispatch();
const id = useAppSelector((state) => state.group_editor.groupId); const [group, setGroup] = useState<Group | null>(null);
const [group, setGroup] = useState<any | null>(null); const [params, setParams] = useState<CreateGroupParams>({});
const [currentStep, setCurrentStep] = useState<Steps>(Steps.ONE);
const isSubmitting = useAppSelector((state) => state.group_editor.isSubmitting); const { createGroup, isSubmitting } = useCreateGroup();
const [currentStep, setCurrentStep] = useState<Steps>(id ? Steps.TWO : Steps.ONE);
const name = useAppSelector((state) => state.group_editor.displayName);
const debouncedName = debounce(name, 300);
const debouncedName = debounce(params.display_name || '', 300);
const { data: { isValid } } = useGroupValidation(debouncedName); const { data: { isValid } } = useGroupValidation(debouncedName);
const handleClose = () => { const handleClose = () => {
onClose('MANAGE_GROUP'); onClose('MANAGE_GROUP');
}; };
const handleSubmit = () => {
return dispatch(submitGroupEditor(true));
};
const confirmationText = useMemo(() => { const confirmationText = useMemo(() => {
switch (currentStep) { switch (currentStep) {
case Steps.THREE: case Steps.THREE:
return intl.formatMessage(messages.done); return intl.formatMessage(messages.done);
case Steps.TWO: case Steps.TWO:
return intl.formatMessage(id ? messages.update : messages.create); return intl.formatMessage(messages.create);
default: default:
return intl.formatMessage(messages.next); return intl.formatMessage(messages.next);
} }
@ -75,12 +60,12 @@ const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
setCurrentStep(Steps.TWO); setCurrentStep(Steps.TWO);
break; break;
case Steps.TWO: case Steps.TWO:
handleSubmit() createGroup(params, {
.then((group) => { onSuccess(group) {
setCurrentStep(Steps.THREE); setCurrentStep(Steps.THREE);
setGroup(group); setGroup(group);
}) },
.catch(() => {}); });
break; break;
case Steps.THREE: case Steps.THREE:
handleClose(); handleClose();
@ -90,13 +75,20 @@ const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
} }
}; };
const StepToRender = manageGroupSteps[currentStep]; const renderStep = () => {
switch (currentStep) {
case Steps.ONE:
return <PrivacyStep params={params} onChange={setParams} />;
case Steps.TWO:
return <DetailsStep params={params} onChange={setParams} />;
case Steps.THREE:
return <ConfirmationStep group={group!} />;
}
};
return ( return (
<Modal <Modal
title={id title={<FormattedMessage id='navigation_bar.create_group' defaultMessage='Create Group' />}
? <FormattedMessage id='navigation_bar.edit_group' defaultMessage='Edit Group' />
: <FormattedMessage id='navigation_bar.create_group' defaultMessage='Create Group' />}
confirmationAction={handleNextStep} confirmationAction={handleNextStep}
confirmationText={confirmationText} confirmationText={confirmationText}
confirmationDisabled={isSubmitting || (currentStep === Steps.TWO && !isValid)} confirmationDisabled={isSubmitting || (currentStep === Steps.TWO && !isValid)}
@ -104,8 +96,7 @@ const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
onClose={handleClose} onClose={handleClose}
> >
<Stack space={2}> <Stack space={2}>
{/* @ts-ignore */} {renderStep()}
<StepToRender group={group} />
</Stack> </Stack>
</Modal> </Modal>
); );

View File

@ -1,162 +1,73 @@
import clsx from 'clsx'; import React from 'react';
import React, { useEffect, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { import { Form, FormGroup, Input, Textarea } from 'soapbox/components/ui';
changeGroupEditorTitle, import AvatarPicker from 'soapbox/features/group/components/group-avatar-picker';
changeGroupEditorDescription, import HeaderPicker from 'soapbox/features/group/components/group-header-picker';
changeGroupEditorMedia, import { useAppSelector, useDebounce, useInstance } from 'soapbox/hooks';
} from 'soapbox/actions/groups'; import { CreateGroupParams, useGroupValidation } from 'soapbox/hooks/api';
import { Avatar, Form, FormGroup, HStack, Icon, Input, Text, Textarea } from 'soapbox/components/ui'; import { usePreview } from 'soapbox/hooks/forms';
import { useAppDispatch, useAppSelector, useDebounce, useInstance } from 'soapbox/hooks';
import { useGroupValidation } from 'soapbox/hooks/api';
import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts';
import resizeImage from 'soapbox/utils/resize-image'; import resizeImage from 'soapbox/utils/resize-image';
import type { List as ImmutableList } from 'immutable'; import type { List as ImmutableList } from 'immutable';
interface IMediaInput {
src: string | null
accept: string
onChange: React.ChangeEventHandler<HTMLInputElement>
disabled: boolean
}
const messages = defineMessages({ const messages = defineMessages({
groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' }, groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' },
groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' }, groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' },
}); });
const HeaderPicker: React.FC<IMediaInput> = ({ src, onChange, accept, disabled }) => { interface IDetailsStep {
return ( params: CreateGroupParams
<label onChange(params: CreateGroupParams): void
className='dark:sm:shadow-inset relative h-24 w-full cursor-pointer overflow-hidden rounded-xl bg-primary-100 text-primary-500 dark:bg-gray-800 dark:text-accent-blue sm:h-44' }
>
{src && <img className='h-full w-full object-cover' src={src} alt='' />}
<HStack
className={clsx('absolute top-0 h-full w-full transition-opacity', {
'opacity-0 hover:opacity-90 bg-primary-100 dark:bg-gray-800': src,
})}
space={1.5}
alignItems='center'
justifyContent='center'
>
<Icon
src={require('@tabler/icons/photo-plus.svg')}
className='h-4.5 w-4.5'
/>
<Text size='md' theme='primary' weight='semibold'> const DetailsStep: React.FC<IDetailsStep> = ({ params, onChange }) => {
<FormattedMessage id='group.upload_banner' defaultMessage='Upload photo' />
</Text>
<input
name='header'
type='file'
accept={accept}
onChange={onChange}
disabled={disabled}
className='hidden'
/>
</HStack>
</label>
);
};
const AvatarPicker: React.FC<IMediaInput> = ({ src, onChange, accept, disabled }) => {
return (
<label className='absolute bottom-0 left-1/2 h-20 w-20 -translate-x-1/2 translate-y-1/2 cursor-pointer rounded-full bg-primary-500 ring-4 ring-white dark:ring-primary-900'>
{src && <Avatar src={src} size={80} />}
<HStack
alignItems='center'
justifyContent='center'
className={clsx('absolute left-0 top-0 h-full w-full rounded-full transition-opacity', {
'opacity-0 hover:opacity-90 bg-primary-500': src,
})}
>
<Icon
src={require('@tabler/icons/camera-plus.svg')}
className='h-5 w-5 text-white'
/>
</HStack>
<span className='sr-only'>Upload avatar</span>
<input
name='avatar'
type='file'
accept={accept}
onChange={onChange}
disabled={disabled}
className='hidden'
/>
</label>
);
};
const DetailsStep = () => {
const intl = useIntl(); const intl = useIntl();
const debounce = useDebounce; const debounce = useDebounce;
const dispatch = useAppDispatch();
const instance = useInstance(); const instance = useInstance();
const groupId = useAppSelector((state) => state.group_editor.groupId); const {
const isUploading = useAppSelector((state) => state.group_editor.isUploading); display_name: displayName = '',
const name = useAppSelector((state) => state.group_editor.displayName); note = '',
const description = useAppSelector((state) => state.group_editor.note); } = params;
const debouncedName = debounce(name, 300);
const debouncedName = debounce(displayName, 300);
const { data: { isValid, message: errorMessage } } = useGroupValidation(debouncedName); const { data: { isValid, message: errorMessage } } = useGroupValidation(debouncedName);
const [avatarSrc, setAvatarSrc] = useState<string | null>(null); const avatarSrc = usePreview(params.avatar);
const [headerSrc, setHeaderSrc] = useState<string | null>(null); const headerSrc = usePreview(params.header);
const attachmentTypes = useAppSelector( const attachmentTypes = useAppSelector(
state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>, state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>,
)?.filter(type => type.startsWith('image/')).toArray().join(','); )?.filter(type => type.startsWith('image/')).toArray().join(',');
const onChangeName: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => { const handleTextChange = (property: keyof CreateGroupParams): React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement> => {
dispatch(changeGroupEditorTitle(target.value)); return (e) => {
onChange({
...params,
[property]: e.target.value,
});
};
}; };
const onChangeDescription: React.ChangeEventHandler<HTMLTextAreaElement> = ({ target }) => { const handleImageChange = (property: keyof CreateGroupParams, maxPixels?: number): React.ChangeEventHandler<HTMLInputElement> => {
dispatch(changeGroupEditorDescription(target.value)); 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<HTMLInputElement> = 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 ( return (
<Form> <Form>
<div className='relative mb-12 flex'> <div className='relative mb-12 flex'>
<HeaderPicker src={headerSrc} accept={attachmentTypes} onChange={handleFileChange} disabled={isUploading} /> <HeaderPicker src={headerSrc} accept={attachmentTypes} onChange={handleImageChange('header', 1920 * 1080)} />
<AvatarPicker src={avatarSrc} accept={attachmentTypes} onChange={handleFileChange} disabled={isUploading} /> <AvatarPicker src={avatarSrc} accept={attachmentTypes} onChange={handleImageChange('avatar', 400 * 400)} />
</div> </div>
<FormGroup <FormGroup
@ -167,8 +78,8 @@ const DetailsStep = () => {
<Input <Input
type='text' type='text'
placeholder={intl.formatMessage(messages.groupNamePlaceholder)} placeholder={intl.formatMessage(messages.groupNamePlaceholder)}
value={name} value={displayName}
onChange={onChangeName} onChange={handleTextChange('display_name')}
maxLength={Number(instance.configuration.getIn(['groups', 'max_characters_name']))} maxLength={Number(instance.configuration.getIn(['groups', 'max_characters_name']))}
/> />
</FormGroup> </FormGroup>
@ -179,8 +90,8 @@ const DetailsStep = () => {
<Textarea <Textarea
autoComplete='off' autoComplete='off'
placeholder={intl.formatMessage(messages.groupDescriptionPlaceholder)} placeholder={intl.formatMessage(messages.groupDescriptionPlaceholder)}
value={description} value={note}
onChange={onChangeDescription} onChange={handleTextChange('note')}
maxLength={Number(instance.configuration.getIn(['groups', 'max_characters_description']))} maxLength={Number(instance.configuration.getIn(['groups', 'max_characters_description']))}
/> />
</FormGroup> </FormGroup>

View File

@ -1,18 +1,20 @@
import React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { changeGroupEditorPrivacy } from 'soapbox/actions/groups';
import List, { ListItem } from 'soapbox/components/list'; import List, { ListItem } from 'soapbox/components/list';
import { Form, FormGroup, Stack, Text } from 'soapbox/components/ui'; import { Form, FormGroup, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { type CreateGroupParams } from 'soapbox/hooks/api';
const PrivacyStep = () => { interface IPrivacyStep {
const dispatch = useAppDispatch(); params: CreateGroupParams
onChange(params: CreateGroupParams): void
}
const locked = useAppSelector((state) => state.group_editor.locked); const PrivacyStep: React.FC<IPrivacyStep> = ({ params, onChange }) => {
const visibility = params.group_visibility || 'everyone';
const onChangePrivacy = (value: boolean) => { const onChangePrivacy = (group_visibility: CreateGroupParams['group_visibility']) => {
dispatch(changeGroupEditorPrivacy(value)); onChange({ ...params, group_visibility });
}; };
return ( return (
@ -33,15 +35,15 @@ const PrivacyStep = () => {
<ListItem <ListItem
label={<Text weight='medium'><FormattedMessage id='manage_group.privacy.public.label' defaultMessage='Public' /></Text>} label={<Text weight='medium'><FormattedMessage id='manage_group.privacy.public.label' defaultMessage='Public' /></Text>}
hint={<FormattedMessage id='manage_group.privacy.public.hint' defaultMessage='Discoverable. Anyone can join.' />} hint={<FormattedMessage id='manage_group.privacy.public.hint' defaultMessage='Discoverable. Anyone can join.' />}
onSelect={() => onChangePrivacy(false)} onSelect={() => onChangePrivacy('everyone')}
isSelected={!locked} isSelected={visibility === 'everyone'}
/> />
<ListItem <ListItem
label={<Text weight='medium'><FormattedMessage id='manage_group.privacy.private.label' defaultMessage='Private (Owner approval required)' /></Text>} label={<Text weight='medium'><FormattedMessage id='manage_group.privacy.private.label' defaultMessage='Private (Owner approval required)' /></Text>}
hint={<FormattedMessage id='manage_group.privacy.private.hint' defaultMessage='Discoverable. Users can join after their request is approved.' />} hint={<FormattedMessage id='manage_group.privacy.private.hint' defaultMessage='Discoverable. Users can join after their request is approved.' />}
onSelect={() => onChangePrivacy(true)} onSelect={() => onChangePrivacy('members_only')}
isSelected={locked} isSelected={visibility === 'members_only'}
/> />
</List> </List>
</FormGroup> </FormGroup>

View File

@ -0,0 +1,33 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useCreateEntity } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { groupSchema } from 'soapbox/schemas';
interface CreateGroupParams {
display_name?: string
note?: string
avatar?: File
header?: File
group_visibility?: 'members_only' | 'everyone'
discoverable?: boolean
tags?: string[]
}
function useCreateGroup() {
const api = useApi();
const { createEntity, ...rest } = useCreateEntity([Entities.GROUPS], (params: CreateGroupParams) => {
return api.post('/api/v1/groups', params, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}, { schema: groupSchema });
return {
createGroup: createEntity,
...rest,
};
}
export { useCreateGroup, type CreateGroupParams };

View File

@ -3,6 +3,7 @@
*/ */
export { useBlockGroupMember } from './groups/useBlockGroupMember'; export { useBlockGroupMember } from './groups/useBlockGroupMember';
export { useCancelMembershipRequest } from './groups/useCancelMembershipRequest'; export { useCancelMembershipRequest } from './groups/useCancelMembershipRequest';
export { useCreateGroup, type CreateGroupParams } from './groups/useCreateGroup';
export { useDeleteGroup } from './groups/useDeleteGroup'; export { useDeleteGroup } from './groups/useDeleteGroup';
export { useDemoteGroupMember } from './groups/useDemoteGroupMember'; export { useDemoteGroupMember } from './groups/useDemoteGroupMember';
export { useGroup, useGroups } from './groups/useGroups'; export { useGroup, useGroups } from './groups/useGroups';

View File

@ -28,7 +28,6 @@ import custom_emojis from './custom-emojis';
import domain_lists from './domain-lists'; import domain_lists from './domain-lists';
import dropdown_menu from './dropdown-menu'; import dropdown_menu from './dropdown-menu';
import filters from './filters'; import filters from './filters';
import group_editor from './group-editor';
import group_memberships from './group-memberships'; import group_memberships from './group-memberships';
import group_relationships from './group-relationships'; import group_relationships from './group-relationships';
import groups from './groups'; import groups from './groups';
@ -93,7 +92,6 @@ const reducers = {
dropdown_menu, dropdown_menu,
entities, entities,
filters, filters,
group_editor,
group_memberships, group_memberships,
group_relationships, group_relationships,
groups, groups,