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
+ )}
`,
+ '',
+ '
',
+ ].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
- )}
`,
- '',
- '
',
- ].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: `
- `,
- metadata: testMetadata,
- },
- };
-
- const rendered = mount(
-
-
- ,
- { attachTo: app }
- );
-
- expect(rendered.html()).to.equal(
- [
- `${JSON.stringify(
- testMetadata
- )}
`,
- ].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: `
+ `,
+ metadata: testMetadata,
+ },
+ };
+
+ const rendered = mount(
+
+
+ ,
+ { attachTo: app }
+ );
+
+ expect(rendered.html()).to.equal(
+ [
+ `${JSON.stringify(
+ testMetadata
+ )}
`,
+ ].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 (
+
+ );
+ };
+
+ // eslint-disable-next-line react/display-name
+ const TestComponentWithRef = forwardRef(
+ (props: TestComponentProps, ref: React.ForwardedRef) => {
+ return (
+
+ );
+ }
+ );
+
+ 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('');
+ });
+
+ 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('');
+ });
+
+ 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('');
+ });
+
+ 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('');
+ });
+
+ 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('');
+ });
+
+ 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('');
+ });
+
+ 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('');
+ });
+
+ 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('');
+ });
+
+ 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);
+}