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

Feature/jss content tokens #1942

Open
wants to merge 13 commits into
base: dev
Choose a base branch
from
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Our versioning strategy is as follows:
* `[sitecore-jss]` GenericFieldValue model is updated to accept Date type ([#1916](https://github.com/Sitecore/jss/pull/1916))
* `[template/node-xmcloud-proxy]` `[sitecore-jss-proxy]` Introduced /api/healthz endpoint ([#1928](https://github.com/Sitecore/jss/pull/1928))
* `[sitecore-jss]` `[sitecore-jss-angular]` Render field metdata chromes in editMode metadata - in edit mode metadata in Pages, angular package field directives will render wrapping `code` elements with field metadata required for editing; ([#1926](https://github.com/Sitecore/jss/pull/1926))
* `[sitecore-jss]` Added services for Content Tokens `/sitecore/templates/Feature/Experience Accelerator/Content Tokens/Content Token` so we're a step closer to enabling (custom) content replacement tokens in headless development

### 🛠 Breaking Change

Expand Down
6 changes: 6 additions & 0 deletions packages/sitecore-jss-nextjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,16 @@ export {
PageViewInstance,
} from '@sitecore-jss/sitecore-jss/tracking';
export {
ContentTokenPhrases,
ContentTokenService,
DictionaryPhrases,
DictionaryService,
GraphQLContentTokenService,
GraphQLContentTokenServiceConfig,
GraphQLDictionaryService,
GraphQLDictionaryServiceConfig,
// TODO:RestContentTokenService,
// TODO:RestContentTokenServiceConfig,
RestDictionaryService,
RestDictionaryServiceConfig,
} from '@sitecore-jss/sitecore-jss/i18n';
Expand Down
3 changes: 3 additions & 0 deletions packages/sitecore-jss/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ export enum SitecoreTemplateId {

// /sitecore/templates/System/Dictionary/Dictionary entry
DictionaryEntry = '6d1cd89719364a3aa511289a94c2a7b1',

// /sitecore/templates/Feature/Experience Accelerator/Content Tokens/Content Token
ContentToken = '7d659ee9d4874d408a9210c6d68844c8',
}

export const FETCH_WITH = {
Expand Down
1 change: 1 addition & 0 deletions packages/sitecore-jss/src/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,5 @@ export default {
personalize: debug(`${rootNamespace}:personalize`),
errorpages: debug(`${rootNamespace}:errorpages`),
proxy: debug(`${rootNamespace}:proxy`),
contenttokens: debug(`${rootNamespace}:contenttokens`),
};
72 changes: 72 additions & 0 deletions packages/sitecore-jss/src/i18n/content-token-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { CacheClient, CacheOptions, MemoryCacheClient } from '../cache-client';

/**
* Object model for Sitecore dictionary phrases
*/
export interface ContentTokenPhrases {
[k: string]: string;
}

/**
* Service that fetches dictionary data using Sitecore's GraphQL API.
*/
export interface ContentTokenService {
/**
* Fetch dictionary data for a language.
* @param {string} language the language to be used to fetch the dictionary
*/
fetchContentTokens(language: string): Promise<ContentTokenPhrases>;
}

/**
* Base implementation of @see ContentTokenService that handles caching dictionary values
*/
export abstract class ContentTokenServiceBase
implements ContentTokenService, CacheClient<ContentTokenPhrases> {
private cache: CacheClient<ContentTokenPhrases>;

/**
* Initializes a new instance of @see ContentTokenService using the provided @see CacheOptions
* @param {CacheOptions} options Configuration options
*/
constructor(public options: CacheOptions) {
this.cache = this.getCacheClient();
}

/**
* Caches a @see ContentTokenPhrases value for the specified cache key.
* @param {string} key The cache key.
* @param {ContentTokenPhrases} value The value to cache.
* @returns The value added to the cache.
* @mixes CacheClient<ContentTokenPhrases>
*/
setCacheValue(key: string, value: ContentTokenPhrases): ContentTokenPhrases {
return this.cache.setCacheValue(key, value);
}

/**
* Retrieves a @see ContentTokenPhrases value from the cache.
* @param {string} key The cache key.
* @returns The @see ContentTokenPhrases value, or null if the specified key is not found in the cache.
*/
getCacheValue(key: string): ContentTokenPhrases | null {
return this.cache.getCacheValue(key);
}

/**
* Gets a cache client that can cache data. Uses memory-cache as the default
* library for caching (@see MemoryCacheClient). Override this method if you
* want to use something else.
* @returns {CacheClient} implementation
*/
protected getCacheClient(): CacheClient<ContentTokenPhrases> {
return new MemoryCacheClient<ContentTokenPhrases>(this.options);
}

/**
* Fetch dictionary data for a language.
* @param {string} language the language to be used to fetch the dictionary
* @returns {Promise<ContentTokenPhrases>}
*/
abstract fetchContentTokens(language: string): Promise<ContentTokenPhrases>;
}
273 changes: 273 additions & 0 deletions packages/sitecore-jss/src/i18n/graphql-content-token-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
/* eslint-disable no-unused-expressions */
import { expect } from 'chai';
import sinon, { SinonSpy } from 'sinon';
import nock from 'nock';
import { SitecoreTemplateId } from '../constants';
import { GraphQLClient, GraphQLRequestClient } from '../graphql-request-client';
import { queryError, GraphQLContentTokenServiceConfig } from './graphql-content-token-service';
import { GraphQLContentTokenService } from '.';
import contentTokenQueryResponse from '../test-data/mockContentTokenQueryResponse.json';
import appRootQueryResponse from '../test-data/mockAppRootQueryResponse.json';

class TestService extends GraphQLContentTokenService {
public client: GraphQLClient;
constructor(options: GraphQLContentTokenServiceConfig) {
super(options);
this.client = this.getGraphQLClient();
}
}

describe('GraphQLContentTokenService', () => {
const endpoint = 'http://site';
const siteName = 'site-name';
const apiKey = 'api-key';
const rootItemId = '{GUID}';
const clientFactory = GraphQLRequestClient.createClientFactory({
endpoint,
apiKey,
});

afterEach(() => {
nock.cleanAll();
});

it('should fetch content token phrases using clientFactory', async () => {
nock(endpoint, { reqheaders: { sc_apikey: apiKey } })
.post('/', /ContentTokenSearch/gi)
.reply(200, contentTokenQueryResponse);

const service = new GraphQLContentTokenService({
siteName,
rootItemId,
cacheEnabled: false,
clientFactory,
});
const result = await service.fetchContentTokens('en');
expect(result.foo).to.equal('foo');
expect(result.bar).to.equal('bar');
});

it('should attempt to fetch the rootItemId, if rootItemId not provided', async () => {
nock(endpoint)
.post('/', /AppRootQuery/)
.reply(200, appRootQueryResponse);

nock(endpoint)
.post('/', (body) => body.variables.rootItemId === 'GUIDGUIDGUID')
.reply(200, contentTokenQueryResponse);

const service = new GraphQLContentTokenService({
clientFactory,
siteName,
cacheEnabled: false,
});
const result = await service.fetchContentTokens('en');
expect(result).to.have.all.keys('foo', 'bar');
// eslint-disable-next-line no-unused-expressions
expect(nock.isDone()).to.be.true;
});

it('should use a custom rootItemId, if provided', async () => {
const customRootId = 'cats';

nock(endpoint)
.post('/', (body) => body.variables.rootItemId === customRootId)
.reply(200, contentTokenQueryResponse);

const service = new GraphQLContentTokenService({
clientFactory,
siteName,
cacheEnabled: false,
rootItemId: customRootId,
});
const result = await service.fetchContentTokens('en');
expect(result).to.have.all.keys('foo', 'bar');
});

it('should use a jssTemplateId, if provided', async () => {
const jssAppTemplateId = '{71d608ca-ac9c-4f1c-8e0a-85a6946e30f8}';
const randomId = '{412286b7-6d4f-4deb-80e9-108ee986c6e9}';

nock(endpoint)
.post('/', (body) => body.variables.jssAppTemplateId === jssAppTemplateId)
.reply(200, {
data: {
layout: {
homePage: {
rootItem: [
{
id: randomId,
},
],
},
},
},
});

nock(endpoint)
.post('/', (body) => body.variables.rootItemId === randomId)
.reply(200, contentTokenQueryResponse);

const service = new GraphQLContentTokenService({
clientFactory,
siteName,
cacheEnabled: false,
jssAppTemplateId,
});

const result = await service.fetchContentTokens('en');
expect(result).to.have.all.keys('foo', 'bar');
});

it('should throw error if could not resolve rootItemId', async () => {
nock(endpoint)
.post('/', /AppRootQuery/)
.reply(200, {
data: {
layout: {
homePage: null,
},
},
});

const service = new GraphQLContentTokenService({
clientFactory,
siteName,
cacheEnabled: false,
});

await service.fetchContentTokens('en').catch((error) => {
expect(error).to.be.instanceOf(Error);
expect(error.message).to.equal(queryError);
});
});

it('should use default pageSize, if pageSize not provided', async () => {
nock(endpoint)
.post(
'/',
(body) =>
body.query.indexOf('$pageSize: Int = 10') > 0 && body.variables.pageSize === undefined
)
.reply(200, contentTokenQueryResponse);

const service = new GraphQLContentTokenService({
clientFactory,
siteName,
rootItemId,
cacheEnabled: false,
pageSize: undefined,
});
const result = await service.fetchContentTokens('en');
expect(result).to.have.all.keys('foo', 'bar');
});

it('should use a custom pageSize, if provided', async () => {
const customPageSize = 2;

nock(endpoint)
.post('/', (body) => body.variables.pageSize === customPageSize)
.reply(200, contentTokenQueryResponse);

const service = new GraphQLContentTokenService({
clientFactory,
siteName,
rootItemId,
cacheEnabled: false,
pageSize: customPageSize,
});
const result = await service.fetchContentTokens('en');
expect(result).to.have.all.keys('foo', 'bar');
});

it('should use custom content token template ID, if provided', async () => {
const customTemplateId = 'custom-template-id';

nock(endpoint)
.post('/', (body) => body.variables.templates === customTemplateId)
.reply(200, contentTokenQueryResponse);

const service = new GraphQLContentTokenService({
clientFactory,
siteName,
rootItemId,
cacheEnabled: false,
contentTokenTemplateId: customTemplateId,
});
const result = await service.fetchContentTokens('en');
expect(result).to.have.all.keys('foo', 'bar');
});

it('should use default content token template ID, if template ID not provided', async () => {
nock(endpoint)
.post('/', (body) => body.variables.templates === SitecoreTemplateId.ContentToken)
.reply(200, contentTokenQueryResponse);

const service = new GraphQLContentTokenService({
clientFactory,
siteName,
rootItemId,
cacheEnabled: false,
});
const result = await service.fetchContentTokens('en');
expect(result).to.have.all.keys('foo', 'bar');
});

it('should use cache', async () => {
nock(endpoint, { reqheaders: { sc_apikey: apiKey } })
.post('/', /ContentTokenSearch/gi)
.reply(200, contentTokenQueryResponse);

const service = new GraphQLContentTokenService({
clientFactory,
siteName,
rootItemId,
cacheEnabled: true,
cacheTimeout: 2,
});

const result1 = await service.fetchContentTokens('en');
expect(result1).to.have.all.keys('foo', 'bar');

const result2 = await service.fetchContentTokens('en');
expect(result2).to.have.all.keys('foo', 'bar');
});

it('should provide a default GraphQL client', () => {
const service = new TestService({
clientFactory,
siteName,
rootItemId,
cacheEnabled: false,
});

const graphQLClient = service.client as GraphQLClient;
const graphQLRequestClient = service.client as GraphQLRequestClient;
// eslint-disable-next-line no-unused-expressions
expect(graphQLClient).to.exist;
// eslint-disable-next-line no-unused-expressions
expect(graphQLRequestClient).to.exist;
});

it('should call clientFactory with the correct arguments', () => {
const clientFactorySpy: SinonSpy = sinon.spy();
const mockServiceConfig = {
siteName: 'supersite',
clientFactory: clientFactorySpy,
retries: 3,
retryStrategy: {
getDelay: () => 1000,
shouldRetry: () => true,
},
};

new GraphQLContentTokenService(mockServiceConfig);

expect(clientFactorySpy.calledOnce).to.be.true;

const calledWithArgs = clientFactorySpy.firstCall.args[0];
expect(calledWithArgs.debugger).to.exist;
expect(calledWithArgs.retries).to.equal(mockServiceConfig.retries);
expect(calledWithArgs.retryStrategy).to.deep.equal(mockServiceConfig.retryStrategy);
});
});
Loading