diff --git a/packages/sitecore-jss-nextjs/src/components/Link.test.tsx b/packages/sitecore-jss-nextjs/src/components/Link.test.tsx index 921c7eb942..3f295391ad 100644 --- a/packages/sitecore-jss-nextjs/src/components/Link.test.tsx +++ b/packages/sitecore-jss-nextjs/src/components/Link.test.tsx @@ -7,6 +7,7 @@ import { mount } from 'enzyme'; import { RouterContext } from 'next/dist/shared/lib/router-context.shared-runtime'; import { Link } from './Link'; import { spy } from 'sinon'; +import { describe } from 'node:test'; const Router = (): NextRouter => ({ pathname: '/', @@ -361,7 +362,7 @@ describe('', () => { expect(rendered).to.have.length(0); }); - it('should render field metadata component when metadata property is present', () => { + describe('editMode metadata', () => { const testMetadata = { contextItem: { id: '{09A07660-6834-476C-B93B-584248D3003B}', @@ -374,29 +375,165 @@ describe('', () => { rawValue: 'Test1', }; - const field = { - value: { - href: '/lorem', - text: 'ipsum', - class: 'my-link', - }, - metadata: testMetadata, - }; - - const rendered = mount( - - - - ); - - expect(rendered.html()).to.equal( - [ - `${JSON.stringify( - testMetadata - )}`, - 'ipsum', - '', - ].join('') - ); + it('should render field metadata component when metadata property is present', () => { + const field = { + value: { + href: '/lorem', + text: 'ipsum', + class: 'my-link', + }, + metadata: testMetadata, + }; + + const rendered = mount( + + + + ); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'ipsum', + '', + ].join('') + ); + }); + + it('should render default empty field component when field value href is not present', () => { + const field = { + value: { + href: undefined, + }, + metadata: testMetadata, + }; + + const rendered = mount( + + + + ); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + '[No text in field]', + '', + ].join('') + ); + }); + + it('should render default empty field component when field href is not present', () => { + const field = { + href: undefined, + metadata: testMetadata, + }; + + const rendered = mount( + + + + ); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + '[No text in field]', + '', + ].join('') + ); + }); + + it('should render custom empty field component when provided, when field value href is not present', () => { + const field = { + value: { + href: undefined, + }, + metadata: testMetadata, + }; + + const EmptyFieldEditingComponent: React.FC = () => ( + Custom Empty field value + ); + + const rendered = mount( + + + + ); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'Custom Empty field value', + '', + ].join('') + ); + }); + + it('should render custom empty field component when provided, when field href is not present', () => { + const field = { + href: undefined, + metadata: testMetadata, + }; + + const EmptyFieldEditingComponent: React.FC = () => ( + Custom Empty field value + ); + + const rendered = mount( + + + + ); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'Custom Empty field value', + '', + ].join('') + ); + }); + + it('should render nothing when field value href is not present and editing is explicitly disabled', () => { + const field = { + value: { href: undefined }, + metadata: testMetadata, + }; + + const rendered = mount( + + + + ); + + expect(rendered.html()).to.equal(''); + }); + + it('should render nothing when field href is not present and editing is explicitly disabled', () => { + const field = { + href: undefined, + metadata: testMetadata, + }; + + const rendered = mount( + + + + ); + + expect(rendered.html()).to.equal(''); + }); }); }); diff --git a/packages/sitecore-jss-nextjs/src/components/Link.tsx b/packages/sitecore-jss-nextjs/src/components/Link.tsx index 9325aba698..6307187bd4 100644 --- a/packages/sitecore-jss-nextjs/src/components/Link.tsx +++ b/packages/sitecore-jss-nextjs/src/components/Link.tsx @@ -30,7 +30,10 @@ export const Link = forwardRef( if ( !field || - (!(field as LinkFieldValue).editable && !field.value && !(field as LinkFieldValue).href) + (!(field as LinkFieldValue).editable && + !field.value && + !(field as LinkFieldValue).href && + !field.metadata) ) { return null; } @@ -38,7 +41,8 @@ export const Link = forwardRef( const value = ((field as LinkFieldValue).href ? field : (field as LinkField).value) as LinkFieldValue; - const { href, querystring, anchor } = value; + // fallback to {} if value is undefined; could happen if field is LinkFieldValue, href is empty in metadata mode + const { href, querystring, anchor } = value || {}; const isEditing = editable && ((field as LinkFieldValue).editable || (field as LinkFieldValue).metadata); diff --git a/packages/sitecore-jss-nextjs/src/components/NextImage.test.tsx b/packages/sitecore-jss-nextjs/src/components/NextImage.test.tsx index cf99249d21..e39b28c793 100644 --- a/packages/sitecore-jss-nextjs/src/components/NextImage.test.tsx +++ b/packages/sitecore-jss-nextjs/src/components/NextImage.test.tsx @@ -4,7 +4,10 @@ import chaiString from 'chai-string'; import { mount } from 'enzyme'; import React from 'react'; import { NextImage } from './NextImage'; -import { ImageField } from '@sitecore-jss/sitecore-jss-react'; +import { + ImageField, + DefaultEmptyFieldEditingComponentImage, +} from '@sitecore-jss/sitecore-jss-react'; import { ImageLoader } from 'next/image'; import { spy, match } from 'sinon'; import sinonChai from 'sinon-chai'; @@ -287,7 +290,7 @@ describe('', () => { }); }); - it('should render field metadata component when metadata property is present', () => { + describe('editMode metadata', () => { const testMetadata = { contextItem: { id: '{09A07660-6834-476C-B93B-584248D3003B}', @@ -300,21 +303,133 @@ describe('', () => { rawValue: 'Test1', }; - const field = { - value: { src: '/assets/img/test0.png', alt: 'my image' }, - metadata: testMetadata, - }; + it('should render field metadata component when metadata property is present', () => { + const field = { + value: { src: '/assets/img/test0.png', alt: 'my image' }, + metadata: testMetadata, + }; + + const rendered = mount(); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'my image', + '', + ].join('') + ); + }); + + it('should render default empty field component for Image when field value src is not present', () => { + const field = { + value: {}, + metadata: testMetadata, + }; + + const rendered = mount(); + const defaultEmptyImagePlaceholder = mount(); + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + defaultEmptyImagePlaceholder.html(), + '', + ].join('') + ); + }); + + it('should render default empty field component for Image when field src is not present', () => { + const field = { + src: undefined, + metadata: testMetadata, + }; + + const rendered = mount(); + const defaultEmptyImagePlaceholder = mount(); + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + defaultEmptyImagePlaceholder.html(), + '', + ].join('') + ); + }); + + it('should render custom empty field component when provided, when field value src is not present', () => { + const field = { + value: {}, + metadata: testMetadata, + }; + + const EmptyFieldEditingComponent: React.FC = () => ( + Custom Empty field value + ); + + const rendered = mount( + + ); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'Custom Empty field value', + '', + ].join('') + ); + }); + + it('should render custom empty field component when provided, when field src is not present', () => { + const field = { + src: undefined, + metadata: testMetadata, + }; - const rendered = mount(); - - expect(rendered.html()).to.equal( - [ - `${JSON.stringify( - testMetadata - )}`, - 'my image', - '', - ].join('') - ); + const EmptyFieldEditingComponent: React.FC = () => ( + Custom Empty field value + ); + + const rendered = mount( + + ); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'Custom Empty field value', + '', + ].join('') + ); + }); + + it('should render nothing when field value is not present, when editing is explicitly disabled', () => { + const field = { + value: {}, + metadata: testMetadata, + }; + + const rendered = mount(); + + expect(rendered.html()).to.equal(''); + }); + + it('should render nothing when field src is not present, when editing is explicitly disabled', () => { + const field = { + src: undefined, + metadata: testMetadata, + }; + + const rendered = mount(); + + expect(rendered.html()).to.equal(''); + }); }); }); diff --git a/packages/sitecore-jss-nextjs/src/components/NextImage.tsx b/packages/sitecore-jss-nextjs/src/components/NextImage.tsx index 6e0f3bbe06..96b4a31c13 100644 --- a/packages/sitecore-jss-nextjs/src/components/NextImage.tsx +++ b/packages/sitecore-jss-nextjs/src/components/NextImage.tsx @@ -1,5 +1,5 @@ import { mediaApi } from '@sitecore-jss/sitecore-jss/media'; -import PropTypes from 'prop-types'; +import PropTypes, { Requireable } from 'prop-types'; import React from 'react'; import { getEEMarkup, @@ -9,77 +9,79 @@ import { withFieldMetadata, } from '@sitecore-jss/sitecore-jss-react'; import Image, { ImageProps as NextImageProperties } from 'next/image'; +import { withEmptyFieldEditingComponent } from '@sitecore-jss/sitecore-jss-react'; +import { DefaultEmptyFieldEditingComponentImage } from '@sitecore-jss/sitecore-jss-react'; +import { isFieldValueEmpty } from '@sitecore-jss/sitecore-jss/layout'; type NextImageProps = ImageProps & Partial; - export const NextImage: React.FC = withFieldMetadata( - ({ editable = true, imageParams, field, mediaUrlPrefix, fill, priority, ...otherProps }) => { - // next handles src and we use a custom loader, - // throw error if these are present - if (otherProps.src) { - throw new Error('Detected src prop. If you wish to use src, use next/image directly.'); - } + withEmptyFieldEditingComponent( + ({ editable = true, imageParams, field, mediaUrlPrefix, fill, priority, ...otherProps }) => { + // next handles src and we use a custom loader, + // throw error if these are present + if (otherProps.src) { + throw new Error('Detected src prop. If you wish to use src, use next/image directly.'); + } - const dynamicMedia = field as ImageField | ImageFieldValue; + const dynamicMedia = field as ImageField | ImageFieldValue; - if ( - !field || - (!dynamicMedia.editable && !dynamicMedia.value && !(dynamicMedia as ImageFieldValue).src) - ) { - return null; - } + if (!field || (!dynamicMedia.editable && isFieldValueEmpty(dynamicMedia))) { + return null; + } - const imageField = dynamicMedia as ImageField; + const imageField = dynamicMedia as ImageField; - // we likely have an experience editor value, should be a string - if (editable && imageField.editable) { - return getEEMarkup( - imageField, - imageParams as { [paramName: string]: string | number }, - mediaUrlPrefix as RegExp, - otherProps as { src: string } - ); - } + // we likely have an experience editor value, should be a string + if (editable && imageField.editable) { + return getEEMarkup( + imageField, + imageParams as { [paramName: string]: string | number }, + mediaUrlPrefix as RegExp, + otherProps as { src: string } + ); + } - // some wise-guy/gal is passing in a 'raw' image object value - const img: ImageFieldValue = (dynamicMedia as ImageFieldValue).src - ? (field as ImageFieldValue) - : (dynamicMedia.value as ImageFieldValue); - if (!img) { - return null; - } + // some wise-guy/gal is passing in a 'raw' image object value + const img: ImageFieldValue = (dynamicMedia as ImageFieldValue).src + ? (field as ImageFieldValue) + : (dynamicMedia.value as ImageFieldValue); + if (!img) { + return null; + } - const attrs = { - ...img, - ...otherProps, - fill, - priority, - src: mediaApi.updateImageUrl( - img.src as string, - imageParams as { [paramName: string]: string | number }, - mediaUrlPrefix as RegExp - ), - }; + const attrs = { + ...img, + ...otherProps, + fill, + priority, + src: mediaApi.updateImageUrl( + img.src as string, + imageParams as { [paramName: string]: string | number }, + mediaUrlPrefix as RegExp + ), + }; - const imageProps = { - ...attrs, - // force replace /media with /jssmedia in src since we _know_ we will be adding a 'mw' query string parameter - // this is required for Sitecore media API resizing to work properly - src: mediaApi.replaceMediaUrlPrefix(attrs.src, mediaUrlPrefix as RegExp), - }; + const imageProps = { + ...attrs, + // force replace /media with /jssmedia in src since we _know_ we will be adding a 'mw' query string parameter + // this is required for Sitecore media API resizing to work properly + src: mediaApi.replaceMediaUrlPrefix(attrs.src, mediaUrlPrefix as RegExp), + }; - // Exclude `width`, `height` in case image is responsive, `fill` is used - if (imageProps.fill) { - delete imageProps.width; - delete imageProps.height; - } + // Exclude `width`, `height` in case image is responsive, `fill` is used + if (imageProps.fill) { + delete imageProps.width; + delete imageProps.height; + } - if (attrs) { - return ; - } + if (attrs) { + return ; + } - return null; // we can't handle the truth - } + return null; // we can't handle the truth + }, + { defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentImage } + ) ); NextImage.propTypes = { @@ -97,6 +99,10 @@ NextImage.propTypes = { imageParams: PropTypes.objectOf( PropTypes.oneOfType([PropTypes.number.isRequired, PropTypes.string.isRequired]).isRequired ), + emptyFieldEditingComponent: PropTypes.oneOfType([ + PropTypes.object as Requireable>, + PropTypes.func as Requireable>, + ]), }; NextImage.displayName = 'NextImage'; diff --git a/packages/sitecore-jss-nextjs/src/components/RichText.test.tsx b/packages/sitecore-jss-nextjs/src/components/RichText.test.tsx index f8879a7dc0..df250b149b 100644 --- a/packages/sitecore-jss-nextjs/src/components/RichText.test.tsx +++ b/packages/sitecore-jss-nextjs/src/components/RichText.test.tsx @@ -231,7 +231,7 @@ describe('RichText', () => { const link1 = links && links[0]; const link2 = links && links[1]; - expect(link1!.href).to.endWith('/testpath/t1?test=sample1'); + expect(link1!.href).to.endsWith('/testpath/t1?test=sample1'); expect(link2!.pathname).to.equal('/t2'); link1 && link1.click(); @@ -380,13 +380,7 @@ describe('RichText', () => { expect(router.prefetch).callCount(0); }); - it('should render field metadata component when metadata property is present', () => { - const app = document.createElement('main'); - - document.body.appendChild(app); - - const router = Router(); - + describe('editMode metadata', () => { const testMetadata = { contextItem: { id: '{09A07660-6834-476C-B93B-584248D3003B}', @@ -399,39 +393,132 @@ describe('RichText', () => { rawValue: 'Test1', }; - const props = { - field: { - value: ` -
-

Hello!

- 1 - 2 - Title -
`, - metadata: testMetadata, - }, - }; - - const rendered = mount( - - - , - { attachTo: app } - ); - - expect(rendered.html()).to.equal( - [ - `${JSON.stringify( - testMetadata - )}
- `, - `
-

Hello!

- 1 - 2 - Title -
`, - ].join('') - ); + it('should render field metadata component when metadata property is present', () => { + const app = document.createElement('main'); + + document.body.appendChild(app); + + const router = Router(); + + const props = { + field: { + value: ` +
+

Hello!

+ 1 + 2 + Title +
`, + metadata: testMetadata, + }, + }; + + const rendered = mount( + + + , + { attachTo: app } + ); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}
+ `, + `
+

Hello!

+ 1 + 2 + Title +
`, + ].join('') + ); + }); + + it('should render default empty field placeholder when field value is empty in edit mode metadata', () => { + const app = document.createElement('main'); + document.body.appendChild(app); + const router = Router(); + + const props = { + field: { + value: '', + metadata: testMetadata, + }, + }; + + const rendered = mount( + + + , + { attachTo: app } + ); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + '[No text in field]', + '', + ].join('') + ); + }); + + it('should render custom empty field placeholder when provided, when field value is empty in edit mode metadata', () => { + const app = document.createElement('main'); + document.body.appendChild(app); + const router = Router(); + + const props = { + field: { + value: '', + metadata: testMetadata, + }, + }; + + const EmptyFieldEditingComponent: React.FC = () => ( + Custom Empty field value + ); + + const rendered = mount( + + + , + { attachTo: app } + ); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'Custom Empty field value', + '', + ].join('') + ); + }); + + it('should render nothing when field value is empty, when editing is explicitly disabled in edit mode metadata ', () => { + const app = document.createElement('main'); + document.body.appendChild(app); + const router = Router(); + + const props = { + field: { + value: '', + metadata: testMetadata, + }, + }; + const rendered = mount( + + + , + { attachTo: app } + ); + + expect(rendered.html()).to.equal(''); + }); }); }); diff --git a/packages/sitecore-jss-nextjs/src/components/RichText.tsx b/packages/sitecore-jss-nextjs/src/components/RichText.tsx index 9e8d88a804..2357b28501 100644 --- a/packages/sitecore-jss-nextjs/src/components/RichText.tsx +++ b/packages/sitecore-jss-nextjs/src/components/RichText.tsx @@ -32,7 +32,7 @@ export const RichText = (props: RichTextProps): JSX.Element => { ...rest } = props; const hasText = props.field && props.field.value; - const isEditing = editable && props.field && props.field.editable; + const isEditing = editable && props.field && (props.field.editable || props.field.metadata); const router = useRouter(); const richTextRef = useRef(null); @@ -74,7 +74,7 @@ export const RichText = (props: RichTextProps): JSX.Element => { }); }; - return ; + return ; }; RichText.propTypes = { diff --git a/packages/sitecore-jss-nextjs/src/index.ts b/packages/sitecore-jss-nextjs/src/index.ts index 629d573594..98dde6fcf4 100644 --- a/packages/sitecore-jss-nextjs/src/index.ts +++ b/packages/sitecore-jss-nextjs/src/index.ts @@ -149,6 +149,8 @@ export { File, FileField, RichTextField, + DefaultEmptyFieldEditingComponentImage, + DefaultEmptyFieldEditingComponentText, VisitorIdentification, PlaceholderComponentProps, SitecoreContext, @@ -165,5 +167,6 @@ export { WithSitecoreContextProps, WithSitecoreContextHocProps, withFieldMetadata, + withEmptyFieldEditingComponent, EditingScripts, } from '@sitecore-jss/sitecore-jss-react'; diff --git a/packages/sitecore-jss-react/src/components/Date.test.tsx b/packages/sitecore-jss-react/src/components/Date.test.tsx index b234517d5c..d62d7f5e17 100644 --- a/packages/sitecore-jss-react/src/components/Date.test.tsx +++ b/packages/sitecore-jss-react/src/components/Date.test.tsx @@ -3,6 +3,7 @@ import { expect } from 'chai'; import { mount, shallow } from 'enzyme'; import React from 'react'; import { DateField } from './Date'; +import { describe } from 'node:test'; describe('', () => { it('should return null if no editable or value', () => { @@ -81,7 +82,7 @@ describe('', () => { expect(c.html()).equal('

11-23-2001

'); }); - it('should render field metadata component when metadata property is present', () => { + describe('editMode metadata', () => { const testMetadata = { contextItem: { id: '{09A07660-6834-476C-B93B-584248D3003B}', @@ -94,23 +95,80 @@ describe('', () => { rawValue: 'Test1', }; - const props = { - field: { - value: '23-11-2001', + it('should render field metadata component when metadata property is present', () => { + const props = { + field: { + value: '23-11-2001', + metadata: testMetadata, + }, + }; + + const rendered = mount(); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + '23-11-2001', + '', + ].join('') + ); + }); + + it('should render default empty field component when field value is empty', () => { + const field = { + value: '', metadata: testMetadata, - }, - }; + }; + + const rendered = mount(); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + '[No text in field]', + '', + ].join('') + ); + }); + + it('should render custom empty field component when provided, when field value is empty', () => { + const field = { + value: '', + metadata: testMetadata, + }; + + const EmptyFieldEditingComponent: React.FC = () => ( + Custom Empty field value + ); + + const rendered = mount( + + ); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'Custom Empty field value', + '', + ].join('') + ); + }); + + it('should render nothing when field value is empty, when editing is explicitly disabled ', () => { + const field = { + value: '', + metadata: testMetadata, + }; + + const rendered = mount(); - const rendered = mount(); - - expect(rendered.html()).to.equal( - [ - `${JSON.stringify( - testMetadata - )}`, - '23-11-2001', - '', - ].join('') - ); + expect(rendered.html()).to.equal(''); + }); }); }); diff --git a/packages/sitecore-jss-react/src/components/Date.tsx b/packages/sitecore-jss-react/src/components/Date.tsx index 116df23074..913b03cb03 100644 --- a/packages/sitecore-jss-react/src/components/Date.tsx +++ b/packages/sitecore-jss-react/src/components/Date.tsx @@ -1,59 +1,61 @@ import React from 'react'; -import PropTypes from 'prop-types'; +import PropTypes, { Requireable } from 'prop-types'; import { withFieldMetadata } from '../enhancers/withFieldMetadata'; +import { withEmptyFieldEditingComponent } from '../enhancers/withEmptyFieldEditingComponent'; +import { DefaultEmptyFieldEditingComponentText } from './DefaultEmptyFieldEditingComponents'; +import { EditableFieldProps } from './sharedTypes'; +import { FieldMetadata } from '@sitecore-jss/sitecore-jss/layout'; +import { isFieldValueEmpty } from '@sitecore-jss/sitecore-jss/layout'; -export interface DateFieldProps { +export interface DateFieldProps extends EditableFieldProps { /** The date field data. */ [htmlAttributes: string]: unknown; - field: { + field: FieldMetadata & { value?: string; editable?: string; - metadata?: { [key: string]: unknown }; }; /** * The HTML element that will wrap the contents of the field. */ tag?: string; - /** - * Can be used to explicitly disable inline editing. - * If true and `field.editable` has a value, then `field.editable` will be processed and rendered as component output. If false, `field.editable` value will be ignored and not rendered. - * @default true - */ - editable?: boolean; + render?: (date: Date | null) => React.ReactNode; } -export const DateField: React.FC = withFieldMetadata( - ({ field, tag, editable = true, render, ...otherProps }) => { - if (!field || (!field.editable && !field.value)) { - return null; - } +export const DateField: React.FC = withFieldMetadata( + withEmptyFieldEditingComponent( + ({ field, tag, editable = true, render, ...otherProps }) => { + if (!field || (!field.editable && isFieldValueEmpty(field))) { + return null; + } - let children: React.ReactNode; + let children: React.ReactNode; - const htmlProps: { - [htmlAttr: string]: unknown; - children?: React.ReactNode; - } = { - ...otherProps, - }; - - if (field.editable && editable) { - htmlProps.dangerouslySetInnerHTML = { - __html: field.editable, + const htmlProps: { + [htmlAttr: string]: unknown; + children?: React.ReactNode; + } = { + ...otherProps, }; - } else if (render) { - children = render(field.value ? new Date(field.value) : null); - } else { - children = field.value; - } - if (tag || (field.editable && editable)) { - return React.createElement(tag || 'span', htmlProps, children); - } else { - return {children}; - } - } + if (field.editable && editable) { + htmlProps.dangerouslySetInnerHTML = { + __html: field.editable, + }; + } else if (render) { + children = render(field.value ? new Date(field.value) : null); + } else { + children = field.value; + } + + if (tag || (field.editable && editable)) { + return React.createElement(tag || 'span', htmlProps, children); + } else { + return {children}; + } + }, + { defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText } + ) ); DateField.propTypes = { @@ -65,6 +67,10 @@ DateField.propTypes = { tag: PropTypes.string, editable: PropTypes.bool, render: PropTypes.func, + emptyFieldEditingComponent: PropTypes.oneOfType([ + PropTypes.object as Requireable>, + PropTypes.func as Requireable>, + ]), }; DateField.displayName = 'Date'; diff --git a/packages/sitecore-jss-react/src/components/DefaultEmptyFieldEditingComponents.tsx b/packages/sitecore-jss-react/src/components/DefaultEmptyFieldEditingComponents.tsx new file mode 100644 index 0000000000..be5e820996 --- /dev/null +++ b/packages/sitecore-jss-react/src/components/DefaultEmptyFieldEditingComponents.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +export const DefaultEmptyFieldEditingComponentText: React.FC = () => { + return [No text in field]; +}; + +export const DefaultEmptyFieldEditingComponentImage: React.FC = () => { + const inlineStyles = { + minWidth: '48px', + minHeight: '48px', + maxWidth: '400px', + maxHeight: '400px', + cursor: 'pointer', + }; + + return ( + + ); +}; diff --git a/packages/sitecore-jss-react/src/components/File.tsx b/packages/sitecore-jss-react/src/components/File.tsx index 510688b6b2..165f6b305b 100644 --- a/packages/sitecore-jss-react/src/components/File.tsx +++ b/packages/sitecore-jss-react/src/components/File.tsx @@ -1,3 +1,4 @@ +import { isFieldValueEmpty } from '@sitecore-jss/sitecore-jss/layout'; import PropTypes from 'prop-types'; import React from 'react'; @@ -27,7 +28,7 @@ export const File: React.FC = ({ field, children, ...otherProps }) => const dynamicField: FileField | FileFieldValue = field; - if (!field || (!dynamicField.value && !(dynamicField as FileFieldValue).src)) { + if (!field || isFieldValueEmpty(dynamicField)) { return null; } diff --git a/packages/sitecore-jss-react/src/components/Image.test.tsx b/packages/sitecore-jss-react/src/components/Image.test.tsx index eb212bb02b..910f10e296 100644 --- a/packages/sitecore-jss-react/src/components/Image.test.tsx +++ b/packages/sitecore-jss-react/src/components/Image.test.tsx @@ -5,6 +5,7 @@ import { mount } from 'enzyme'; import React from 'react'; import { imageField as eeImageData } from '../test-data/ee-data'; import { Image, ImageField } from './Image'; +import { DefaultEmptyFieldEditingComponentImage } from './DefaultEmptyFieldEditingComponents'; const expect = chai.use(chaiString).expect; @@ -294,7 +295,7 @@ describe('', () => { expect(rendered.find('img')).to.have.length(1); }); - it('should render field metadata component when metadata property is present', () => { + describe('editMode metadata', () => { const testMetadata = { contextItem: { id: '{09A07660-6834-476C-B93B-584248D3003B}', @@ -307,22 +308,134 @@ describe('', () => { rawValue: 'Test1', }; - const imgField = { - src: '/assets/img/test0.png', - width: 8, - height: 10, - metadata: testMetadata, - }; - const rendered = mount(); + it('should render field metadata component when metadata property is present', () => { + const imgField = { + src: '/assets/img/test0.png', + width: 8, + height: 10, + metadata: testMetadata, + }; + const rendered = mount(); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + '', + '', + ].join('') + ); + }); - expect(rendered.html()).to.equal( - [ - `${JSON.stringify( - testMetadata - )}`, - '', - '', - ].join('') - ); + it('should render default empty field component for Image when field value src is not present', () => { + const field = { + value: { src: undefined }, + metadata: testMetadata, + }; + + const rendered = mount(); + const defaultEmptyImagePlaceholder = mount(); + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + defaultEmptyImagePlaceholder.html(), + '', + ].join('') + ); + }); + + it('should render default empty field component for Image when field src is not present', () => { + const field = { + src: undefined, + metadata: testMetadata, + }; + + const rendered = mount(); + const defaultEmptyImagePlaceholder = mount(); + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + defaultEmptyImagePlaceholder.html(), + '', + ].join('') + ); + }); + + it('should render custom empty field component when provided, when field value src is not present', () => { + const field = { + value: { src: undefined }, + metadata: testMetadata, + }; + + const EmptyFieldEditingComponent: React.FC = () => ( + Custom Empty field value + ); + + const rendered = mount( + + ); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'Custom Empty field value', + '', + ].join('') + ); + }); + + it('should render custom empty field component when provided, when field src is not present', () => { + const field = { + src: undefined, + metadata: testMetadata, + }; + + const EmptyFieldEditingComponent: React.FC = () => ( + Custom Empty field value + ); + + const rendered = mount( + + ); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'Custom Empty field value', + '', + ].join('') + ); + }); + + it('should render nothing when field value src is not present, when editing is explicitly disabled', () => { + const field = { + value: { src: undefined }, + metadata: testMetadata, + }; + + const rendered = mount(); + + expect(rendered.html()).to.equal(''); + }); + + it('should render nothing when field src is not present, when editing is explicitly disabled', () => { + const field = { + src: undefined, + metadata: testMetadata, + }; + + const rendered = mount(); + + expect(rendered.html()).to.equal(''); + }); }); }); diff --git a/packages/sitecore-jss-react/src/components/Image.tsx b/packages/sitecore-jss-react/src/components/Image.tsx index ec5c8672d8..81125870b7 100644 --- a/packages/sitecore-jss-react/src/components/Image.tsx +++ b/packages/sitecore-jss-react/src/components/Image.tsx @@ -1,9 +1,14 @@ import { mediaApi } from '@sitecore-jss/sitecore-jss/media'; -import PropTypes from 'prop-types'; +import PropTypes, { Requireable } from 'prop-types'; import React from 'react'; import { addClassName, convertAttributesToReactProps } from '../utils'; import { getAttributesString } from '../utils'; import { withFieldMetadata } from '../enhancers/withFieldMetadata'; +import { withEmptyFieldEditingComponent } from '../enhancers/withEmptyFieldEditingComponent'; +import { DefaultEmptyFieldEditingComponentImage } from './DefaultEmptyFieldEditingComponents'; +import { EditableFieldProps } from './sharedTypes'; +import { FieldMetadata } from '@sitecore-jss/sitecore-jss/layout'; +import { isFieldValueEmpty } from '@sitecore-jss/sitecore-jss/layout'; export interface ImageFieldValue { [attributeName: string]: unknown; @@ -34,17 +39,10 @@ export interface ImageSizeParameters { sc?: number; } -export interface ImageProps { +export interface ImageProps extends EditableFieldProps { [attributeName: string]: unknown; /** Image field data (consistent with other field types) */ - field?: (ImageField | ImageFieldValue) & { metadata?: { [key: string]: unknown } }; - - /** - * Can be used to explicitly disable inline editing. - * If true and `media.editable` has a value, then `media.editable` will be processed - * and rendered as component output. If false, `media.editable` value will be ignored and not rendered. - */ - editable?: boolean; + field?: (ImageField | ImageFieldValue) & FieldMetadata; /** * Parameters that will be attached to Sitecore media URLs @@ -144,42 +142,42 @@ export const getEEMarkup = ( }; export const Image: React.FC = withFieldMetadata( - ({ editable = true, imageParams, field, mediaUrlPrefix, ...otherProps }) => { - const dynamicMedia = field as ImageField | ImageFieldValue; - - if ( - !field || - (!dynamicMedia.editable && !dynamicMedia.value && !(dynamicMedia as ImageFieldValue).src) - ) { - return null; - } - - const imageField = dynamicMedia as ImageField; - - if (editable && imageField.editable) { - return getEEMarkup(imageField, imageParams, mediaUrlPrefix, otherProps); - } - - // some wise-guy/gal is passing in a 'raw' image object value - const img = (dynamicMedia as ImageFieldValue).src - ? field - : (dynamicMedia.value as ImageFieldValue); - if (!img) { - return null; - } - - // prevent metadata from being passed to the img tag - if (img.metadata) { - delete img.metadata; - } - - const attrs = getImageAttrs({ ...img, ...otherProps }, imageParams, mediaUrlPrefix); - if (attrs) { - return ; - } - - return null; // we can't handle the truth - } + withEmptyFieldEditingComponent( + ({ editable = true, imageParams, field, mediaUrlPrefix, ...otherProps }) => { + const dynamicMedia = field as ImageField | ImageFieldValue; + + if (!field || (!dynamicMedia.editable && isFieldValueEmpty(dynamicMedia))) { + return null; + } + + const imageField = dynamicMedia as ImageField; + + if (editable && imageField.editable) { + return getEEMarkup(imageField, imageParams, mediaUrlPrefix, otherProps); + } + + // some wise-guy/gal is passing in a 'raw' image object value + const img = (dynamicMedia as ImageFieldValue).src + ? field + : (dynamicMedia.value as ImageFieldValue); + if (!img) { + return null; + } + + // prevent metadata from being passed to the img tag + if (img.metadata) { + delete img.metadata; + } + + const attrs = getImageAttrs({ ...img, ...otherProps }, imageParams, mediaUrlPrefix); + if (attrs) { + return ; + } + + return null; // we can't handle the truth + }, + { defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentImage } + ) ); Image.propTypes = { @@ -197,6 +195,10 @@ Image.propTypes = { imageParams: PropTypes.objectOf( PropTypes.oneOfType([PropTypes.number.isRequired, PropTypes.string.isRequired]).isRequired ), + emptyFieldEditingComponent: PropTypes.oneOfType([ + PropTypes.object as Requireable>, + PropTypes.func as Requireable>, + ]), }; Image.displayName = 'Image'; diff --git a/packages/sitecore-jss-react/src/components/Link.test.tsx b/packages/sitecore-jss-react/src/components/Link.test.tsx index e9fc1942c4..db2784e214 100644 --- a/packages/sitecore-jss-react/src/components/Link.test.tsx +++ b/packages/sitecore-jss-react/src/components/Link.test.tsx @@ -126,8 +126,7 @@ describe('', () => { const link = c.find('a'); expect(ref.current?.id).to.equal(link.props().id); }); - - it('should render field metadata component when metadata property is present', () => { + describe('editMode metadata', () => { const testMetadata = { contextItem: { id: '{09A07660-6834-476C-B93B-584248D3003B}', @@ -140,21 +139,131 @@ describe('', () => { rawValue: 'Test1', }; - const field = { - href: '/lorem', - text: 'ipsum', - metadata: testMetadata, - }; - const rendered = mount(); + it('should render field metadata component when metadata property is present', () => { + const field = { + href: '/lorem', + text: 'ipsum', + metadata: testMetadata, + }; + const rendered = mount(); - expect(rendered.html()).to.equal( - [ - `${JSON.stringify( - testMetadata - )}`, - 'ipsum', - '', - ].join('') - ); + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'ipsum', + '', + ].join('') + ); + }); + + it('should render default empty field component when field value is not present', () => { + const field = { + value: { href: undefined }, + metadata: testMetadata, + }; + const rendered = mount(); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + '[No text in field]', + '', + ].join('') + ); + }); + + it('should render default empty field component when field value href is not present', () => { + const field = { + href: undefined, + metadata: testMetadata, + }; + const rendered = mount(); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + '[No text in field]', + '', + ].join('') + ); + }); + + it('should render custom empty field component when provided, when field value is not present', () => { + const field = { + value: { href: undefined }, + metadata: testMetadata, + }; + + const EmptyFieldEditingComponent: React.FC = () => ( + Custom Empty field value + ); + + const rendered = mount( + + ); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'Custom Empty field value', + '', + ].join('') + ); + }); + + it('should render custom empty field component when provided, when field value href is not present', () => { + const field = { + href: undefined, + metadata: testMetadata, + }; + + const EmptyFieldEditingComponent: React.FC = () => ( + Custom Empty field value + ); + + const rendered = mount( + + ); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'Custom Empty field value', + '', + ].join('') + ); + }); + + it('should render nothing when field value is not present, when editing is explicitly disabled', () => { + const field = { + value: undefined, + metadata: testMetadata, + }; + + const rendered = mount(); + + expect(rendered.html()).to.equal(''); + }); + + it('should render nothing when field value href is empty, when editing is explicitly disabled', () => { + const field = { + value: { href: undefined }, + metadata: testMetadata, + }; + + const rendered = mount(); + + expect(rendered.html()).to.equal(''); + }); }); }); diff --git a/packages/sitecore-jss-react/src/components/Link.tsx b/packages/sitecore-jss-react/src/components/Link.tsx index d702f44030..75130b9ad8 100644 --- a/packages/sitecore-jss-react/src/components/Link.tsx +++ b/packages/sitecore-jss-react/src/components/Link.tsx @@ -1,6 +1,11 @@ import React, { ReactElement, RefAttributes, forwardRef } from 'react'; -import PropTypes from 'prop-types'; +import PropTypes, { Requireable } from 'prop-types'; import { withFieldMetadata } from '../enhancers/withFieldMetadata'; +import { withEmptyFieldEditingComponent } from '../enhancers/withEmptyFieldEditingComponent'; +import { DefaultEmptyFieldEditingComponentText } from './DefaultEmptyFieldEditingComponents'; +import { EditableFieldProps } from './sharedTypes'; +import { FieldMetadata } from '@sitecore-jss/sitecore-jss/layout'; +import { isFieldValueEmpty } from '@sitecore-jss/sitecore-jss/layout'; export interface LinkFieldValue { [attributeName: string]: unknown; @@ -21,16 +26,11 @@ export interface LinkField { editableLastPart?: string; } -export type LinkProps = React.AnchorHTMLAttributes & +export type LinkProps = EditableFieldProps & + React.AnchorHTMLAttributes & RefAttributes & { /** The link field data. */ - field: (LinkField | LinkFieldValue) & { metadata?: { [key: string]: unknown } }; - /** - * Can be used to explicitly disable inline editing. - * If true and `field.editable` has a value, then `field.editable` will be processed and rendered as component output. If false, `field.editable` value will be ignored and not rendered. - * @default true - */ - editable?: boolean; + field: (LinkField | LinkFieldValue) & FieldMetadata; /** * Displays a link text ('description' in Sitecore) even when children exist @@ -40,91 +40,90 @@ export type LinkProps = React.AnchorHTMLAttributes & }; export const Link: React.FC = withFieldMetadata( - // eslint-disable-next-line react/display-name - forwardRef( - ({ field, editable = true, showLinkTextWithChildrenPresent, ...otherProps }, ref) => { - const children = otherProps.children as React.ReactNode; - const dynamicField: LinkField | LinkFieldValue = field; - - if ( - !field || - (!dynamicField.editableFirstPart && - !dynamicField.value && - !(dynamicField as LinkFieldValue).href) - ) { - return null; - } - - const resultTags: ReactElement[] = []; + withEmptyFieldEditingComponent( + // eslint-disable-next-line react/display-name + forwardRef( + ({ field, editable = true, showLinkTextWithChildrenPresent, ...otherProps }, ref) => { + const children = otherProps.children as React.ReactNode; + const dynamicField: LinkField | LinkFieldValue = field; + + if (!field || (!dynamicField.editableFirstPart && isFieldValueEmpty(dynamicField))) { + return null; + } - // EXPERIENCE EDITOR RENDERING - if (editable && dynamicField.editableFirstPart) { - const markup = (dynamicField.editableFirstPart as string) + dynamicField.editableLastPart; + const resultTags: ReactElement[] = []; + + // EXPERIENCE EDITOR RENDERING + if (editable && dynamicField.editableFirstPart) { + const markup = (dynamicField.editableFirstPart as string) + dynamicField.editableLastPart; + + // in an ideal world, we'd pre-render React children here and inject them between editableFirstPart and editableLastPart. + // However, we cannot combine arbitrary unparsed HTML (innerHTML) based components with actual vDOM components (the children) + // because the innerHTML is not parsed - it'd make a discontinuous vDOM. So, we'll go for the next best compromise of rendering the link field and children separately + // as siblings. Should be "good enough" for most cases - and write your own helper if it isn't. Or bring xEditor out of 2006. + + const htmlProps = { + className: 'sc-link-wrapper', + dangerouslySetInnerHTML: { + __html: markup, + }, + ...otherProps, + key: 'editable', + }; + + // Exclude children, since 'dangerouslySetInnerHTML' and 'children' can't be set together + // and children will be added as a sibling + delete htmlProps.children; + + resultTags.push(); + + // don't render normal link tag when editing, if no children exist + // this preserves normal-ish behavior if not using a link body (no hacks required) + if (!children) { + return resultTags[0]; + } + } - // in an ideal world, we'd pre-render React children here and inject them between editableFirstPart and editableLastPart. - // However, we cannot combine arbitrary unparsed HTML (innerHTML) based components with actual vDOM components (the children) - // because the innerHTML is not parsed - it'd make a discontinuous vDOM. So, we'll go for the next best compromise of rendering the link field and children separately - // as siblings. Should be "good enough" for most cases - and write your own helper if it isn't. Or bring xEditor out of 2006. + // handle link directly on field for forgetful devs + const link = (dynamicField as LinkFieldValue).href + ? (field as LinkFieldValue) + : (dynamicField as LinkField).value; - const htmlProps = { - className: 'sc-link-wrapper', - dangerouslySetInnerHTML: { - __html: markup, - }, - ...otherProps, - key: 'editable', - }; + if (!link) { + return null; + } - // Exclude children, since 'dangerouslySetInnerHTML' and 'children' can't be set together - // and children will be added as a sibling - delete htmlProps.children; + const anchor = link.linktype !== 'anchor' && link.anchor ? `#${link.anchor}` : ''; + const querystring = link.querystring ? `?${link.querystring}` : ''; - resultTags.push(); + const anchorAttrs: { [attr: string]: unknown } = { + href: `${link.href}${querystring}${anchor}`, + className: link.class, + title: link.title, + target: link.target, + }; - // don't render normal link tag when editing, if no children exist - // this preserves normal-ish behavior if not using a link body (no hacks required) - if (!children) { - return resultTags[0]; + if (anchorAttrs.target === '_blank' && !anchorAttrs.rel) { + // information disclosure attack prevention keeps target blank site from getting ref to window.opener + anchorAttrs.rel = 'noopener noreferrer'; } - } - - // handle link directly on field for forgetful devs - const link = (dynamicField as LinkFieldValue).href - ? (field as LinkFieldValue) - : (dynamicField as LinkField).value; - - if (!link) { - return null; - } - const anchor = link.linktype !== 'anchor' && link.anchor ? `#${link.anchor}` : ''; - const querystring = link.querystring ? `?${link.querystring}` : ''; + const linkText = + showLinkTextWithChildrenPresent || !children ? link.text || link.href : null; - const anchorAttrs: { [attr: string]: unknown } = { - href: `${link.href}${querystring}${anchor}`, - className: link.class, - title: link.title, - target: link.target, - }; + resultTags.push( + React.createElement( + 'a', + { ...anchorAttrs, ...otherProps, key: 'link', ref }, + linkText, + children + ) + ); - if (anchorAttrs.target === '_blank' && !anchorAttrs.rel) { - // information disclosure attack prevention keeps target blank site from getting ref to window.opener - anchorAttrs.rel = 'noopener noreferrer'; + return {resultTags}; } - - const linkText = showLinkTextWithChildrenPresent || !children ? link.text || link.href : null; - - resultTags.push( - React.createElement( - 'a', - { ...anchorAttrs, ...otherProps, key: 'link', ref }, - linkText, - children - ) - ); - - return {resultTags}; - } + ), + { defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, isForwardRef: true } ), true ); @@ -142,6 +141,10 @@ export const LinkPropTypes = { ]).isRequired, editable: PropTypes.bool, showLinkTextWithChildrenPresent: PropTypes.bool, + emptyFieldEditingComponent: PropTypes.oneOfType([ + PropTypes.object as Requireable>, + PropTypes.func as Requireable>, + ]), }; Link.propTypes = LinkPropTypes; diff --git a/packages/sitecore-jss-react/src/components/RichText.test.tsx b/packages/sitecore-jss-react/src/components/RichText.test.tsx index 1a741ca318..fb2d36874c 100644 --- a/packages/sitecore-jss-react/src/components/RichText.test.tsx +++ b/packages/sitecore-jss-react/src/components/RichText.test.tsx @@ -4,6 +4,7 @@ import { mount } from 'enzyme'; import { RichText, RichTextField } from './RichText'; import { richTextField as eeRichTextData } from '../test-data/ee-data'; +import { describe } from 'node:test'; describe('', () => { it('should render nothing with missing field', () => { @@ -95,7 +96,7 @@ describe('', () => { expect(rendered.html()).to.contain('value'); }); - it('should render field metadata component when metadata property is present', () => { + describe('editMode metadata', () => { const testMetadata = { contextItem: { id: '{09A07660-6834-476C-B93B-584248D3003B}', @@ -108,21 +109,78 @@ describe('', () => { rawValue: 'Test1', }; - const field = { - value: 'value', - metadata: testMetadata, - }; + it('should render field metadata component when metadata property is present', () => { + const field = { + value: 'value', + metadata: testMetadata, + }; + + const rendered = mount(); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + '
value
', + '', + ].join('') + ); + }); + + it('should render default empty field component when field value is empty', () => { + const field = { + value: '', + metadata: testMetadata, + }; + + const rendered = mount(); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + '[No text in field]', + '', + ].join('') + ); + }); + + it('should render custom empty field component when provided, when field value is empty', () => { + const field = { + value: '', + metadata: testMetadata, + }; + + const EmptyFieldEditingComponent: React.FC = () => ( + Custom Empty field value + ); + + const rendered = mount( + + ); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'Custom Empty field value', + '', + ].join('') + ); + }); + + it('should render nothing when field value is empty, when editing is explicitly disabled ', () => { + const field = { + value: '', + metadata: testMetadata, + }; + + const rendered = mount(); - const rendered = mount(); - - expect(rendered.html()).to.equal( - [ - `${JSON.stringify( - testMetadata - )}`, - '
value
', - '', - ].join('') - ); + expect(rendered.html()).to.equal(''); + }); }); }); diff --git a/packages/sitecore-jss-react/src/components/RichText.tsx b/packages/sitecore-jss-react/src/components/RichText.tsx index e9c767c22f..f878b8856a 100644 --- a/packages/sitecore-jss-react/src/components/RichText.tsx +++ b/packages/sitecore-jss-react/src/components/RichText.tsx @@ -1,14 +1,17 @@ import React, { forwardRef } from 'react'; -import PropTypes from 'prop-types'; +import PropTypes, { Requireable } from 'prop-types'; import { withFieldMetadata } from '../enhancers/withFieldMetadata'; +import { withEmptyFieldEditingComponent } from '../enhancers/withEmptyFieldEditingComponent'; +import { DefaultEmptyFieldEditingComponentText } from './DefaultEmptyFieldEditingComponents'; +import { EditableFieldProps } from './sharedTypes'; +import { FieldMetadata, isFieldValueEmpty } from '@sitecore-jss/sitecore-jss/layout'; -export interface RichTextField { +export interface RichTextField extends FieldMetadata { value?: string; editable?: string; - metadata?: { [key: string]: unknown }; } -export interface RichTextProps { +export interface RichTextProps extends EditableFieldProps { [htmlAttributes: string]: unknown; /** The rich text field data. */ field?: RichTextField; @@ -17,32 +20,29 @@ export interface RichTextProps { * @default
*/ tag?: string; - /** - * Can be used to explicitly disable inline editing. - * If true and `field.editable` has a value, then `field.editable` will be processed and rendered as component output. If false, `field.editable` value will be ignored and not rendered. - * @default true - */ - editable?: boolean; } export const RichText: React.FC = withFieldMetadata( - // eslint-disable-next-line react/display-name - forwardRef( - ({ field, tag = 'div', editable = true, ...otherProps }, ref) => { - if (!field || (!field.editable && !field.value)) { - return null; - } + withEmptyFieldEditingComponent( + // eslint-disable-next-line react/display-name + forwardRef( + ({ field, tag = 'div', editable = true, ...otherProps }, ref) => { + if (!field || (!field.editable && isFieldValueEmpty(field))) { + return null; + } - const htmlProps = { - dangerouslySetInnerHTML: { - __html: field.editable && editable ? field.editable : field.value, - }, - ref, - ...otherProps, - }; + const htmlProps = { + dangerouslySetInnerHTML: { + __html: field.editable && editable ? field.editable : field.value, + }, + ref, + ...otherProps, + }; - return React.createElement(tag || 'div', htmlProps); - } + return React.createElement(tag || 'div', htmlProps); + } + ), + { defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, isForwardRef: true } ), true ); @@ -55,6 +55,10 @@ export const RichTextPropTypes = { }), tag: PropTypes.string, editable: PropTypes.bool, + emptyFieldEditingComponent: PropTypes.oneOfType([ + PropTypes.object as Requireable>, + PropTypes.func as Requireable>, + ]), }; RichText.propTypes = RichTextPropTypes; diff --git a/packages/sitecore-jss-react/src/components/Text.test.tsx b/packages/sitecore-jss-react/src/components/Text.test.tsx index 633cd1fbb9..de724563b4 100644 --- a/packages/sitecore-jss-react/src/components/Text.test.tsx +++ b/packages/sitecore-jss-react/src/components/Text.test.tsx @@ -179,7 +179,7 @@ describe('', () => { expect(rendered.html()).to.contain('value'); }); - it('should render field metadata component when metadata property is present', () => { + describe('editMode metadata', () => { const testMetadata = { contextItem: { id: '{09A07660-6834-476C-B93B-584248D3003B}', @@ -192,25 +192,78 @@ describe('', () => { rawValue: 'Test1', }; - const field = { - value: 'value', - metadata: testMetadata, - }; + it('should render field metadata component when metadata property is present', () => { + const field = { + value: 'value', + metadata: testMetadata, + }; - const rendered = mount( - -
test
-
- ); + const rendered = mount(); - expect(rendered.html()).to.equal( - [ - `${JSON.stringify( - testMetadata - )}`, - 'value', - '', - ].join('') - ); + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'value', + '', + ].join('') + ); + }); + + it('should render default empty field component when field value is empty in edit mode metadata', () => { + const field = { + value: '', + metadata: testMetadata, + }; + + const rendered = mount(); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + '[No text in field]', + '', + ].join('') + ); + }); + + it('should render custom empty field component when provided, when field value is empty in edit mode metadata', () => { + const field = { + value: '', + metadata: testMetadata, + }; + + const EmptyFieldEditingComponent: React.FC = () => ( + Custom Empty field value + ); + + const rendered = mount( + + ); + + expect(rendered.html()).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'Custom Empty field value', + '', + ].join('') + ); + }); + + it('should render nothing when field value is empty, when editing is explicitly disabled in edit mode metadata ', () => { + const field = { + value: '', + metadata: testMetadata, + }; + + const rendered = mount(); + + expect(rendered.html()).to.equal(''); + }); }); }); diff --git a/packages/sitecore-jss-react/src/components/Text.tsx b/packages/sitecore-jss-react/src/components/Text.tsx index 21f2e9b9fa..74c54307aa 100644 --- a/packages/sitecore-jss-react/src/components/Text.tsx +++ b/packages/sitecore-jss-react/src/components/Text.tsx @@ -1,14 +1,17 @@ import React, { ReactElement } from 'react'; import { withFieldMetadata } from '../enhancers/withFieldMetadata'; -import PropTypes from 'prop-types'; +import { withEmptyFieldEditingComponent } from '../enhancers/withEmptyFieldEditingComponent'; +import { DefaultEmptyFieldEditingComponentText } from './DefaultEmptyFieldEditingComponents'; +import PropTypes, { Requireable } from 'prop-types'; +import { EditableFieldProps } from './sharedTypes'; +import { FieldMetadata, isFieldValueEmpty } from '@sitecore-jss/sitecore-jss/layout'; -export interface TextField { +export interface TextField extends FieldMetadata { value?: string | number; editable?: string; - metadata?: { [key: string]: unknown }; } -export interface TextProps { +export interface TextProps extends EditableFieldProps { [htmlAttributes: string]: unknown; /** The text field data. */ field?: TextField; @@ -16,12 +19,6 @@ export interface TextProps { * The HTML element that will wrap the contents of the field. */ tag?: string; - /** - * Can be used to explicitly disable inline editing. - * If true and `field.editable` has a value, then `field.editable` will be processed and rendered as component output. If false, `field.editable` value will be ignored and not rendered. - * @default true - */ - editable?: boolean; /** * If false, HTML-encoding of the field value is disabled and the value is rendered as-is. */ @@ -29,70 +26,73 @@ export interface TextProps { } export const Text: React.FC = withFieldMetadata( - ({ field, tag, editable = true, encode = true, ...otherProps }) => { - if (!field || (!field.editable && (field.value === undefined || field.value === ''))) { - return null; - } + withEmptyFieldEditingComponent( + ({ field, tag, editable = true, encode = true, ...otherProps }) => { + if (!field || (!field.editable && isFieldValueEmpty(field))) { + return null; + } - // can't use editable value if we want to output unencoded - if (!encode) { - // eslint-disable-next-line no-param-reassign - editable = false; - } + // can't use editable value if we want to output unencoded + if (!encode) { + // eslint-disable-next-line no-param-reassign + editable = false; + } - const isEditable = field.editable && editable; + const isEditable = field.editable && editable; - let output: string | number | (ReactElement | string)[] = isEditable - ? field.editable || '' - : field.value === undefined - ? '' - : field.value; + let output: string | number | (ReactElement | string)[] = isEditable + ? field.editable || '' + : field.value === undefined + ? '' + : field.value; - // when string value isn't formatted, we should format line breaks - if (!field.editable && typeof output === 'string') { - const splitted = String(output).split('\n'); + // when string value isn't formatted, we should format line breaks + if (!field.editable && typeof output === 'string') { + const splitted = String(output).split('\n'); - if (splitted.length) { - const formatted: (ReactElement | string)[] = []; + if (splitted.length) { + const formatted: (ReactElement | string)[] = []; - splitted.forEach((str, i) => { - const isLast = i === splitted.length - 1; + splitted.forEach((str, i) => { + const isLast = i === splitted.length - 1; - formatted.push(str); + formatted.push(str); - if (!isLast) { - formatted.push(
); - } - }); + if (!isLast) { + formatted.push(
); + } + }); - output = formatted; + output = formatted; + } } - } - - const setDangerously = isEditable || !encode; - let children = null; - const htmlProps: { - [htmlAttributes: string]: unknown; - children?: React.ReactNode; - } = { - ...otherProps, - }; + const setDangerously = isEditable || !encode; - if (setDangerously) { - htmlProps.dangerouslySetInnerHTML = { - __html: output, + let children = null; + const htmlProps: { + [htmlAttributes: string]: unknown; + children?: React.ReactNode; + } = { + ...otherProps, }; - } else { - children = output; - } - - if (tag || setDangerously) { - return React.createElement(tag || 'span', htmlProps, children); - } else { - return {children}; - } - } + + if (setDangerously) { + htmlProps.dangerouslySetInnerHTML = { + __html: output, + }; + } else { + children = output; + } + + if (tag || setDangerously) { + return React.createElement(tag || 'span', htmlProps, children); + } else { + return {children}; + } + }, + { defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText } + ) ); Text.propTypes = { @@ -104,6 +104,10 @@ Text.propTypes = { tag: PropTypes.string, editable: PropTypes.bool, encode: PropTypes.bool, + emptyFieldEditingComponent: PropTypes.oneOfType([ + PropTypes.object as Requireable>, + PropTypes.func as Requireable>, + ]), }; Text.displayName = 'Text'; diff --git a/packages/sitecore-jss-react/src/components/sharedTypes.ts b/packages/sitecore-jss-react/src/components/sharedTypes.ts index 0ca7d22af6..ed9f5fe643 100644 --- a/packages/sitecore-jss-react/src/components/sharedTypes.ts +++ b/packages/sitecore-jss-react/src/components/sharedTypes.ts @@ -17,3 +17,21 @@ export type JssComponentType = ComponentType & { // react elements will not have it - so it's optional here render?: { [key: string]: unknown }; }; + +/** + * Shared editing field props + */ +export interface EditableFieldProps { + /** + * Can be used to explicitly disable inline editing. + * If true and `field.editable` has a value, then `field.editable` will be processed and rendered as component output. If false, `field.editable` value will be ignored and not rendered. + * @default true + */ + editable?: boolean; + /** + * -- Edit Mode Metadata -- + * + * Custom element to render in Pages in Metadata edit mode if field value is empty + */ + emptyFieldEditingComponent?: React.ComponentClass | React.FC; +} diff --git a/packages/sitecore-jss-react/src/enhancers/withEmptyFieldEditingComponent.test.tsx b/packages/sitecore-jss-react/src/enhancers/withEmptyFieldEditingComponent.test.tsx new file mode 100644 index 0000000000..2cd08c9c60 --- /dev/null +++ b/packages/sitecore-jss-react/src/enhancers/withEmptyFieldEditingComponent.test.tsx @@ -0,0 +1,318 @@ +/* eslint-disable no-unused-expressions */ +import React, { forwardRef } from 'react'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import { withEmptyFieldEditingComponent } from './withEmptyFieldEditingComponent'; +import { DefaultEmptyFieldEditingComponentText } from '../components/DefaultEmptyFieldEditingComponents'; +import { describe } from 'node:test'; + +describe('withEmptyFieldEditingComponent', () => { + describe('Metadata', () => { + const testMetadata = { + contextItem: { + id: '{09A07660-6834-476C-B93B-584248D3003B}', + language: 'en', + revision: 'a0b36ce0a7db49418edf90eb9621e145', + version: 1, + }, + fieldId: '{414061F4-FBB1-4591-BC37-BFFA67F745EB}', + fieldType: 'single-line', + rawValue: 'Test1', + }; + + type TestComponentProps = { + field?: { + value?: { [key: string]: string | undefined } | string; + metadata?: { [key: string]: unknown }; + src?: string; + href?: string; + }; + editable?: boolean; + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const TestComponent = (props: TestComponentProps) => { + return ( +
+

hi

+

foo

+

bar

+
+ ); + }; + + // eslint-disable-next-line react/display-name + const TestComponentWithRef = forwardRef( + (props: TestComponentProps, ref: React.ForwardedRef) => { + return ( +
+

hi

+

foo

+

bar

+
+ ); + } + ); + + it('Should render provided default empty value component component if field value is not provided', () => { + const props = { + field: { + value: '', + metadata: testMetadata, + }, + }; + + const WrappedComponent = withEmptyFieldEditingComponent(TestComponent, { + defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, + }); + + const rendered = mount(); + const expected = mount(); + + expect(rendered.html()).to.equal(expected.html()); + }); + + it('Should render custom empty value component if provided via props if field value is not provided', () => { + const EmptyFieldEditingComponent: React.FC = () => ( + Custom Empty field value + ); + + const props = { + field: { + value: '', + metadata: testMetadata, + }, + emptyFieldEditingComponent: EmptyFieldEditingComponent, + }; + + const WrappedComponent = withEmptyFieldEditingComponent(TestComponent, { + defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, + }); + + const rendered = mount(); + const expected = mount(); + + expect(rendered.html()).to.equal(expected.html()); + }); + + it('Should render component if field value is provided', () => { + const props = { + field: { + value: 'field value', + metadata: testMetadata, + }, + }; + + const WrappedComponent = withEmptyFieldEditingComponent(TestComponent, { + defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, + }); + + const rendered = mount(); + expect(rendered.html()).to.equal('

hi

foo

bar

'); + }); + + it('Should render component if component is explicitly not editable if value is empty', () => { + const props = { + field: { + value: '', + metadata: testMetadata, + }, + editable: false, + }; + + const WrappedComponent = withEmptyFieldEditingComponent(TestComponent, { + defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, + }); + + const rendered = mount(); + expect(rendered.html()).to.equal('

hi

foo

bar

'); + }); + + it('Should render component if metadata is not provided', () => { + const props = { + field: { + value: '', + }, + }; + + const WrappedComponent = withEmptyFieldEditingComponent(TestComponent, { + defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, + }); + + const rendered = mount(); + expect(rendered.html()).to.equal('

hi

foo

bar

'); + }); + + it('Should render component with forward ref if field value is provided', () => { + const props = { + field: { + value: 'field value', + metadata: testMetadata, + }, + }; + + const WrappedComponent = withEmptyFieldEditingComponent( + TestComponentWithRef, + { + defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, + isForwardRef: true, + } + ); + const ref = React.createRef(); + const rendered = mount(); + + expect(ref.current?.outerHTML).to.equal('

foo

'); + expect(rendered.html()).to.equal('

hi

foo

bar

'); + }); + + describe('Image', () => { + it('Should render component if field src is provided', () => { + const props = { + field: { + metadata: testMetadata, + src: 'img src', + }, + }; + + const WrappedComponent = withEmptyFieldEditingComponent(TestComponent, { + defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, + }); + + const rendered = mount(); + expect(rendered.html()).to.equal('

hi

foo

bar

'); + }); + + it('Should render component if field value src is provided', () => { + const props = { + field: { + metadata: testMetadata, + value: { src: 'img src' }, + }, + }; + + const WrappedComponent = withEmptyFieldEditingComponent(TestComponent, { + defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, + }); + + const rendered = mount(); + expect(rendered.html()).to.equal('

hi

foo

bar

'); + }); + + it('Should render provided default empty value component component if field value src is not provided', () => { + const props = { + field: { + value: { src: undefined }, + metadata: testMetadata, + }, + }; + + const WrappedComponent = withEmptyFieldEditingComponent(TestComponent, { + defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, + }); + + const rendered = mount(); + const expected = mount(); + + expect(rendered.html()).to.equal(expected.html()); + }); + + it('Should render custom empty value component if provided via props if field src is not provided', () => { + const EmptyFieldEditingComponent: React.FC = () => ( + Custom Empty field value + ); + + const props = { + field: { + src: undefined, + metadata: testMetadata, + }, + emptyFieldEditingComponent: EmptyFieldEditingComponent, + }; + + const WrappedComponent = withEmptyFieldEditingComponent(TestComponent, { + defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, + }); + + const rendered = mount(); + const expected = mount(); + + expect(rendered.html()).to.equal(expected.html()); + }); + }); + + describe('Link', () => { + it('Should render component if field href is provided', () => { + const props = { + field: { + metadata: testMetadata, + href: 'img src', + }, + }; + + const WrappedComponent = withEmptyFieldEditingComponent(TestComponent, { + defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, + }); + + const rendered = mount(); + expect(rendered.html()).to.equal('

hi

foo

bar

'); + }); + + it('Should render component if field value href is provided', () => { + const props = { + field: { + metadata: testMetadata, + value: { href: 'img src' }, + }, + }; + + const WrappedComponent = withEmptyFieldEditingComponent(TestComponent, { + defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, + }); + + const rendered = mount(); + expect(rendered.html()).to.equal('

hi

foo

bar

'); + }); + + it('Should render provided default empty value component component if field value href is not provided', () => { + const props = { + field: { + value: { href: undefined }, + metadata: testMetadata, + }, + }; + + const WrappedComponent = withEmptyFieldEditingComponent(TestComponent, { + defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, + }); + + const rendered = mount(); + const expected = mount(); + + expect(rendered.html()).to.equal(expected.html()); + }); + + it('Should render custom empty value component if provided via props if field href is not provided', () => { + const EmptyFieldEditingComponent: React.FC = () => ( + Custom Empty field value + ); + + const props = { + field: { + href: undefined, + metadata: testMetadata, + }, + emptyFieldEditingComponent: EmptyFieldEditingComponent, + }; + + const WrappedComponent = withEmptyFieldEditingComponent(TestComponent, { + defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, + }); + + const rendered = mount(); + const expected = mount(); + + expect(rendered.html()).to.equal(expected.html()); + }); + }); + }); +}); diff --git a/packages/sitecore-jss-react/src/enhancers/withEmptyFieldEditingComponent.tsx b/packages/sitecore-jss-react/src/enhancers/withEmptyFieldEditingComponent.tsx new file mode 100644 index 0000000000..b24dd5ddd5 --- /dev/null +++ b/packages/sitecore-jss-react/src/enhancers/withEmptyFieldEditingComponent.tsx @@ -0,0 +1,81 @@ +import React, { ComponentType, forwardRef } from 'react'; +import { + GenericFieldValue, + Field, + isFieldValueEmpty, + FieldMetadata, +} from '@sitecore-jss/sitecore-jss/layout'; + +/** + * The HOC options + * */ +export interface WithEmptyFieldEditingComponentOptions { + /** + * the default empty field component + */ + defaultEmptyFieldEditingComponent: React.FC; + /** + * 'true' if forward reference is needed + */ + isForwardRef?: boolean; +} + +/* + * represents the WithEmptyFieldEditingComponent HOC's props + */ +interface WithEmptyFieldEditingComponentProps { + // Partial type is used here because _field.value_ could be required or optional for the different field types + field?: (Partial | GenericFieldValue) & FieldMetadata; + editable?: boolean; + emptyFieldEditingComponent?: React.ComponentClass | React.FC; +} + +/** + * Returns the passed field component or default component in case field value is empty and edit mode is 'metadata' + * @param {ComponentType} FieldComponent the field component + * @param {WithEmptyFieldEditingComponentProps} options the options of the HOC; + */ +export function withEmptyFieldEditingComponent< + FieldComponentProps extends WithEmptyFieldEditingComponentProps, + RefElementType = HTMLElement +>( + FieldComponent: ComponentType, + options: WithEmptyFieldEditingComponentOptions +) { + const getEmptyFieldEditingComponent = ( + props: FieldComponentProps + ): React.ComponentClass | React.FC => { + const { editable = true } = props; + if (props.field?.metadata && editable && isFieldValueEmpty(props.field)) { + return props.emptyFieldEditingComponent || options.defaultEmptyFieldEditingComponent; + } + + return null; + }; + + if (options.isForwardRef) { + // eslint-disable-next-line react/display-name + return forwardRef((props: FieldComponentProps, ref: React.ForwardedRef) => { + const EmptyFieldEditingComponent = getEmptyFieldEditingComponent(props); + return ( + <> + {(EmptyFieldEditingComponent && ) || ( + + )} + + ); + }); + } + + // eslint-disable-next-line react/display-name + return (props: FieldComponentProps) => { + const EmptyFieldEditingComponent = getEmptyFieldEditingComponent(props); + return ( + <> + {(EmptyFieldEditingComponent && ) || ( + + )} + + ); + }; +} diff --git a/packages/sitecore-jss-react/src/index.ts b/packages/sitecore-jss-react/src/index.ts index f9ff82f176..d11283ee90 100644 --- a/packages/sitecore-jss-react/src/index.ts +++ b/packages/sitecore-jss-react/src/index.ts @@ -103,4 +103,9 @@ export { withDatasourceCheck } from './enhancers/withDatasourceCheck'; export { EditFrameProps, EditFrame } from './components/EditFrame'; export { ComponentBuilder, ComponentBuilderConfig } from './ComponentBuilder'; export { withFieldMetadata } from './enhancers/withFieldMetadata'; +export { withEmptyFieldEditingComponent } from './enhancers/withEmptyFieldEditingComponent'; export { EditingScripts } from './components/EditingScripts'; +export { + DefaultEmptyFieldEditingComponentText, + DefaultEmptyFieldEditingComponentImage, +} from './components/DefaultEmptyFieldEditingComponents'; diff --git a/packages/sitecore-jss/src/layout/index.ts b/packages/sitecore-jss/src/layout/index.ts index bea0c1b392..17c2d61c3b 100644 --- a/packages/sitecore-jss/src/layout/index.ts +++ b/packages/sitecore-jss/src/layout/index.ts @@ -9,14 +9,16 @@ export { ComponentRendering, HtmlElementRendering, Field, + GenericFieldValue, Item, PlaceholdersData, ComponentFields, ComponentParams, EditMode, + FieldMetadata, } from './models'; -export { getFieldValue, getChildPlaceholder } from './utils'; +export { getFieldValue, getChildPlaceholder, isFieldValueEmpty } from './utils'; export { getContentStylesheetLink } from './content-styles'; diff --git a/packages/sitecore-jss/src/layout/models.ts b/packages/sitecore-jss/src/layout/models.ts index eae3853605..3f60e02081 100644 --- a/packages/sitecore-jss/src/layout/models.ts +++ b/packages/sitecore-jss/src/layout/models.ts @@ -124,11 +124,18 @@ export type GenericFieldValue = | { [key: string]: unknown } | Array<{ [key: string]: unknown }>; -export interface Field { +export interface Field extends FieldMetadata { value: T; editable?: string; } +/** + * represents the field metadata provided by layout service in editMode 'metadata' + */ +export interface FieldMetadata { + metadata?: { [key: string]: unknown }; +} + /** * Content data returned from Content Service */ diff --git a/packages/sitecore-jss/src/layout/utils.test.ts b/packages/sitecore-jss/src/layout/utils.test.ts index 18ee43363a..7da3c77c1f 100644 --- a/packages/sitecore-jss/src/layout/utils.test.ts +++ b/packages/sitecore-jss/src/layout/utils.test.ts @@ -1,7 +1,7 @@ /* eslint-disable no-unused-expressions */ import { expect } from 'chai'; import { ComponentRendering } from '../../layout'; -import { getFieldValue, getChildPlaceholder } from './utils'; +import { getFieldValue, getChildPlaceholder, isFieldValueEmpty } from './utils'; describe('sitecore-jss layout utils', () => { describe('getFieldValue', () => { @@ -53,4 +53,140 @@ describe('sitecore-jss layout utils', () => { expect((result[0] as ComponentRendering).componentName).to.be.equal('placed'); }); }); + + describe('isFieldValueEmpty', () => { + it('should return true if passed parameter is not present', () => { + const field = {}; + const result = isFieldValueEmpty(field); + expect(result).to.be.true; + }); + + it('should return true if field value is empty for Field', () => { + const field = { + value: '', + }; + const result = isFieldValueEmpty(field); + expect(result).to.be.true; + }); + + it('should return false if field value is not empty for Field', () => { + const field = { + value: 'field value', + }; + const result = isFieldValueEmpty(field); + expect(result).to.be.false; + }); + + describe('Image', () => { + it('should return true if src is empty for GenericFieldValue', () => { + const fieldValue = { + src: '', + }; + const result = isFieldValueEmpty(fieldValue); + expect(result).to.be.true; + }); + + it('should return true if src is empty for Field', () => { + const field = { + value: { + src: '', + }, + }; + const result = isFieldValueEmpty(field); + expect(result).to.be.true; + }); + + it('should return false if src is not empty for GenericFieldValue', () => { + const fieldValue = { + src: 'imagesrc', + }; + const result = isFieldValueEmpty(fieldValue); + expect(result).to.be.false; + }); + + it('should return false if src is not empty for Field', () => { + const field = { + value: { + src: 'the image src', + }, + }; + const result = isFieldValueEmpty(field); + expect(result).to.be.false; + }); + }); + + describe('Link', () => { + it('should return true if href is empty for GenericFieldValue', () => { + const fieldValue = { + href: '', + }; + const result = isFieldValueEmpty(fieldValue); + expect(result).to.be.true; + }); + + it('should return true if href is empty for Field', () => { + const field = { + value: { + href: '', + }, + }; + const result = isFieldValueEmpty(field); + expect(result).to.be.true; + }); + + it('should return false if href is not empty for GenericFieldValue', () => { + const fieldValue = { + href: 'some.url//', + }; + const result = isFieldValueEmpty(fieldValue); + expect(result).to.be.false; + }); + + it('should return false if href is not empty for Field', () => { + const field = { + value: { + href: 'some.url//', + }, + }; + const result = isFieldValueEmpty(field); + expect(result).to.be.false; + }); + }); + + describe('boolean', () => { + it('should return false if field value is boolean false', () => { + const field = { + value: false, + }; + const result = isFieldValueEmpty(field); + expect(result).to.be.false; + }); + + it('should return false if field value is boolean true', () => { + const field = { + value: true, + }; + const result = isFieldValueEmpty(field); + expect(result).to.be.false; + }); + }); + + describe('number', () => { + it('should return false if field value has number value', () => { + const field = { + value: 1, + }; + const result = isFieldValueEmpty(field); + expect(result).to.be.false; + }); + + it('should return false if field value has value number 0', () => { + const field = { + value: 0, + }; + const result = isFieldValueEmpty(field); + expect(result).to.be.false; + }); + }); + }); }); diff --git a/packages/sitecore-jss/src/layout/utils.ts b/packages/sitecore-jss/src/layout/utils.ts index e13d918e00..56811b092f 100644 --- a/packages/sitecore-jss/src/layout/utils.ts +++ b/packages/sitecore-jss/src/layout/utils.ts @@ -1,4 +1,10 @@ -import { ComponentRendering, ComponentFields, Field, HtmlElementRendering } from './models'; +import { + ComponentRendering, + ComponentFields, + Field, + GenericFieldValue, + HtmlElementRendering, +} from './models'; /** * Safely extracts a field value from a rendering or fields object. @@ -72,3 +78,41 @@ export function getChildPlaceholder( return rendering.placeholders[placeholderName]; } + +/** + * Determines if the passed in field object's value is empty. + * @param {GenericFieldValue | Partial} field the field object. + * Partial type is used here because _field.value_ could be required or optional for the different field types + */ +export function isFieldValueEmpty(field: GenericFieldValue | Partial): boolean { + const isImageFieldEmpty = (fieldValue: GenericFieldValue) => + !(fieldValue as { [key: string]: unknown }).src; + const isFileFieldEmpty = (fieldValue: GenericFieldValue) => + !(fieldValue as { [key: string]: unknown }).src; + const isLinkFieldEmpty = (fieldValue: GenericFieldValue) => + !(fieldValue as { [key: string]: unknown }).href; + + const isEmpty = (fieldValue: GenericFieldValue) => { + if (typeof fieldValue === 'object') { + return ( + isImageFieldEmpty(fieldValue) && + isFileFieldEmpty(fieldValue) && + isLinkFieldEmpty(fieldValue) + ); + } else if (typeof fieldValue === 'number' || typeof fieldValue === 'boolean') { + // Avoid returning true for 0 and false values + return false; + } else { + return !fieldValue; + } + }; + + if (!field) return true; + + const dynamicField = field as Partial; + if (dynamicField.value !== undefined) { + return isEmpty(dynamicField.value); + } + + return isEmpty(field as GenericFieldValue); +}