diff --git a/src/api/approach.tsx b/src/api/approach.tsx index 5cd2cf2f6e..0753728fdc 100644 --- a/src/api/approach.tsx +++ b/src/api/approach.tsx @@ -5,32 +5,32 @@ import { Translate, } from '@material-ui/icons'; import React, { ReactNode } from 'react'; +import { entries, mapFromList } from '../util'; import { ProductApproach, ProductMethodology } from './schema.generated'; -export const MethodologyToApproach: Record< - ProductMethodology, - ProductApproach +export const ApproachMethodologies: Record< + ProductApproach, + ProductMethodology[] > = { - // Written - Paratext: 'Written', - OtherWritten: 'Written', - - // Oral Translation - Render: 'OralTranslation', - OtherOralTranslation: 'OralTranslation', - - // Oral Stories - BibleStories: 'OralStories', - BibleStorying: 'OralStories', - OneStory: 'OralStories', - OtherOralStories: 'OralStories', - - // Visual - Film: 'Visual', - SignLanguage: 'Visual', - OtherVisual: 'Visual', + Written: ['Paratext', 'OtherWritten'], + OralTranslation: ['Render', 'OtherOralTranslation'], + OralStories: [ + 'BibleStories', + 'BibleStorying', + 'OneStory', + 'OtherOralStories', + ], + Visual: ['Film', 'SignLanguage', 'OtherVisual'], }; +export const MethodologyToApproach = entries(ApproachMethodologies).reduce( + (map, [approach, methodologies]) => ({ + ...map, + ...mapFromList(methodologies, (methodology) => [methodology, approach]), + }), + {} +) as Record; + export const ApproachIcons: Record = { Written: , OralTranslation: , diff --git a/src/api/displayEnums.ts b/src/api/displayEnums.ts index ebdc34f992..a34d20f98a 100644 --- a/src/api/displayEnums.ts +++ b/src/api/displayEnums.ts @@ -7,11 +7,16 @@ import { ProjectStatus, Role, } from '.'; +import { ProductTypes } from '../scenes/Products/ProductForm/constants'; import { Nullable } from '../util'; +import { MethodologyToApproach } from './approach'; import { InternshipEngagementPosition, + ProductMedium, ProductMethodology, + ProductPurpose, ProjectStep, + ScriptureRangeInput, } from './schema.generated'; // Helper to display enums in a generic way @@ -40,5 +45,27 @@ export const PartnershipStatuses: PartnershipAgreementStatus[] = [ 'Signed', ]; -export const displayMethodology = displayEnum(); +export const displayMethodology = (methodology: ProductMethodology) => + methodology.includes('Other') + ? 'Other' + : displayEnum()(methodology); + export const displayApproach = displayEnum(); + +export const displayMethodologyWithLabel = (methodology: ProductMethodology) => + `${displayApproach( + MethodologyToApproach[methodology] + )} - ${displayMethodology(methodology)}`; + +export const displayScripture = ({ start, end }: ScriptureRangeInput) => + `${start.book} ${start.chapter}:${start.verse} - ${end.chapter}:${end.verse}`; + +export const displayProductMedium = (medium: ProductMedium) => + medium === 'EBook' ? 'E-Book' : displayEnum()(medium); + +export const displayProductPurpose = displayEnum(); + +export const displayProductTypes = (type: ProductTypes) => + type === 'DirectScriptureProduct' + ? 'Scripture' + : displayEnum()(type); diff --git a/src/components/EngagementBreadcrumb/EngagementBreadcrumb.graphql b/src/components/EngagementBreadcrumb/EngagementBreadcrumb.graphql new file mode 100644 index 0000000000..5073eaf20a --- /dev/null +++ b/src/components/EngagementBreadcrumb/EngagementBreadcrumb.graphql @@ -0,0 +1,13 @@ +fragment EngagementBreadcrumb on Engagement { + id + ... on LanguageEngagement { + language { + value { + name { + canRead + value + } + } + } + } +} diff --git a/src/components/EngagementBreadcrumb/EngagementBreadcrumb.tsx b/src/components/EngagementBreadcrumb/EngagementBreadcrumb.tsx new file mode 100644 index 0000000000..4e2893eeeb --- /dev/null +++ b/src/components/EngagementBreadcrumb/EngagementBreadcrumb.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { Except } from 'type-fest'; +import { Nullable } from '../../util'; +import { SecuredBreadcrumb, SecuredBreadcrumbProps } from '../Breadcrumb'; +import { EngagementBreadcrumbFragment } from './EngagementBreadcrumb.generated'; + +export interface EngagementBreadcrumbProps + extends Except, 'data'> { + data?: Nullable; + projectId?: string; +} + +export const EngagementBreadcrumb = ({ + data, + projectId, + ...rest +}: EngagementBreadcrumbProps) => ( + +); diff --git a/src/components/EngagementBreadcrumb/index.ts b/src/components/EngagementBreadcrumb/index.ts new file mode 100644 index 0000000000..d5230d5972 --- /dev/null +++ b/src/components/EngagementBreadcrumb/index.ts @@ -0,0 +1 @@ +export * from './EngagementBreadcrumb'; diff --git a/src/components/ProductCard/ProductCard.graphql b/src/components/ProductCard/ProductCard.graphql new file mode 100644 index 0000000000..07f00c0c27 --- /dev/null +++ b/src/components/ProductCard/ProductCard.graphql @@ -0,0 +1,54 @@ +fragment ProductCard on Product { + id + scriptureReferences { + canRead + canEdit + value { + start { + book + chapter + verse + } + end { + book + chapter + verse + } + } + } + mediums { + canRead + canEdit + value + } + methodology { + canRead + canEdit + value + } + ... on DerivativeScriptureProduct { + produces { + canRead + canEdit + value { + ...Producible + } + } + scriptureReferencesOverride { + canRead + canEdit + value { + start { + book + chapter + verse + } + end { + book + chapter + verse + } + } + } + } +} diff --git a/src/components/ProductCard/ProductCard.stories.tsx b/src/components/ProductCard/ProductCard.stories.tsx new file mode 100644 index 0000000000..4ef98e84df --- /dev/null +++ b/src/components/ProductCard/ProductCard.stories.tsx @@ -0,0 +1,145 @@ +import { number, object, select, text } from '@storybook/addon-knobs'; +import { DateTime } from 'luxon'; +import * as React from 'react'; +import { + newTestament, + oldTestament, +} from '../../scenes/Products/ProductForm/constants'; +import { ProductCard } from './ProductCard'; +import { ProductCardFragment } from './ProductCard.generated'; + +export default { + title: 'Components', + decorators: [ + (Story: React.FC) => ( +
+ +
+ ), + ], +}; + +const derivativeScriptureProducts = [ + 'DirectScriptureProduct', + 'DerivativeScriptureProduct', + 'Song', + 'Story', + 'Film', + 'LiteracyMaterial', +] as const; + +const getProduct = () => { + const methodologyValue = select( + 'Methodology', + [ + 'Paratext', + 'OtherWritten', + 'Render', + 'OtherOralTranslation', + 'BibleStories', + 'OneStory', + 'OtherOralStories', + 'Film', + 'SignLanguage', + 'OtherVisual', + ], + 'Paratext' + ); + + const getScriptureRange = () => { + const books = [...oldTestament, ...newTestament]; + const book = books[Math.floor(Math.random() * books.length)]; + return { + start: { + book, + chapter: Math.ceil(Math.random() * 20), + verse: Math.ceil(Math.random() * 20), + }, + end: { + book, + chapter: Math.ceil(Math.random() * 20), + verse: Math.ceil(Math.random() * 20), + }, + }; + }; + + const getScriptureRangeArray = () => { + const scriptureCount = number('Number of Scripture Ranges', 1, { + range: true, + min: 0, + max: 20, + step: 1, + }); + const scriptureRangeArray = []; + for (const _ of new Array(scriptureCount)) { + scriptureRangeArray.push(getScriptureRange()); + } + return scriptureRangeArray; + }; + + const sharedValues: Pick< + ProductCardFragment, + 'id' | 'scriptureReferences' | 'mediums' | 'methodology' + > = { + id: '0958d98477', + scriptureReferences: { + canRead: true, + canEdit: true, + value: getScriptureRangeArray(), + __typename: 'SecuredScriptureRanges', + }, + mediums: { + canRead: true, + canEdit: true, + value: object('Mediums', ['Print']), + __typename: 'SecuredProductMediums', + }, + methodology: { + canRead: true, + canEdit: true, + value: methodologyValue, + __typename: 'SecuredMethodology', + }, + }; + + const directProduct: ProductCardFragment = { + ...sharedValues, + __typename: 'DirectScriptureProduct', + }; + + const productType = select( + 'Product Type', + derivativeScriptureProducts, + 'Story' + ); + + const derivativeProduct: ProductCardFragment = { + ...sharedValues, + __typename: 'DerivativeScriptureProduct', + produces: { + canRead: true, + canEdit: true, + value: { + __typename: productType, + id: '123', + name: { + canRead: true, + canEdit: true, + value: text('Producible Name', 'My Childhood Story'), + }, + createdAt: DateTime.local(), + scriptureReferences: sharedValues.scriptureReferences, + }, + }, + scriptureReferencesOverride: { + canRead: true, + canEdit: true, + }, + }; + + return derivativeScriptureProducts.includes(productType) + ? derivativeProduct + : directProduct; +}; + +export const Product = () => ; diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 0000000000..ee196a717e --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,161 @@ +import { + Card, + CardActions, + CardContent, + makeStyles, + Typography, +} from '@material-ui/core'; +import { + AddCircle, + DescriptionOutlined, + LibraryBooksOutlined, + MenuBook, + MusicNote, + PlayCircleFilled, + SvgIconComponent, +} from '@material-ui/icons'; +import clsx from 'clsx'; +import React from 'react'; +import { + displayMethodologyWithLabel, + displayProductMedium, + displayProductTypes, +} from '../../api'; +import { ProductTypes } from '../../scenes/Products/ProductForm/constants'; +import { entries } from '../../util'; +import { + getScriptureRangeDisplay, + ScriptureRange, + scriptureRangeDictionary, +} from '../../util/biblejs'; +import { DisplaySimpleProperty } from '../DisplaySimpleProperty'; +import { ButtonLink, CardActionAreaLink } from '../Routing'; +import { ProductCardFragment } from './ProductCard.generated'; + +const useStyles = makeStyles(({ spacing }) => ({ + root: { + height: '100%', + display: 'flex', + flexDirection: 'column', + }, + actionArea: { + flex: 1, + }, + content: { + height: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + '& > *': { + margin: spacing(1, 0), + }, + }, + contentAdd: { + padding: spacing(7, 0), + justifyContent: 'center', + }, + icon: { + fontSize: 80, + }, +})); + +interface ProductCardProps { + product: ProductCardFragment; +} + +const iconMap: Record = { + DirectScriptureProduct: MenuBook, + Story: DescriptionOutlined, + Film: PlayCircleFilled, + Song: MusicNote, + LiteracyMaterial: LibraryBooksOutlined, + DerivativeScriptureProduct: DescriptionOutlined, +}; + +export const ProductCard = ({ product }: ProductCardProps) => { + const classes = useStyles(); + const type = + product.__typename === 'DerivativeScriptureProduct' + ? product.produces.value?.__typename || 'DerivativeScriptureProduct' + : product.__typename; + + const producibleName = + product.__typename === 'DerivativeScriptureProduct' && + (product.produces.value?.__typename === 'Film' + ? product.produces.value.name.value + : product.produces.value?.__typename === 'LiteracyMaterial' + ? product.produces.value.name.value + : product.produces.value?.__typename === 'Song' + ? product.produces.value.name.value + : product.produces.value?.__typename === 'Story' + ? product.produces.value.name.value + : ''); + + const Icon = type ? iconMap[type] : undefined; + + return ( + + + + {Icon && } + {type && ( + {`${displayProductTypes( + type + )}${producibleName ? ` - ${producibleName}` : ''}`} + )} + + displayProductMedium(medium)) + .join(', ')} + /> + + getScriptureRangeDisplay(scriptureRange, book) + ) + .join(', ')} + /> + + + + + Edit + + + + ); +}; + +export const AddProductCard = () => { + const classes = useStyles(); + + return ( + + + + + Add Product + + + + ); +}; diff --git a/src/components/ProductCard/index.ts b/src/components/ProductCard/index.ts new file mode 100644 index 0000000000..7ce031c382 --- /dev/null +++ b/src/components/ProductCard/index.ts @@ -0,0 +1 @@ +export * from './ProductCard'; diff --git a/src/components/form/CheckboxesField.stories.tsx b/src/components/form/CheckboxesField.stories.tsx index cab5bde2a0..2dce752653 100644 --- a/src/components/form/CheckboxesField.stories.tsx +++ b/src/components/form/CheckboxesField.stories.tsx @@ -88,6 +88,7 @@ export const ToggleButtons = () => { ['start', 'end', 'top', 'bottom'], 'end' )} + pickOne={boolean('pick one', false)} > {['red', 'blue', 'green', 'yellow'].map((color) => ( & label?: ReactNode; helperText?: ReactNode; FormGroupProps?: FormGroupProps; + pickOne?: boolean; }; export type CheckboxOptionProps = Pick< @@ -67,6 +68,7 @@ export const CheckboxesField = ({ labelPlacement = 'end', defaultValue: defaultValueProp, FormGroupProps, + pickOne, ...props }: CheckboxesFieldProps) => { // Memoize defaultValue so array can be passed inline while still preventing @@ -134,6 +136,14 @@ export const CheckboxesField = ({ return; } + if (pickOne && checked) { + input.onChange([optName]); + return; + } + if (pickOne && !checked) { + input.onChange([]); + return; + } const newVal = new Set(value); if (checked) { newVal.add(optName); diff --git a/src/components/form/RadioField.tsx b/src/components/form/RadioField.tsx index d455583a79..95209b01e9 100644 --- a/src/components/form/RadioField.tsx +++ b/src/components/form/RadioField.tsx @@ -21,7 +21,7 @@ export type RadioFieldProps = FieldConfig & { name: string; label?: string; helperText?: ReactNode; -} & Omit & +} & FormControlProps & Pick & Pick; @@ -65,12 +65,13 @@ export const RadioField = ({ helperText, labelPlacement, row, + required = true, ...props }: RadioFieldProps) => { const name = useFieldName(nameProp); const { input, meta, rest } = useField(name, { ...props, - required: true, + required, // FF expects each radio option to be its own field. // However, we want them grouped up because it works better with MUI & // you only have to specify field name, validators, etc. once. @@ -84,7 +85,7 @@ export const RadioField = ({ {...rest} component="fieldset" error={showError(meta)} - required + required={required} disabled={disabled} > {label && ( diff --git a/src/components/form/SecuredField.tsx b/src/components/form/SecuredField.tsx index b153f73215..ef271b3623 100644 --- a/src/components/form/SecuredField.tsx +++ b/src/components/form/SecuredField.tsx @@ -3,30 +3,32 @@ import { ConditionalKeys } from 'type-fest'; import { Secured } from '../../api'; import { Nullable } from '../../util'; -interface SecuredFieldRenderProps { - name: string; +export interface SecuredFieldRenderProps { + name: Name; disabled?: boolean; } +export type SecuredKeys = Extract>, string>; + /** * An experimental way to render a form field of a secured property. * @experimental */ -export const SecuredField = >>({ +export const SecuredField = >({ obj, name, children, }: { obj: Nullable; name: K; - children: (props: SecuredFieldRenderProps) => ReactElement; + children: (props: SecuredFieldRenderProps) => ReactElement; }) => { const field = obj?.[name] as Nullable>; if (field && !field.canRead) { return null; } return children({ - name: name as string, + name, disabled: obj ? !field?.canEdit : undefined, }); }; diff --git a/src/scenes/Engagement/LanguageEngagement/LanguageEngagementDetail.graphql b/src/scenes/Engagement/LanguageEngagement/LanguageEngagementDetail.graphql index 9a1e950409..034e0c5446 100644 --- a/src/scenes/Engagement/LanguageEngagement/LanguageEngagementDetail.graphql +++ b/src/scenes/Engagement/LanguageEngagement/LanguageEngagementDetail.graphql @@ -52,5 +52,12 @@ fragment LanguageEngagementDetail on LanguageEngagement { ceremony { ...CeremonyCard } + products { + canRead + canCreate + items { + ...ProductCard + } + } ...EditEngagement } diff --git a/src/scenes/Engagement/LanguageEngagement/LanguageEngagementDetail.tsx b/src/scenes/Engagement/LanguageEngagement/LanguageEngagementDetail.tsx index 970c0ef098..caf14b078f 100644 --- a/src/scenes/Engagement/LanguageEngagement/LanguageEngagementDetail.tsx +++ b/src/scenes/Engagement/LanguageEngagement/LanguageEngagementDetail.tsx @@ -23,6 +23,7 @@ import { useDateTimeFormatter, } from '../../../components/Formatters'; import { OptionsIcon, PlantIcon } from '../../../components/Icons'; +import { AddProductCard, ProductCard } from '../../../components/ProductCard'; import { ProjectBreadcrumb } from '../../../components/ProjectBreadcrumb'; import { Redacted } from '../../../components/Redacted'; import { Link } from '../../../components/Routing'; @@ -167,7 +168,7 @@ export const LanguageEngagementDetail: FC = ({ wrap={(node) => {node}} /> - + = ({ icon={PlantIcon} onClick={() => show('completeDate')} onButtonClick={() => show('completeDate')} + emptyValue="None" /> @@ -188,6 +190,7 @@ export const LanguageEngagementDetail: FC = ({ icon={OptionsIcon} onClick={() => show('disbursementCompleteDate')} onButtonClick={() => show('disbursementCompleteDate')} + emptyValue="None" /> @@ -199,6 +202,7 @@ export const LanguageEngagementDetail: FC = ({ icon={ChatOutlined} onClick={() => show('communicationsCompleteDate')} onButtonClick={() => show('communicationsCompleteDate')} + emptyValue="None" /> @@ -207,6 +211,29 @@ export const LanguageEngagementDetail: FC = ({ + + + Products + + {engagement.products.canRead ? ( + + {engagement.products.items.map((product) => ( + + + + ))} + {engagement.products.canCreate && ( + + + + )} + + ) : ( + + You don't have permission to see this engagement's products + + )} + ({ + root: { + overflowY: 'auto', + padding: spacing(4), + maxWidth: breakpoints.values.md, + '& > *': { + marginBottom: spacing(2), + }, + }, +})); + +export const CreateProduct = () => { + const classes = useStyles(); + const navigate = useNavigate(); + + const { projectId, engagementId } = useParams(); + const { enqueueSnackbar } = useSnackbar(); + + const { data, loading } = useGetProductBreadcrumbQuery({ + variables: { + projectId, + engagementId, + }, + }); + + const project = data?.project; + const engagement = data?.engagement; + + const [createProduct] = useCreateProductMutation(); + + return ( +
+ + + + Create Product + + + {loading ? : 'Create Product'} + + {!loading && ( + { + try { + const { data } = await createProduct({ + variables: { + input: { + product: { + engagementId, + ...inputs, + ...(productType !== 'DirectScriptureProduct' && produces + ? { + produces: produces.id, + scriptureReferencesOverride: scriptureReferences, + } + : { + scriptureReferences, + }), + }, + }, + }, + }); + + const { product } = data!.createProduct; + + enqueueSnackbar(`Created product`, { + variant: 'success', + action: () => ( + + Edit + + ), + }); + + navigate('../../'); + } catch (e) { + await handleFormError(e); + } + }} + /> + )} +
+ ); +}; diff --git a/src/scenes/Products/Create/index.ts b/src/scenes/Products/Create/index.ts new file mode 100644 index 0000000000..235eabe50c --- /dev/null +++ b/src/scenes/Products/Create/index.ts @@ -0,0 +1 @@ +export * from './CreateProduct'; diff --git a/src/scenes/Products/Edit/EditProduct.graphql b/src/scenes/Products/Edit/EditProduct.graphql new file mode 100644 index 0000000000..2cd8661d0e --- /dev/null +++ b/src/scenes/Products/Edit/EditProduct.graphql @@ -0,0 +1,19 @@ +mutation UpdateProduct($input: UpdateProductInput!) { + updateProduct(input: $input) { + product { + ...ProductForm + } + } +} + +query Product($productId: ID!, $projectId: ID!, $engagementId: ID!) { + product(id: $productId) { + ...ProductForm + } + project(id: $projectId) { + ...ProjectBreadcrumb + } + engagement(id: $engagementId) { + ...EngagementBreadcrumb + } +} diff --git a/src/scenes/Products/Edit/EditProduct.tsx b/src/scenes/Products/Edit/EditProduct.tsx new file mode 100644 index 0000000000..18f0ec512e --- /dev/null +++ b/src/scenes/Products/Edit/EditProduct.tsx @@ -0,0 +1,140 @@ +import { Breadcrumbs, makeStyles, Typography } from '@material-ui/core'; +import { Skeleton } from '@material-ui/lab'; +import { useSnackbar } from 'notistack'; +import React, { useMemo } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { handleFormError } from '../../../api'; +import { EngagementBreadcrumb } from '../../../components/EngagementBreadcrumb'; +import { ProjectBreadcrumb } from '../../../components/ProjectBreadcrumb'; +import { ProductForm } from '../ProductForm'; +import { ScriptureRangeFragment } from '../ProductForm/ProductForm.generated'; +import { + useProductQuery, + useUpdateProductMutation, +} from './EditProduct.generated'; + +const removeScriptureTypename = ( + scriptureReferences: readonly ScriptureRangeFragment[] +) => + scriptureReferences.map( + ({ start: { __typename, ...start }, end: { __typename: _, ...end } }) => ({ + start, + end, + }) + ); + +const useStyles = makeStyles(({ spacing, breakpoints }) => ({ + root: { + overflowY: 'auto', + padding: spacing(4), + maxWidth: breakpoints.values.md, + '& > *': { + marginBottom: spacing(2), + }, + }, +})); + +export const EditProduct = () => { + const classes = useStyles(); + const navigate = useNavigate(); + + const { projectId, engagementId, productId } = useParams(); + const { enqueueSnackbar } = useSnackbar(); + + const { data, loading } = useProductQuery({ + variables: { + projectId, + engagementId, + productId, + }, + }); + + const [updateProduct] = useUpdateProductMutation(); + + const project = data?.project; + const engagement = data?.engagement; + const product = data?.product; + + const initialValues = useMemo(() => { + if (!product) return undefined; + const { mediums, purposes, methodology, scriptureReferences } = product; + + return { + product: { + mediums: mediums.value, + purposes: purposes.value, + methodology: methodology.value, + scriptureReferences: removeScriptureTypename(scriptureReferences.value), + ...(product.__typename === 'DirectScriptureProduct' + ? { + productType: product.__typename, + } + : product.__typename === 'DerivativeScriptureProduct' && + (product.produces.value?.__typename === 'Film' || + product.produces.value?.__typename === 'Song' || + product.produces.value?.__typename === 'LiteracyMaterial' || + product.produces.value?.__typename === 'Story') + ? { + produces: { + id: product.produces.value.id, + name: product.produces.value.name, + }, + productType: product.produces.value.__typename, + } + : undefined), + }, + }; + }, [product]); + + return ( +
+ + + + Edit Product + + + {loading ? : 'Edit Product'} + + + {product && ( + { + try { + await updateProduct({ + variables: { + input: { + product: { + id: product.id, + ...input, + produces: produces?.id, + ...(productType !== 'DirectScriptureProduct' + ? { + scriptureReferencesOverride: scriptureReferences, + } + : { + scriptureReferences, + }), + }, + }, + }, + }); + + enqueueSnackbar(`Updated product`, { + variant: 'success', + }); + + navigate('../../'); + } catch (e) { + await handleFormError(e); + } + }} + initialValues={initialValues} + /> + )} +
+ ); +}; diff --git a/src/scenes/Products/Edit/index.ts b/src/scenes/Products/Edit/index.ts new file mode 100644 index 0000000000..820701f879 --- /dev/null +++ b/src/scenes/Products/Edit/index.ts @@ -0,0 +1 @@ +export * from './EditProduct'; diff --git a/src/scenes/Products/ProductForm/AccordionSection.tsx b/src/scenes/Products/ProductForm/AccordionSection.tsx new file mode 100644 index 0000000000..7fea760a23 --- /dev/null +++ b/src/scenes/Products/ProductForm/AccordionSection.tsx @@ -0,0 +1,437 @@ +import { + Accordion, + AccordionDetails, + AccordionSummary, + makeStyles, + Typography, +} from '@material-ui/core'; +import { ExpandMore } from '@material-ui/icons'; +import { ToggleButton } from '@material-ui/lab'; +import { startCase } from 'lodash'; +import React, { ComponentType, MouseEvent, ReactNode, useState } from 'react'; +import { FormRenderProps } from 'react-final-form'; +import { Except, Merge, UnionToIntersection } from 'type-fest'; +import { + ApproachMethodologies, + CreateProduct, + displayApproach, + displayMethodology, + displayMethodologyWithLabel, + displayProductMedium, + displayProductPurpose, + displayProductTypes, + ProductMedium, + ProductMediumList, + ProductPurpose, + ProductPurposeList, + UpdateProduct, +} from '../../../api'; +import { useDialog } from '../../../components/Dialog'; +import { + FieldConfig, + RadioField, + RadioOption, + SecuredField, + SecuredFieldRenderProps, + SecuredKeys, + ToggleButtonOption, + ToggleButtonsField, +} from '../../../components/form'; +import { + FilmField, + FilmLookupItem, + LiteracyMaterialField, + LiteracyMaterialLookupItem, + SongField, + SongLookupItem, + StoryField, + StoryLookupItem, +} from '../../../components/form/Lookup'; +import { entries } from '../../../util'; +import { + getScriptureRangeDisplay, + matchingScriptureRanges, + mergeScriptureRange, + ScriptureRange, + scriptureRangeDictionary, +} from '../../../util/biblejs'; +import { + newTestament, + oldTestament, + ProductTypes, + productTypes, +} from './constants'; +import { ProductFormFragment } from './ProductForm.generated'; +import { VersesDialog, versesDialogValues } from './VersesDialog'; + +const useStyles = makeStyles(({ spacing, typography }) => ({ + accordionSummary: { + flexDirection: 'column', + }, + accordionSection: { + display: 'flex', + flexDirection: 'column', + }, + section: { + '&:not(:last-child)': { + marginBottom: spacing(2), + }, + }, + label: { + fontWeight: typography.weight.bold, + }, + toggleButtonContainer: { + margin: spacing(0, -1), + }, +})); + +const productFieldMap: Partial & { name: string }> +>> = { + Film: FilmField, + Story: StoryField, + LiteracyMaterial: LiteracyMaterialField, + Song: SongField, +}; + +export interface ProductFormValues { + product: Merge< + Except, + { + productType?: ProductTypes; + produces?: + | FilmLookupItem + | StoryLookupItem + | LiteracyMaterialLookupItem + | SongLookupItem; + scriptureReferences?: readonly ScriptureRange[]; + } + >; +} + +export interface ScriptureFormValues { + book: string; + updatingScriptures: ScriptureRange[]; +} + +type Product = UnionToIntersection; + +type ProductKey = SecuredKeys; + +export const AccordionSection = ({ + values, + form, + product, + touched, +}: Except, 'handleSubmit'> & { + product?: ProductFormFragment; +}) => { + const productObj = product as Product | undefined; + const classes = useStyles(); + const isEditing = Boolean(productObj); + const { + productType, + methodology, + produces, + scriptureReferences, + mediums, + purposes, + } = (values as Partial).product ?? {}; + + const [openedSection, setOpenedSection] = useState( + isEditing ? undefined : 'produces' + ); + const accordionState = { + openedSection, + onOpen: setOpenedSection, + product: productObj, + }; + + const [scriptureForm, openScriptureForm, scriptureInitialValues] = useDialog< + ScriptureFormValues + >(); + + const openBook = (event: MouseEvent) => { + openScriptureForm({ + book: event.currentTarget.value, + updatingScriptures: matchingScriptureRanges( + event.currentTarget.value, + scriptureReferences + ), + }); + }; + + const isProducesFieldMissing = !produces && touched?.['product.produces']; + + const onVersesFieldSubmit = ({ updatingScriptures }: versesDialogValues) => { + scriptureInitialValues && + form.change( + // @ts-expect-error this is a valid field key + 'product.scriptureReferences', + mergeScriptureRange( + updatingScriptures, + scriptureReferences, + scriptureInitialValues.book + ) + ); + }; + + return ( +
+ ( + <> + {productType && ( + + {`${displayProductTypes(productType)} ${ + (productType !== 'DirectScriptureProduct' && + produces?.name.value) || + '' + }`} + + )} + {isProducesFieldMissing && ( + + Product selection required + + )} + + )} + > + {(props) => { + const productTypeField = ( + + {productTypes.map((option) => ( + + ))} + + ); + + const ProductField = productType + ? productFieldMap[productType] + : undefined; + const productField = ProductField && ( + + ); + + return ( + <> + {productTypeField} + {productField} + + ); + }} + + {/* //TODO: maybe include scriptureReferencesOverride in the name to show api field error */} + + entries(scriptureRangeDictionary(scriptureReferences)).map( + ([book, scriptureRange]) => ( + + {getScriptureRangeDisplay(scriptureRange, book)} + + ) + ) + } + > + {({ disabled }) => ( + <> +
+ Old Testament +
+ {oldTestament.map((book) => { + const matchingArr = matchingScriptureRanges( + book, + scriptureReferences + ); + return ( + + {getScriptureRangeDisplay(matchingArr, book)} + + ); + })} +
+
+ New Testament +
+ {newTestament.map((book) => { + const matchingArr = matchingScriptureRanges( + book, + scriptureReferences + ); + return ( + + {getScriptureRangeDisplay(matchingArr, book)} + + ); + })} +
+ + )} +
+ + mediums?.map((medium: ProductMedium) => ( + + {displayProductMedium(medium)} + + )) + } + > + {(props) => ( + + {ProductMediumList.map((option) => ( + + ))} + + )} + + + purposes?.map((purpose: ProductPurpose) => ( + + {displayProductPurpose(purpose)} + + )) + } + > + {(props) => ( + + {ProductPurposeList.map((option) => ( + + ))} + + )} + + + methodology && ( + + {displayMethodologyWithLabel(methodology)} + + ) + } + > + {(props) => ( + + {entries(ApproachMethodologies).map(([approach, methodologies]) => ( +
+ + {displayApproach(approach)} + + {methodologies.map((option) => ( + + ))} +
+ ))} +
+ )} +
+ {scriptureInitialValues && ( + + )} +
+ ); +}; + +const SecuredAccordion = ({ + name, + product, + openedSection, + onOpen, + title, + renderCollapsed, + children, +}: { + name: K; + product?: Product; + openedSection: ProductKey | undefined; + onOpen: (name: K | undefined) => void; + title?: ReactNode; + renderCollapsed: () => ReactNode; + children: (props: SecuredFieldRenderProps) => ReactNode; +}) => { + const classes = useStyles(); + const isOpen = openedSection === name; + + return ( + + {(fieldProps) => ( + { + onOpen(isExpanded ? name : undefined); + }} + disabled={fieldProps.disabled} + > + } + classes={{ content: classes.accordionSummary }} + > + + {isOpen && 'Choose '} + {title ?? startCase(name)} + +
+ {isOpen ? null : renderCollapsed()} +
+
+ + {children(fieldProps)} + +
+ )} +
+ ); +}; diff --git a/src/scenes/Products/ProductForm/ProductForm.graphql b/src/scenes/Products/ProductForm/ProductForm.graphql new file mode 100644 index 0000000000..669b619021 --- /dev/null +++ b/src/scenes/Products/ProductForm/ProductForm.graphql @@ -0,0 +1,107 @@ +fragment ProductForm on Product { + id + legacyType + scriptureReferences { + ...SecuredScriptureRanges + } + mediums { + canRead + canEdit + value + } + purposes { + canRead + canEdit + value + } + methodology { + canRead + canEdit + value + } + approach + legacyType + ... on DerivativeScriptureProduct { + produces { + canRead + canEdit + value { + ...Producible + } + } + scriptureReferencesOverride { + ...SecuredScriptureRangesOverride + } + } +} + +fragment Producible on Producible { + id + __typename + createdAt + ... on Film { + name { + ...ss + } + scriptureReferences { + ...SecuredScriptureRanges + } + } + ... on LiteracyMaterial { + name { + ...ss + } + scriptureReferences { + ...SecuredScriptureRanges + } + } + ... on Story { + name { + ...ss + } + scriptureReferences { + ...SecuredScriptureRanges + } + } + ... on Song { + name { + ...ss + } + scriptureReferences { + ...SecuredScriptureRanges + } + } +} + +fragment SecuredScriptureRanges on SecuredScriptureRanges { + canRead + canEdit + value { + ...ScriptureRange + } +} + +fragment SecuredScriptureRangesOverride on SecuredScriptureRangesOverride { + canRead + canEdit + value { + ...ScriptureRange + } +} + +fragment ScriptureRange on ScriptureRange { + start { + book + chapter + verse + } + end { + book + chapter + verse + } +} + +mutation DeleteProduct($productId: ID!) { + deleteProduct(id: $productId) +} diff --git a/src/scenes/Products/ProductForm/ProductForm.tsx b/src/scenes/Products/ProductForm/ProductForm.tsx new file mode 100644 index 0000000000..afdfaccb59 --- /dev/null +++ b/src/scenes/Products/ProductForm/ProductForm.tsx @@ -0,0 +1,99 @@ +import { makeStyles, Typography } from '@material-ui/core'; +import { useSnackbar } from 'notistack'; +import React from 'react'; +import { Form, FormProps } from 'react-final-form'; +import { useNavigate } from 'react-router'; +import { GQLOperations } from '../../../api'; +import { + FieldGroup, + SubmitAction, + SubmitButton, + SubmitError, +} from '../../../components/form'; +import { AccordionSection, ProductFormValues } from './AccordionSection'; +import { + ProductFormFragment, + useDeleteProductMutation, +} from './ProductForm.generated'; + +const useStyles = makeStyles(({ spacing }) => ({ + submissionBlurb: { + margin: spacing(4, 0), + width: spacing(50), + '& h4': { + marginBottom: spacing(1), + }, + }, + deleteButton: { + marginLeft: spacing(1), + }, +})); + +export const ProductForm = ({ + product, + ...props +}: FormProps & { + product?: ProductFormFragment; +}) => { + const classes = useStyles(); + + const { enqueueSnackbar } = useSnackbar(); + const navigate = useNavigate(); + const [deleteProduct] = useDeleteProductMutation({ + awaitRefetchQueries: true, + refetchQueries: [GQLOperations.Query.Engagement], + }); + + return ( + + {...props} + onSubmit={async (data, form) => { + if ((data as SubmitAction).submitAction !== 'delete') { + return await props.onSubmit(data, form); + } + if (!product) { + return; + } + + await deleteProduct({ + variables: { + productId: product.id, + }, + }); + enqueueSnackbar(`Deleted product`, { + variant: 'success', + }); + navigate('../../'); + }} + > + {({ handleSubmit, ...rest }) => ( +
+ + + + +
+ Check Your Selections + + If the selections above look good to you, go ahead and save your + Product. If you need to edit your choices, do that above. + +
+ + Save Product + + {product && ( + + Delete Product + + )} + + )} + + ); +}; diff --git a/src/scenes/Products/ProductForm/VersesDialog.tsx b/src/scenes/Products/ProductForm/VersesDialog.tsx new file mode 100644 index 0000000000..f4db12a682 --- /dev/null +++ b/src/scenes/Products/ProductForm/VersesDialog.tsx @@ -0,0 +1,59 @@ +import { makeStyles, Typography } from '@material-ui/core'; +import React, { useMemo } from 'react'; +import { Except } from 'type-fest'; +import { + DialogForm, + DialogFormProps, +} from '../../../components/Dialog/DialogForm'; +import { VersesField } from '../../../components/form/VersesField'; +import { Nullable } from '../../../util'; +import { ScriptureRange } from '../../../util/biblejs'; +import { ScriptureFormValues } from './AccordionSection'; + +const useStyles = makeStyles(({ spacing }) => ({ + dialogText: { + margin: spacing(1, 0), + }, +})); + +export interface versesDialogValues { + updatingScriptures: ScriptureRange[]; +} + +type VersesDialogProps = Except< + DialogFormProps, + 'initialValues' +> & + ScriptureFormValues & { + currentScriptureReferences: Nullable; + }; + +export const VersesDialog = ({ + book, + updatingScriptures, + currentScriptureReferences, + ...dialogProps +}: VersesDialogProps) => { + const classes = useStyles(); + + const initialValues = useMemo(() => ({ updatingScriptures }), [ + updatingScriptures, + ]); + + return ( + + {...dialogProps} + title={book} + initialValues={initialValues} + > + + Choose Your Chapters + + + When choosing chapters and verses, you can make multiple selections. + Input your wanted chapter/s and verses to create multiple selections. + + + + ); +}; diff --git a/src/scenes/Products/ProductForm/constants.ts b/src/scenes/Products/ProductForm/constants.ts new file mode 100644 index 0000000000..0f847a2096 --- /dev/null +++ b/src/scenes/Products/ProductForm/constants.ts @@ -0,0 +1,83 @@ +export const oldTestament = [ + 'Genesis', + 'Exodus', + 'Leviticus', + 'Numbers', + 'Deuteronomy', + 'Joshua', + 'Judges', + 'Ruth', + '1 Samuel', + '2 Samuel', + '1 Kings', + '2 Kings', + '1 Chronicles', + '2 Chronicles', + 'Ezra', + 'Nehemiah', + 'Esther', + 'Job', + 'Psalm', + 'Proverbs', + 'Ecclesiastes', + 'Song of Solomon', + 'Isaiah', + 'Jeremiah', + 'Lamentations', + 'Ezekiel', + 'Daniel', + 'Hosea', + 'Joel', + 'Amos', + 'Obadiah', + 'Jonah', + 'Micah', + 'Nahum', + 'Habakkuk', + 'Zephaniah', + 'Haggai', + 'Zechariah', + 'Malachi', +]; + +export const newTestament = [ + 'Matthew', + 'Mark', + 'Luke', + 'John', + 'Acts', + 'Romans', + '1 Corinthians', + '2 Corinthians', + 'Galatians', + 'Ephesians', + 'Philippians', + 'Colossians', + '1 Thessalonians', + '2 Thessalonians', + '1 Timothy', + '2 Timothy', + 'Titus', + 'Philemon', + 'Hebrews', + 'James', + '1 Peter', + '2 Peter', + '1 John', + '2 John', + '3 John', + 'Jude', + 'Revelation', +]; + +export const productTypes = [ + 'DirectScriptureProduct', + 'Story', + 'Film', + 'Song', + 'LiteracyMaterial', +] as const; + +export type ProductTypes = + | typeof productTypes[number] + | 'DerivativeScriptureProduct'; diff --git a/src/scenes/Products/ProductForm/index.ts b/src/scenes/Products/ProductForm/index.ts new file mode 100644 index 0000000000..ad116e91e1 --- /dev/null +++ b/src/scenes/Products/ProductForm/index.ts @@ -0,0 +1 @@ +export * from './ProductForm'; diff --git a/src/scenes/Projects/Projects.tsx b/src/scenes/Projects/Projects.tsx index 081ef56daa..73b59dfd49 100644 --- a/src/scenes/Projects/Projects.tsx +++ b/src/scenes/Projects/Projects.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { useRoutes } from 'react-router-dom'; import { Engagement } from '../Engagement'; import { PartnershipList } from '../Partnerships/List'; +import { CreateProduct } from '../Products/Create'; +import { EditProduct } from '../Products/Edit'; import { ProjectBudget } from './Budget'; import { ProjectFilesList } from './Files'; import { ProjectList } from './List'; @@ -42,6 +44,14 @@ export const Projects = () => { path: '/:projectId/budget', element: , }, + { + path: ':projectId/engagements/:engagementId/products/create', + element: , + }, + { + path: ':projectId/engagements/:engagementId/products/:productId', + element: , + }, ]); if (!matched) { diff --git a/src/theme/overrides.ts b/src/theme/overrides.ts index 3067c6a6f0..928a3093fb 100644 --- a/src/theme/overrides.ts +++ b/src/theme/overrides.ts @@ -101,6 +101,7 @@ export const appOverrides: ThemeOptions['overrides'] = ({ }, MuiToggleButton: { root: { + textTransform: 'none', borderRadius: 14, color: palette.text.primary, backgroundColor: palette.background.paper, diff --git a/src/util/biblejs/reference.ts b/src/util/biblejs/reference.ts index 6f213d7aa6..efe792ba75 100644 --- a/src/util/biblejs/reference.ts +++ b/src/util/biblejs/reference.ts @@ -1,3 +1,4 @@ +import { groupBy } from 'lodash'; import { books } from './bibleBooks'; import { Nullable } from '..'; @@ -199,3 +200,56 @@ class ScriptureError extends Error { this.code = code; } } + +/** + * Takes in a scripture range array and a book to match, outputs a new array with the matching ranges. + * This assumes each scripture range object is only contained to one book. + * @param bookToMatch The book to match + * @param scriptureReferenceArr Array of scripture references, can be undefined + */ +export const matchingScriptureRanges = ( + bookToMatch: string, + scriptureReferenceArr: Nullable +) => + (scriptureReferenceArr ?? []).filter( + ({ start: { book } }: ScriptureRange) => book === bookToMatch + ); + +export const getScriptureRangeDisplay = ( + scriptureReferenceArr: readonly ScriptureRange[], + book: string +) => { + const count = scriptureReferenceArr.length; + return count + ? `${book} ${formatScriptureRange(scriptureReferenceArr[0])} ${ + count > 1 ? `+ ${count - 1} more` : '' + }` + : book; +}; + +/** + * Creates a dictary from an array of scripture ranges + * Keys are bible books and the values are array of scriptureRanges that start with that book + */ +export const scriptureRangeDictionary = ( + scriptureReferenceArr: readonly ScriptureRange[] | undefined = [] +): Record => + groupBy(scriptureReferenceArr, (range) => range.start.book); + +/** + * Merge two scripture ranges together + * merging all ranges in the the updating values and the ranges in the prevScriptureReferences array that doesn't match the book + */ +export const mergeScriptureRange = ( + updatingScriptures: readonly ScriptureRange[], + prevScriptureReferences: Nullable, + book: string +): ScriptureRange[] => + prevScriptureReferences + ? [ + ...prevScriptureReferences.filter( + (scriptureRange) => scriptureRange.start.book !== book + ), + ...updatingScriptures, + ] + : [...updatingScriptures];