diff --git a/package.json b/package.json index 1405d813..1413c39b 100644 --- a/package.json +++ b/package.json @@ -34,10 +34,10 @@ "dependencies": { "@commitlint/cli": "11.0.0", "@commitlint/config-conventional": "11.0.0", - "@hookform/resolvers": "1.3.1", + "@hookform/resolvers": "1.3.2", "@sanity/components": "2.1.0", "@sanity/icons": "1.0.1", - "@sanity/ui": "0.32.1", + "@sanity/ui": "0.32.2", "@tanem/react-nprogress": "3.0.52", "commitizen": "4.2.2", "cz-conventional-changelog": "3.3.0", diff --git a/src/components/Browser/index.tsx b/src/components/Browser/index.tsx index 37e8b244..e585d742 100644 --- a/src/components/Browser/index.tsx +++ b/src/components/Browser/index.tsx @@ -32,9 +32,10 @@ const Browser: FC = (props: Props) => { // Callbacks const handleAssetUpdate = (update: MutationEvent) => { - const {documentId, result, transition, type} = update + const {documentId, result, transition} = update - if (type !== 'mutation') { + // Ignore draft updates + if (documentId.startsWith('drafts.')) { return } @@ -56,14 +57,15 @@ const Browser: FC = (props: Props) => { } const handleTagUpdate = (update: MutationEvent) => { - const {documentId, result, transition, type} = update + const {documentId, result, transition} = update - if (type !== 'mutation') { + // Ignore draft updates + if (documentId.startsWith('drafts.')) { return } if (transition === 'appear') { - dispatch(tagsListenerCreate({...result, name: result?.name?.current} as Tag)) + dispatch(tagsListenerCreate(result as Tag)) } if (transition === 'disappear') { diff --git a/src/components/DialogDetails/index.tsx b/src/components/DialogDetails/index.tsx index a55a6f80..8f0f018b 100644 --- a/src/components/DialogDetails/index.tsx +++ b/src/components/DialogDetails/index.tsx @@ -16,7 +16,7 @@ import { import {Asset} from '@types' import groq from 'groq' import client from 'part:@sanity/base/client' -import React, {FC, ReactNode, useCallback, useEffect, useState} from 'react' +import React, {FC, ReactNode, useEffect, useState} from 'react' import {useForm} from 'react-hook-form' import {useDispatch} from 'react-redux' import {AspectRatio} from 'theme-ui' @@ -47,7 +47,7 @@ const formSchema = yup.object().shape({ }) // Strip keys with empty strings, undefined or null values -const sanitiseFormData = (formData: FormData) => { +const sanitizeFormData = (formData: FormData) => { return Object.keys(formData).reduce((acc: Record, key) => { if (formData[key] !== '' && formData[key] != null) { acc[key] = formData[key] @@ -57,27 +57,81 @@ const sanitiseFormData = (formData: FormData) => { }, {}) } +const getFilenameWithoutExtension = (asset?: Asset): string | undefined => { + const extensionIndex = asset?.originalFilename?.lastIndexOf(`.${asset.extension}`) + return asset?.originalFilename?.slice(0, extensionIndex) +} + const DialogDetails: FC = (props: Props) => { const {asset, children, id} = props // State + // - Generate a snapshot of the current asset const [assetSnapshot, setAssetSnapshot] = useState(asset) const [tabSection, setTabSection] = useState<'details' | 'references'>('details') // Redux const dispatch = useDispatch() const item = useTypedSelector(state => state.assets.byIds)[asset?._id || ''] + const tagIds = useTypedSelector(state => state.tags.allIds) + const tagsByIds = useTypedSelector(state => state.tags.byIds) + + const currentAsset = item ? asset : assetSnapshot + + const allTagOptions = tagIds.reduce((acc: {label: string; value: string}[], id) => { + const tag = tagsByIds[id]?.tag + + if (tag) { + acc.push({ + label: tag?.name?.current, + value: tag?._id + }) + } + + return acc + }, []) + + // Map tag references to react-select options, skip over items with nullish labels or values + const generateTagOptions = (asset?: Asset) => { + return asset?.tags?.reduce((acc: {label: string; value: string}[], v) => { + const tag = tagsByIds[v._ref]?.tag + if (tag) { + acc.push({ + label: tag?.name?.current, + value: tag?._id + }) + } + return acc + }, []) + } + + const generateDefaultValues = (asset?: Asset) => ({ + altText: asset?.altText || '', + description: asset?.description || '', + originalFilename: asset ? getFilenameWithoutExtension(asset) : undefined, + tags: generateTagOptions(asset) || null, + title: asset?.title || '' + }) + + // Generate a string from all current tag labels + // This is used purely to determine tag updates to then update the form in real time + const currentTagLabels = generateTagOptions(currentAsset) + ?.map(tag => tag.label) + .join(',') + + const imageUrl = currentAsset ? imageDprUrl(currentAsset, 250) : undefined // react-hook-form const {control, errors, formState, handleSubmit, register, reset} = useForm({ - mode: 'all', // NOTE: this forces re-renders on all changes! + defaultValues: generateDefaultValues(asset), + mode: 'onChange', resolver: yupResolver(formSchema) }) // Callbacks - const handleClose = useCallback(() => { + const handleClose = () => { dispatch(dialogRemove(id)) - }, []) + } const handleDelete = () => { if (!asset) { @@ -91,14 +145,12 @@ const DialogDetails: FC = (props: Props) => { ) } - const handleUpdate = (update: MutationEvent) => { - const {result, transition, type} = update + const handleAssetUpdate = (update: MutationEvent) => { + const {result, transition} = update - if (result && transition === 'update' && type === 'mutation') { + if (result && transition === 'update') { + // Regenerate asset snapshot setAssetSnapshot(result as Asset) - - // Reset react-hook-form - reset() } } @@ -108,16 +160,24 @@ const DialogDetails: FC = (props: Props) => { return } - // Sanitise form data: strip nullish values - const sanitisedFormData = sanitiseFormData(formData) + // Sanitize form data: strip nullish values + const sanitizedFormData = sanitizeFormData(formData) dispatch( assetsUpdate( asset, // Form data { - ...sanitisedFormData, - originalFilename: `${sanitisedFormData.originalFilename}.${asset.extension}` // Append extension to filename + ...sanitizedFormData, + // Append extension to filename + originalFilename: `${sanitizedFormData.originalFilename}.${asset.extension}`, + // Map tags to sanity references + tags: + sanitizedFormData?.tags?.map((tag: {label: string; value: string}) => ({ + _ref: tag.value, + _type: 'reference', + _weak: true + })) || null }, // Options { @@ -128,26 +188,36 @@ const DialogDetails: FC = (props: Props) => { } // Effects - // - Fetch initial value + initialize subscriber + // - Listen for asset mutations and update snapshot useEffect(() => { - // Remember that Sanity listeners ignore joins, order clauses and projections - const QUERY = groq`*[_id == $id]` - if (!asset) { return } - // Listen for changes - const subscription = client.listen(QUERY, {id: asset._id}).subscribe(handleUpdate) + // Remember that Sanity listeners ignore joins, order clauses and projections + // - current asset + const subscriptionAsset = client + .listen(groq`*[_id == $id]`, {id: asset._id}) + .subscribe(handleAssetUpdate) return () => { - if (subscription) { - subscription.unsubscribe() - } + subscriptionAsset?.unsubscribe() } }, []) - const imageUrl = assetSnapshot ? imageDprUrl(assetSnapshot, 250) : undefined + // - Partially reset form when current tags have changed + useEffect(() => { + reset( + { + tags: generateTagOptions(currentAsset) + }, + { + errors: true, + dirtyFields: true, + isDirty: true + } + ) + }, [currentTagLabels]) const Footer = () => ( @@ -174,9 +244,6 @@ const DialogDetails: FC = (props: Props) => { ) - const extensionIndex = assetSnapshot?.originalFilename?.lastIndexOf(`.${assetSnapshot.extension}`) - const filenameWithoutExtension = assetSnapshot?.originalFilename?.slice(0, extensionIndex) - return ( } @@ -190,17 +257,17 @@ const DialogDetails: FC = (props: Props) => { {/* Image */} {imageUrl && ( - + )} {/* Metadata */} - {assetSnapshot && } + {currentAsset && } @@ -252,18 +319,18 @@ const DialogDetails: FC = (props: Props) => { error={errors?.tags} label="Tags" name="tags" - ref={register} - value={assetSnapshot?.tags} + options={allTagOptions} + value={generateTagOptions(currentAsset)} /> {/* Filename */} {/* Alt text */} = (props: Props) => { label="Alt Text" name="altText" ref={register} - value={assetSnapshot?.altText} + value={currentAsset?.altText} /> {/* Title */} = (props: Props) => { label="Title" name="title" ref={register} - value={assetSnapshot?.title} + value={currentAsset?.title} /> {/* Description */} = (props: Props) => { name="description" ref={register} rows={3} - value={assetSnapshot?.description} + value={currentAsset?.description} /> @@ -301,7 +368,7 @@ const DialogDetails: FC = (props: Props) => { hidden={tabSection !== 'references'} id="references-panel" > - {item?.asset && } + {asset && } diff --git a/src/components/FormFieldInputTags/index.tsx b/src/components/FormFieldInputTags/index.tsx index 1578a5df..71c162d2 100644 --- a/src/components/FormFieldInputTags/index.tsx +++ b/src/components/FormFieldInputTags/index.tsx @@ -1,6 +1,6 @@ import {AddIcon, ChevronDownIcon, CloseIcon} from '@sanity/icons' import {Box, Card, Flex, Text, studioTheme} from '@sanity/ui' -import React, {forwardRef} from 'react' +import React, {FC} from 'react' import {Controller, FieldError} from 'react-hook-form' import {useDispatch} from 'react-redux' import {components} from 'react-select' @@ -18,17 +18,72 @@ type Props = { error?: FieldError label: string name: string + options: { + label: string + value: string + }[] placeholder?: string - value?: string + value?: {label: string; value: string}[] } -type Ref = HTMLInputElement - const themeDarkPrimaryBlue = studioTheme?.color?.dark?.primary?.spot?.blue const themeDarkPrimaryGray = studioTheme?.color?.dark?.primary?.spot?.gray const themeRadius = studioTheme?.radius const themeSpace = studioTheme?.space +const customSelectStyles = { + control: (styles: any, {isDisabled}: {isDisabled: boolean}) => ({ + ...styles, + backgroundColor: 'transparent', + color: 'white', + border: 'none', + borderRadius: themeRadius[2], + boxShadow: 'inset 0 0 0 1px #272a2e', // TODO: use theme value + minHeight: '35px', + opacity: isDisabled ? 0.5 : 'inherit', + outline: 'none' + }), + input: (styles: any) => ({ + ...styles, + color: 'white', + marginLeft: themeSpace[2] + }), + multiValue: (styles: any) => ({ + ...styles, + backgroundColor: themeDarkPrimaryGray, + borderRadius: themeRadius[2] + }), + multiValueRemove: (styles: any) => ({ + ...styles, + '&:hover': { + background: 'transparent', + color: 'inherit' + } + }), + option: (styles: any, {isFocused}: {isFocused: boolean}) => ({ + ...styles, + backgroundColor: isFocused ? themeDarkPrimaryBlue : 'transparent', + borderRadius: themeRadius[2], + color: isFocused ? '#1f2123' : 'inherit', // TODO: use theme value + padding: '4px 6px', // TODO: use theme value + '&:hover': { + backgroundColor: themeDarkPrimaryBlue, + color: '#1f2123' // TODO: use theme value + } + }), + placeholder: (styles: any) => ({ + ...styles, + marginLeft: themeSpace[2] + }), + valueContainer: (styles: any) => ({ + ...styles, + marginBottom: themeSpace[1], + marginLeft: themeSpace[1], + marginTop: themeSpace[1], + padding: 0 + }) +} + const DropdownIndicator = (props: any) => { return ( @@ -86,32 +141,14 @@ const Option = (props: any) => { ) } -const FormFieldInputTags = forwardRef((props: Props, ref) => { - const {control, description, disabled, error, label, name, placeholder, value} = props +const FormFieldInputTags: FC = (props: Props) => { + const {control, description, disabled, error, label, name, options, placeholder, value} = props // Redux const dispatch = useDispatch() - const allIds = useTypedSelector(state => state.tags.allIds) - const byIds = useTypedSelector(state => state.tags.byIds) const creating = useTypedSelector(state => state.tags.creating) - const selectTags = allIds.map(id => { - const tag = byIds[id].tag - - return { - label: tag.name, - value: tag.name - } - }) - // Callbacks - const handleChange = (newValue: any, actionMeta: any) => { - console.group('Value Changed') - console.log(newValue) - console.log(`action: ${actionMeta.action}`) - console.groupEnd() - } - const handleCreate = (value: string) => { // Dispatch action to create new tag dispatch(tagsCreate(value)) @@ -124,84 +161,42 @@ const FormFieldInputTags = forwardRef((props: Props, ref) => { {/* Select */} ({ - ...styles, - backgroundColor: 'transparent', - color: 'white', - border: 'none', - borderRadius: themeRadius[2], - boxShadow: 'inset 0 0 0 1px #272a2e', // TODO: use theme value - minHeight: '35px', - opacity: isDisabled ? 0.5 : 'inherit', - outline: 'none' - }), - input: (styles: any) => ({ - ...styles, - color: 'white', - marginLeft: themeSpace[2] - }), - multiValue: (styles: any) => ({ - ...styles, - backgroundColor: themeDarkPrimaryGray, - borderRadius: themeRadius[2] - }), - multiValueRemove: (styles: any) => ({ - ...styles, - '&:hover': { - background: 'transparent', - color: 'inherit' - } - }), - option: (styles: any, {isFocused}: {isFocused: boolean}) => ({ - ...styles, - backgroundColor: isFocused ? themeDarkPrimaryBlue : 'transparent', - borderRadius: themeRadius[2], - color: isFocused ? '#1f2123' : 'inherit', // TODO: use theme value - padding: '4px 6px', // TODO: use theme value - '&:hover': { - backgroundColor: themeDarkPrimaryBlue, - color: '#1f2123' // TODO: use theme value - } - }), - placeholder: (styles: any) => ({ - ...styles, - marginLeft: themeSpace[2] - }), - valueContainer: (styles: any) => ({ - ...styles, - marginBottom: themeSpace[1], - marginLeft: themeSpace[1], - marginTop: themeSpace[1], - padding: 0 - }) + render={({onBlur, onChange, value: controllerValue}) => { + return ( + + ) }} /> ) -}) +} export default FormFieldInputTags diff --git a/src/modules/assets/index.ts b/src/modules/assets/index.ts index 04452b9f..e9c85cea 100644 --- a/src/modules/assets/index.ts +++ b/src/modules/assets/index.ts @@ -764,6 +764,7 @@ export const assetsFetchPageIndexEpic = ( mimeType, originalFilename, size, + tags, title, url }`, @@ -884,9 +885,7 @@ export const assetsUpdateEpic = ( return of(action).pipe( debugThrottle(state.debug.badConnection), mergeMap(() => from(client.patch(asset._id).set(formData).commit())), - mergeMap((updatedAsset: any) => { - return of(assetsUpdateComplete(updatedAsset._id, options)) - }), + mergeMap((updatedAsset: any) => of(assetsUpdateComplete(updatedAsset._id, options))), catchError(error => of(assetsUpdateError(asset, error))) ) }) diff --git a/src/modules/tags/index.ts b/src/modules/tags/index.ts index 2956bf34..dde6c287 100644 --- a/src/modules/tags/index.ts +++ b/src/modules/tags/index.ts @@ -296,7 +296,7 @@ export const tagsFetch = (): TagsFetchRequestAction => { _id, _rev, _type, - 'name': name.current + name } | order(name.current asc), } ` diff --git a/src/types/index.ts b/src/types/index.ts index 751d5f33..62a434ae 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,9 +2,10 @@ import {SanityDocument, SanityImageAssetDocument} from '@sanity/client' import {ReactElement} from 'react' export type Asset = SanityImageAssetDocument & { - altText: string - description: string - title: string + altText?: string + description?: string + tags?: SanityReference[] + title?: string } export type AssetItem = { @@ -64,6 +65,12 @@ export type Order = { field: string } +export type SanityReference = { + _ref: string + _type: 'reference' + _weak?: boolean +} + export type SearchFacetInputProps = | SearchFacetInputNumberProps | SearchFacetInputSelectProps @@ -160,7 +167,10 @@ export type Span = { } export type Tag = SanityDocument & { - name: string + name: { + _type: 'slug' + current: string + } } export type TagItem = { diff --git a/yarn.lock b/yarn.lock index fa5fc4ed..69bb14ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -381,10 +381,10 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" -"@hookform/resolvers@1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-1.3.1.tgz#129533db26f7d25d10145fe95e17367c9127e042" - integrity sha512-RrTZHRVSL1UvFWf8/HxGXzfkbYtgdauVBo4gCZ61euQZU3hk5jbWIk05/vQmEctUeUggxDcnUnVqd9yAf5z0zg== +"@hookform/resolvers@1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-1.3.2.tgz#9d747784c6d647da22bcf43d0be17f470d412182" + integrity sha512-OumtsPsGqBcsMXGUkM7IGDO55+ZmruF3u3D7eDYDK4HpYQcp4Q5QeqQtQr/BLD1wgTiB4YLvcboRXrITuJOwdA== "@juggle/resize-observer@^3.2.0": version "3.2.0" @@ -796,17 +796,17 @@ dependencies: "@types/react" "^17.0.0" -"@sanity/ui@0.32.1": - version "0.32.1" - resolved "https://registry.yarnpkg.com/@sanity/ui/-/ui-0.32.1.tgz#c037aeabf16ca9bc55a7f3fd87e53d3d78f2814e" - integrity sha512-1GlpLdZl2Za4iRIiN8rin32YlQ6ggch5QDlAIzdgnweGpxOjtosVAR+IL9zU5MVtGUvImIgGK1FB55qTrqE2hQ== +"@sanity/ui@0.32.2": + version "0.32.2" + resolved "https://registry.yarnpkg.com/@sanity/ui/-/ui-0.32.2.tgz#77b574ee54e34d9e3cebaae93ea4c7cbdec026ff" + integrity sha512-5WgHCAhqfNqfdYWDB4cN0yi4dNbeqTNiXFNDda5I2ZOy+ZJJRmkriCPxtZ0CzDMo0uDdYgzVeang7uRQ3UFt7Q== dependencies: "@juggle/resize-observer" "^3.2.0" "@popperjs/core" "^2.6.0" "@reach/auto-id" "^0.12.1" "@sanity/color" "^2.0.11" "@sanity/icons" "^1.0.1" - framer-motion "^3.1.1" + framer-motion "^3.1.3" popper-max-size-modifier "^0.2.0" react-is "^17.0.1" react-popper "^2.2.4" @@ -3126,10 +3126,10 @@ framer-motion@^2.9.5: optionalDependencies: "@emotion/is-prop-valid" "^0.8.2" -framer-motion@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-3.1.1.tgz#a8a779501213b7ce02cc35beb27621d73cc2f1e7" - integrity sha512-Gm1QSb0xUxuhcPar5FIs5Ws+STrhLZ6XZf2Io8dVwFofe1OzwkL9asGFVu7z3y6WqC4Hvnxm7wsW5SBHlxZDYw== +framer-motion@^3.1.3: + version "3.1.4" + resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-3.1.4.tgz#05b6c6c9028e8e7b1d396fcf4701d339d152a632" + integrity sha512-dVxlWwmqGwI/5k57XsjBJMOyk5jvgk/hW/VGvHsgZrETc39cmjYFYRwWXkrdqqCDobhbmFaQcZwXCy8N5yDqIw== dependencies: framesync "^5.0.0" hey-listen "^1.0.8"