Skip to content
6 changes: 5 additions & 1 deletion src/content/PagePartials/CollectionEntry.query.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
#import '@blocks/VideoBlock/VideoBlock.fragment.graphql'
#import '@blocks/VideoEmbedBlock/VideoEmbedBlock.fragment.graphql'

query PagePartialCollectionEntry($locale: SiteLocale!, $id: ItemId!) {
query PagePartialCollectionEntry(
$id: ItemId!,
$locale: SiteLocale!,
$fallbackLocales: [SiteLocale!]
) {
record: pagePartial(locale: $locale, filter: { id: { eq: $id } }) {
__typename
id
Expand Down
6 changes: 5 additions & 1 deletion src/content/Pages/CollectionEntry.query.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
#import '@blocks/VideoEmbedBlock/VideoEmbedBlock.fragment.graphql'
#import '@blocks/GroupingBlock/GroupingBlock.fragment.graphql'

query PageCollectionEntry($locale: SiteLocale!, $slug: String!) {
query PageCollectionEntry(
$slug: String!,
$locale: SiteLocale!,
$fallbackLocales: [SiteLocale!]
) {
record: page(locale: $locale, filter: { slug: { eq: $slug } }) {
...PageRoute
_seoMetaTags {
Expand Down
7 changes: 5 additions & 2 deletions src/layouts/default.query.graphql
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
#import './AppMenu/AppMenu.fragment.graphql'

query DefaultLayout($locale: SiteLocale!) {
seo: _site(locale: $locale) {
query DefaultLayout(
$locale: SiteLocale!,
$fallbackLocales: [SiteLocale!]
) {
seo: _site(locale: $locale, fallbackLocales: $fallbackLocales) {
faviconMetaTags(variants: [icon, appleTouchIcon, msApplication]) {
tag
attributes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@ import {
} from 'vitest';
import { HttpResponse, graphql } from 'msw';
import { setupServer } from 'msw/node';
import { parse } from 'graphql';
import { datocmsCollection, type CollectionInfo } from '@lib/datocms';
import { Kind, parse, type FragmentDefinitionNode } from 'graphql';
import { print } from 'graphql/language/printer';
import {
datocmsCollection,
getFragmentNameAndDocument,
getQueryNameAndVariables,
inlineFragmentName,
type CollectionInfo
} from './collection';
import type { LocaleVariables } from './request';

vi.mock('../../../../datocms-environment', () => ({
datocmsBuildTriggerId: 'mock-build-trigger-id',
Expand Down Expand Up @@ -52,7 +60,7 @@ describe('datocmsCollection:', () => {
})));

server.use(graphql.query('AllMyMockCollection', () => HttpResponse.json({
data: { MyMockCollection: mockCollection }
data: { entries: mockCollection }
})));

const records = await datocmsCollection({ collection: 'MyMockCollection', fragment: 'id title' });
Expand All @@ -76,7 +84,7 @@ describe('datocmsCollection:', () => {
})));

server.use(graphql.query('AllMyMockCollection', () => HttpResponse.json({
data: { MyMockCollection: mockCollection }
data: { entries: mockCollection }
})));

const records = await datocmsCollection({ collection: 'MyMockCollection', fragment });
Expand Down Expand Up @@ -108,7 +116,7 @@ describe('datocmsCollection:', () => {
const paginatedData = mockCollection.slice(skip, skip + recordsPerRequest);

requestCount++;
return HttpResponse.json({ data: { MyMockCollection: paginatedData } });
return HttpResponse.json({ data: { entries: paginatedData } });
}),
);

Expand Down Expand Up @@ -146,7 +154,7 @@ describe('datocmsCollection:', () => {
})));

server.use(graphql.query('AllMyMockCollection', () => HttpResponse.json({
data: { MyMockCollection: [] }
data: { entries: [] }
})));

const records = await datocmsCollection({ collection: 'MyMockCollection', fragment: 'id title' });
Expand Down Expand Up @@ -180,3 +188,75 @@ describe('datocmsCollection:', () => {
expect(response!.message).toContain(errorResponse[0].message);
});
});

describe('getFragmentNameAndDocument:', () => {
const type = 'MyMockRecord';
const name = 'MyMockRecordFragment';
const inlineFragment = /* graphql */`
id
title
`;
const createFragment = (name: string, type: string) => parse(/* graphql */`fragment ${name} on ${type} {
${inlineFragment}
}`);
const fragment = createFragment(name, type);

test('returns a name and document for a parsed fragment', () => {
const { fragmentName, fragmentDocument } = getFragmentNameAndDocument({ fragment, type });
expect(fragmentName).toBe(name);
expect(fragmentDocument).toBe(print(fragment));
});

test('returns a name and document for a fragment body as string', () => {
const fragment = createFragment(inlineFragmentName, type);
const { fragmentName, fragmentDocument } = getFragmentNameAndDocument({ fragment: inlineFragment, type });
expect(fragmentName).toBe(inlineFragmentName);
expect(fragmentDocument).toBe(print(fragment));
});

test('returns fragment on correct type for a fragment body as string', () => {
const { fragmentDocument } = getFragmentNameAndDocument({ fragment: inlineFragment, type });
const { definitions } = parse(fragmentDocument);
const fragmentDefinition = definitions.find(({ kind }) => kind === Kind.FRAGMENT_DEFINITION) as FragmentDefinitionNode;
expect(fragmentDefinition.typeCondition.name.value).toBe(type);
});
});

describe('queryNameAndVariables:', () => {
const collection = 'Mocks';
const variables: LocaleVariables = {
locale: 'en',
fallbackLocales: []
};
const fragment = `
fragment MyMockRecordFragment on MyMockRecord {
id
slug
}
`;

const fragmentWithVariables = `
fragment MyMockRecordFragment on MyMockRecord {
id
slug(locale: $locale, fallbackLocales: $fallbackLocales)
}
`;

test('returns just the query name when no variables are used in fragment', () => {
const queryName = getQueryNameAndVariables({
collection,
variables,
fragmentDocument: fragment,
});
expect(queryName).toBe(`All${collection}`);
});

test('returns just query name and variables when variables are used in fragment', () => {
const queryName = getQueryNameAndVariables({
collection,
variables,
fragmentDocument: fragmentWithVariables,
});
expect(queryName).toBe(`All${collection}($locale: SiteLocale!, $fallbackLocales: [SiteLocale!])`);
});
});
166 changes: 166 additions & 0 deletions src/lib/datocms/collection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { Kind, parse, type DocumentNode, type FragmentDefinitionNode } from 'graphql';
import { print } from 'graphql/language/printer';
import { datocmsRequest, type LocaleVariables } from './request';
import { defaultLocale } from '@lib/i18n';

/**
* Returns all records from a DatoCMS collection (like 'Pages')
* with data for each record based on the provided fragment.
*
* DatoCMS GraphQL API has a limit of 100 records per request.
* This function uses pagination to get all records.
* @see https://www.datocms.com/docs/content-delivery-api/pagination
*
* @param {string} params.collection
* - The name of the DatoCMS collection. For example, `"Pages"`
* @param {DocumentNode|string} params.fragment
* - The GraphQL fragment to include for each record, For example `pageRouteFragment`.
*/
export async function datocmsCollection<CollectionType>({
collection,
fragment
}: {
collection: string;
fragment: string | DocumentNode;
}) {
const records: CollectionType[] = [];

const { count, type } = await getCollectionMetadata(collection);
if (!type || !count) return records; // Function has to have early return if type is null.

const recordsPerPage = 100; // DatoCMS GraphQL API has a limit of 100 records per request
const totalPages = Math.ceil(count / recordsPerPage);

const { fragmentName, fragmentDocument } = getFragmentNameAndDocument({ fragment, type });

// When the fragment used in the collection query uses locale variables, w
// we want to set them so the query succeeds. Setting the locale to the
// default locale, and fallback locales to an empty array, the return value
// will be same as when no variables would be used.
const variables: LocaleVariables = {
locale: defaultLocale,
fallbackLocales: []
};

const queryName = getQueryNameAndVariables({ collection, variables, fragmentDocument });
const key = 'entries' as const;
for (let page = 0; page < totalPages; page++) {
const data = await datocmsRequest<{ [key]: CollectionType[] }>({
variables,
query: parse(/* graphql */`
# Insert fragment definition from fragmentDocument,
# which is either the fragment passed from an import from @lib/datocms/types.ts
# or the one created from a string;
${fragmentDocument}

query ${queryName} {
${key}: all${collection} (
first: ${recordsPerPage},
skip: ${page * recordsPerPage}
) {
...${fragmentName}
}
}
`)
});

records.push(...data[key]);
}

return records;
}

export const inlineFragmentName = 'InlineFragment';

export function getFragmentNameAndDocument({
fragment,
type,
}: {
fragment: string | DocumentNode,
type: string
}): {
fragmentName: string,
fragmentDocument: string,
} {
// Create new fragment to maintain support for passing a string to argument fragment
const fragmentDocument = typeof fragment === 'string'
? parse(`fragment ${inlineFragmentName} on ${type} { ${fragment} }`)
: fragment;
const { definitions } = fragmentDocument;
const fragmentDefinition = definitions
.find((definition): definition is FragmentDefinitionNode =>
definition.kind === Kind.FRAGMENT_DEFINITION
);
const fragmentName = fragmentDefinition?.name?.value;
if (!fragmentName) {
throw new Error('Fragment definition has no name.');
}
return {
fragmentName,
fragmentDocument: print(fragmentDocument),
};
}

/**
* Return a formatted query name with variables where applicable, for example:
* `AllPagePartials` or
* `AllPages($locale: SiteLocale!, $fallbackLocales: [SiteLocale!]`
*/
export function getQueryNameAndVariables({
collection,
variables,
fragmentDocument
}: {
collection: string,
variables: LocaleVariables,
fragmentDocument: string
}): string {
const queryParams: string[] = [];
const paramTypes: Record<string, string> = {
locale: 'SiteLocale!',
fallbackLocales: '[SiteLocale!]',
} satisfies Record<keyof LocaleVariables, string>;

for (const key in variables) {
// Verify if a variable is being used in the compound fragment.
if (new RegExp(`\\$${key}\\b`).test(fragmentDocument)) {
queryParams.push(`$${key}: ${paramTypes[key]}`);
}
}

const paramString = queryParams.join(', ');

return `All${collection}${paramString ? `(${paramString})` : ''}`;
}

export type CollectionInfo = {
meta: { count: number };
records: [] | [{ __typename: string }];
};

/**
* Fetches type and number of records for collection.
* Note that when there are no records for a collection, the type is `null`.
*
* @param collection
* @returns
*/
export async function getCollectionMetadata(collection: string): Promise<{
count: number, type: string | null
}> {
const query = parse(/* graphql */`
query ${collection}Meta {
# Fetch first record to get the __typename to be used for the fragment created from a string
records: all${collection}(first: 1) { __typename }
meta: _all${collection}Meta { count }
}
`);
const {
meta: { count },
records: [
{ __typename: type } = { __typename: null } // Collection might be empty
]
} = await datocmsRequest<CollectionInfo>({ query });

return { count, type };
}
Loading
Loading