diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 1770092b8..e536ff25c 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -32,15 +32,18 @@ export default function Header (props: HeaderProps): JSX.Element { - We created a special  - - - Test area - -  for test driving the new edit feature. -  Learn more - + <> +
• May 2023: Photo sharing and tagging is temporarily disabled while we're upgrading our media storage.
+
+ • January 2023: Use this special  + + + Test area + +  for test driving the new edit feature Learn more +
+ + } /> diff --git a/src/components/UploadPhotoTrigger.tsx b/src/components/UploadPhotoTrigger.tsx index 21cb44e87..4c8235dab 100644 --- a/src/components/UploadPhotoTrigger.tsx +++ b/src/components/UploadPhotoTrigger.tsx @@ -87,7 +87,7 @@ export default function UploadPhotoTrigger ({ className = '', onUploaded, childr return (
{ + className={clx('pointer-events-none cursor-not-allowed', className, uploading ? 'pointer-events-none' : '')} {...getRootProps()} onClick={(e) => { if (status === 'authenticated' && !uploading) { openFileDialog() } else { diff --git a/src/components/basecamp/CreateUpdateModal.tsx b/src/components/basecamp/CreateUpdateModal.tsx new file mode 100644 index 000000000..0827c291a --- /dev/null +++ b/src/components/basecamp/CreateUpdateModal.tsx @@ -0,0 +1,29 @@ +import { MobileDialog, DialogContent } from '../ui/MobileDialog' +import { ReactElement } from 'react' + +interface CreateUpdateModalProps { + isOpen: boolean + setOpen: (arg0: boolean) => void + contentContainer: ReactElement +} + +/** + * Modal for creating and updating objects (areas, organizations, etc) in our datastores. + */ +export default function CreateUpdateModal ({ + isOpen, + setOpen, + contentContainer +}: CreateUpdateModalProps): JSX.Element { + return ( + + setOpen(false)}> + {contentContainer} + + + ) +} diff --git a/src/components/basecamp/OrganizationForm.tsx b/src/components/basecamp/OrganizationForm.tsx new file mode 100644 index 000000000..f80070f6a --- /dev/null +++ b/src/components/basecamp/OrganizationForm.tsx @@ -0,0 +1,262 @@ +import { Input, TextArea, Select } from '../ui/form' +import { useForm, FormProvider } from 'react-hook-form' +import { OrganizationType, OrgType, OrganizationEditableFieldsType, RulesType } from '../../js/types' +import clx from 'classnames' +import { graphqlClient } from '../../js/graphql/Client' +import { useMutation } from '@apollo/client' +import { useSession } from 'next-auth/react' +import { + MUTATION_ADD_ORGANIZATION, + MUTATION_UPDATE_ORGANIZATION, + QUERY_ORGANIZATIONS, + AddOrganizationProps, + UpdateOrganizationProps +} from '../../js/graphql/gql/organization' +import { toast } from 'react-toastify' +import { validate as uuidValidate } from 'uuid' + +const DISPLAY_NAME_FORM_VALIDATION_RULES: RulesType = { + required: 'A display name is required.', + minLength: { + value: 2, + message: 'Minimum 2 characters.' + } +} + +const MUUID_VALIDATION = { + validate: (value: string) => { + return conjoinedStringToArray(value).every(uuidValidate) || 'Expected comma-separated MUUID hex strings eg. 49017dad-7baf-5fde-8078-f3a4b1230bbb, 88352d11-eb85-5fde-8078-889bb1230b11...' + } +} + +interface HtmlFormProps extends OrganizationEditableFieldsType { + conjoinedAssociatedAreaIds: string // Form will return one large conjoined string + conjoinedExcludedAreaIds: string // Form will return one large conjoined string + orgId: string + orgType: OrgType +} + +interface OrganizationFormProps { + existingOrg: OrganizationType | null + onClose: () => void +} + +/* + * Form for creating and updating organzations. + * When existingOrg is null, form creates new org. + * When not null, form updates the existingOrg instead. +*/ +export default function OrganizationForm ({ existingOrg, onClose }: OrganizationFormProps): JSX.Element { + const session = useSession() + const [addOrganization] = useMutation<{ addOrganization: OrganizationType }, { input: AddOrganizationProps }>( + MUTATION_ADD_ORGANIZATION, { + client: graphqlClient, + onError: (error) => toast.error(`Unexpected error: ${error.message}`), + refetchQueries: [{ + query: QUERY_ORGANIZATIONS, + variables: { + sort: { updatedAt: -1 }, + limit: 20 + } + }] + } + ) + const [updateOrganization] = useMutation<{ updateOrganization: OrganizationType }, { input: UpdateOrganizationProps }>( + MUTATION_UPDATE_ORGANIZATION, { + client: graphqlClient, + onError: (error) => toast.error(`Unexpected error: ${error.message}`), + refetchQueries: [{ + query: QUERY_ORGANIZATIONS, + variables: { + sort: { updatedAt: -1 }, + limit: 20 + } + }] + } + ) + // React-hook-form declaration + const form = useForm({ + mode: 'onBlur', + defaultValues: { + displayName: existingOrg?.displayName ?? '', + orgType: existingOrg?.orgType ?? OrgType.LOCAL_CLIMBING_ORGANIZATION, + conjoinedAssociatedAreaIds: existingOrg?.associatedAreaIds?.join(', ') ?? '', + conjoinedExcludedAreaIds: existingOrg?.excludedAreaIds?.join(', ') ?? '', + description: existingOrg?.content?.description ?? '', + website: existingOrg?.content?.website ?? '', + email: existingOrg?.content?.email ?? '', + instagramLink: existingOrg?.content?.instagramLink ?? '', + donationLink: existingOrg?.content?.donationLink ?? '', + hardwareReportLink: existingOrg?.content?.hardwareReportLink ?? '', + facebookLink: existingOrg?.content?.facebookLink ?? '' + } + }) + + const { handleSubmit, formState: { isSubmitting, dirtyFields } } = form + + /** + * Routes to addOrganization or updateOrganization GraphQL calls + * based on whether there was an existing org to update or not. + * @param formProps Data populated by the user in the form + * @returns + */ + const submitHandler = async ({ + orgType, + displayName, + conjoinedAssociatedAreaIds, + conjoinedExcludedAreaIds, + website, + email, + donationLink, + facebookLink, + instagramLink, + hardwareReportLink, + description + }: HtmlFormProps): Promise => { + const dirtyEditableFields: OrganizationEditableFieldsType = { + ...dirtyFields?.displayName === true && { displayName }, + ...dirtyFields?.conjoinedAssociatedAreaIds === true && { associatedAreaIds: conjoinedStringToArray(conjoinedAssociatedAreaIds) }, + ...dirtyFields?.conjoinedExcludedAreaIds === true && { excludedAreaIds: conjoinedStringToArray(conjoinedExcludedAreaIds) }, + ...dirtyFields?.website === true && { website }, + ...dirtyFields?.email === true && { email }, + ...dirtyFields?.donationLink === true && { donationLink }, + ...dirtyFields?.facebookLink === true && { facebookLink }, + ...dirtyFields?.instagramLink === true && { instagramLink }, + ...dirtyFields?.hardwareReportLink === true && { hardwareReportLink }, + ...dirtyFields?.description === true && { description } + } + if (existingOrg == null) { + const input: AddOrganizationProps = { + orgType, + ...dirtyEditableFields + } + await addOrganization({ + variables: { input }, + context: { + headers: { + authorization: `Bearer ${session?.data?.accessToken as string ?? ''}` + } + } + }) + } else { + const input: UpdateOrganizationProps = { + orgId: existingOrg.orgId, + ...dirtyEditableFields + } + await updateOrganization({ + variables: { input }, + context: { + headers: { + authorization: `Bearer ${session?.data?.accessToken as string ?? ''}` + } + } + }) + } + onClose() + } + + return ( +
+
+

Organization Editor

+ {existingOrg !== null && ( // Id, OrgId, OrgTypes are immutable and only exist during updating. +
+
+

orgType

+

{existingOrg.orgType}

+
+
+

orgId

+

{existingOrg.orgId}

+
+
+ )} +
+
+ +
+ + {existingOrg == null && +