Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[nextjs] Bring your own code (BYOC) feature #1568

Merged
merged 16 commits into from
Jul 26, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { BYOCProps, BYOCRenderer } from '@sitecore-jss/sitecore-jss-nextjs';
import React from 'react';
import * as FEAAS from '@sitecore-feaas/clientside/react';

export const Default = (props: BYOCProps): JSX.Element => {
const rendererProps = {
components: FEAAS.External.registered,
...props,
};
return (
<div className="component-content">
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
<BYOCRenderer {...rendererProps} />
</div>
);
};
2 changes: 2 additions & 0 deletions packages/sitecore-jss-nextjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ export {
FEaaSComponentProps,
FEaaSComponentParams,
fetchFEaaSComponentServerProps,
BYOCProps,
BYOCRenderer,
File,
FileField,
RichTextField,
Expand Down
119 changes: 119 additions & 0 deletions packages/sitecore-jss-react/src/components/BYOCRenderer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import React from 'react';
import { expect } from 'chai';
import { mount } from 'enzyme';
import { BYOCRenderer } from './BYOCRenderer';
import { MissingComponent } from './MissingComponent';
import { ComponentFields } from '@sitecore-jss/sitecore-jss/layout';

describe('<BYOCRenderer />', () => {
type PropType = {
text: string;
};

const ComponentWithProps = (props: PropType) => (
<div className="byoc">I display this: {props.text || 'nothing'}</div>
);

const getBaseByocProps = (
registeredComponent: React.ComponentType<any>,
componentProps?: string,
fields?: ComponentFields
) => {
const registeredComponents = {
RegisteredComponent: {
name: 'RegisteredComponent',
component: registeredComponent,
},
};

return {
params: {
ComponentName: 'RegisteredComponent',
ComponentProps: componentProps,
},
fields: fields,
components: registeredComponents,
};
};

it('should render the registered component with provided props', () => {
const noPropComponent = () => <div className="byoc">Registered Component</div>;
const props = getBaseByocProps(noPropComponent);

const wrapper = mount(<BYOCRenderer {...props} />);

expect(wrapper.find('div.byoc').text()).to.equal('Registered Component');
});

it('should render missing component frame when component isnt registered', () => {
const props = { params: { ComponentName: 'NonExistentComponent' }, components: {} };

const wrapper = mount(<BYOCRenderer {...props} />);

expect(wrapper.find(MissingComponent)).to.have.lengthOf(1);
});

it('should use props from rendering params when present', () => {
const props = getBaseByocProps(ComponentWithProps, JSON.stringify({ text: 'this is text' }));

const wrapper = mount(<BYOCRenderer {...props} />);

expect(wrapper.find('div.byoc').text()).to.contain('I display this');
expect(wrapper.find('div.byoc').text()).to.contain('this is text');
});

it('should prioritize props from rendering params', () => {
const dataSourceFields: ComponentFields = {
text: {
value: 'this is data source text',
},
};

const props = getBaseByocProps(
ComponentWithProps,
JSON.stringify({ text: 'this is param text' }),
dataSourceFields
);

const wrapper = mount(<BYOCRenderer {...props} />);

expect(wrapper.find('div.byoc').text()).to.contain('I display this');
expect(wrapper.find('div.byoc').text()).to.contain('this is param text');
});

it('should use props from data source as fallback', () => {
const dataSourceFields: ComponentFields = {
text: {
value: 'this is data source text',
},
};
const props = getBaseByocProps(ComponentWithProps, '', dataSourceFields);

const wrapper = mount(<BYOCRenderer {...props} />);

expect(wrapper.find('div.byoc').text()).to.contain('I display this');
expect(wrapper.find('div.byoc').text()).to.contain('this is data source text');
});

it('should use props from data source if params have invalid JSON', () => {
const dataSourceFields: ComponentFields = {
text: {
value: 'this is data source text',
},
};
const props = getBaseByocProps(ComponentWithProps, 'this is not a JSON', dataSourceFields);

const wrapper = mount(<BYOCRenderer {...props} />);

expect(wrapper.find('div.byoc').text()).to.contain('I display this');
expect(wrapper.find('div.byoc').text()).to.contain('this is data source text');
});

it('should fallback to empty props when other sources fail', () => {
const props = getBaseByocProps(ComponentWithProps, '', {});

const wrapper = mount(<BYOCRenderer {...props} />);

expect(wrapper.find('div.byoc').text()).to.equal('I display this: nothing');
});
});
89 changes: 89 additions & 0 deletions packages/sitecore-jss-react/src/components/BYOCRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
import React from 'react';
import { ComponentFields } from '@sitecore-jss/sitecore-jss/layout';
import { getDataFromFields } from '../utils';
import { MissingComponent } from './MissingComponent';
import { RegisteredComponents } from '@sitecore-feaas/clientside/types/ui/FEAASExternal';

/**
* Data from rendering params on Sitecore's BYOC rendering
*/
type BYOCRenderingParams = {
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
/**
* Name of the component to render
*/
ComponentName: string;
/**
* JSON props to pass into rendered component
*/
ComponentProps?: string;
};

/**
* Props for BYOC wrapper component
*/
export type BYOCProps = {
/**
* rendering params
*/
params?: BYOCRenderingParams;
/**
* fields from datasource items to be passed as rendered child component props
*/
fields?: ComponentFields;
};

/**
* Props for BYOCRenderer component. Includes components list to load external components from.
*/
type ByocRendererProps = BYOCProps & {
components: RegisteredComponents;
};

/**
* BYOCRenderer helps rendering BYOC components - that can be taken from anywhere
* and registered without being deployed as Sitecore renderings
* @param {ByocRendererProps} props component props
* @returns dynamicly rendered component or Missing Component error frame
*/
export const BYOCRenderer = (props: ByocRendererProps) => {
const { ComponentName: componentName } = props.params || {};
if (!componentName) return <MissingComponent />;

// props.components would contain component from internal FEAAS regsitered component collection (registered in app)
// we can't access this collection here directly, as the collection from packages's dependency would be different from the one in app
const Component = Object.keys(props.components).length
? Object.values(props.components).find((component) => component.name === componentName)
?.component
: null;
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved

if (!Component) {
console.warn(
`Component "${componentName}" was not registered, please ensure the FEEAS.External.registerComponent call is made.`
);
const missingProps = {
rendering: {
componentName: componentName,
},
errorOverride: 'BYOC: This component was not registered.',
};
return <MissingComponent {...missingProps} />;
}

let componentProps: { [key: string]: unknown } = undefined;

if (props.params?.ComponentProps) {
try {
componentProps = JSON.parse(props.params.ComponentProps) ?? {};
} catch (e) {
console.warn(
`Parsing props for ${componentName} component from rendering params failed. Attempting to parse from data source`
);
}
}
if (!componentProps) {
componentProps = props.fields ? getDataFromFields(props.fields) : {};
}

return <Component {...componentProps} />;
};
16 changes: 2 additions & 14 deletions packages/sitecore-jss-react/src/components/FEaaSComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import React from 'react';
import * as FEAAS from '@sitecore-feaas/clientside/react';
import {
ComponentFields,
LayoutServicePageState,
getFieldValue,
} from '@sitecore-jss/sitecore-jss/layout';
import { ComponentFields, LayoutServicePageState } from '@sitecore-jss/sitecore-jss/layout';
import { getDataFromFields } from '../utils';

export const FEAAS_COMPONENT_RENDERING_NAME = 'FEaaSComponent';

Expand Down Expand Up @@ -139,15 +136,6 @@ export async function fetchFEaaSComponentServerProps(
}
}

const getDataFromFields = (fields: ComponentFields): { [key: string]: unknown } => {
let data: { [key: string]: unknown } = {};
data = Object.entries(fields).reduce((acc, [key]) => {
acc[key] = getFieldValue(fields, key);
return acc;
}, data);
return data;
};

/**
* Build component endpoint URL from component's params
* @param {FEaaSComponentParams} params rendering parameters for FEAAS component
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';
import { expect } from 'chai';
import { mount } from 'enzyme';
import { MissingComponent } from './MissingComponent';

describe('<MissingComponent>', () => {
it('should accept and display custom error', () => {
const errorMsg = 'Oops, I errored again';
const props = {
rendering: {
componentName: 'test',
},
errorOverride: errorMsg,
};

const wrapper = mount(<MissingComponent {...props} />);

expect(wrapper.find('div p').text()).to.contain(errorMsg);
});
});
14 changes: 8 additions & 6 deletions packages/sitecore-jss-react/src/components/MissingComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface MissingComponentProps {
rendering?: {
componentName?: string;
};
errorOverride?: string;
}

export const MissingComponent: React.FC<MissingComponentProps> = (props) => {
Expand All @@ -13,8 +14,12 @@ export const MissingComponent: React.FC<MissingComponentProps> = (props) => {
? props.rendering.componentName
: 'Unnamed Component';

console.log(`Component props for unimplemented '${componentName}' component`, props);

// error override would mean component is not unimplemented
!props.errorOverride &&
console.log(`Component props for unimplemented '${componentName}' component`, props);
const errorMessage =
props.errorOverride ||
'JSS component is missing React implementation. See the developer console for more information.';
return (
<div
style={{
Expand All @@ -26,10 +31,7 @@ export const MissingComponent: React.FC<MissingComponentProps> = (props) => {
}}
>
<h2>{componentName}</h2>
<p>
JSS component is missing React implementation. See the developer console for more
information.
</p>
<p>{errorMessage}</p>
</div>
);
};
Expand Down
1 change: 1 addition & 0 deletions packages/sitecore-jss-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export {
FEaaSComponentParams,
fetchFEaaSComponentServerProps,
} from './components/FEaaSComponent';
export { BYOCProps, BYOCRenderer } from './components/BYOCRenderer';
export { Link, LinkField, LinkFieldValue, LinkProps, LinkPropTypes } from './components/Link';
export { File, FileField } from './components/File';
export { VisitorIdentification } from './components/VisitorIdentification';
Expand Down
15 changes: 15 additions & 0 deletions packages/sitecore-jss-react/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ComponentFields, getFieldValue } from '@sitecore-jss/sitecore-jss/layout';
import { parse as styleParse } from 'style-attr';

// https://stackoverflow.com/a/10426674/9324
Expand Down Expand Up @@ -98,3 +99,17 @@ export const getAttributesString = (attributes: { [key: string]: unknown }): str

return attributesEntries.join(' ');
};

/**
* Used in FEAAS and BYOC implementations to convert datasource item field values into component props
* @param {ComponentFields} fields field collection from Sitecore
* @returns JSON object that can be used as props
*/
export const getDataFromFields = (fields: ComponentFields): { [key: string]: unknown } => {
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
let data: { [key: string]: unknown } = {};
data = Object.entries(fields).reduce((acc, [key]) => {
acc[key] = getFieldValue(fields, key);
return acc;
}, data);
return data;
};