diff --git a/.env.development.local.example b/.env.development.local.example
index ecd076b54..891bb3470 100644
--- a/.env.development.local.example
+++ b/.env.development.local.example
@@ -18,6 +18,19 @@
# You must be careful to use sensitive information only on the server-side, because if you use them on the browser or getInitialProps, they'll be leaked, even if the variable doesn't start with "NEXT_PUBLIC_".
# Any change to this file needs a server restart to be applied.
+# Airtable API Key, can be found at https://airtable.com/account
+# Used to authenticate to the Airtable API
+# XXX Be extra cautious with this, it's shared by all the Airtable bases of your account
+# REQUIRED - If not set, the app won't work at all and likely throw a "Error: Unauthorized"
+# Example (fake value): keyAJ3h1VfPLRPPPP
+AIRTABLE_API_KEY=
+
+# Airtable Base ID, can be found in your airtable base then "Help > API Documentation"
+# Airtable base to which API requests will be sent
+# REQUIRED - If not set, the app won't work at all
+# Example (fake value): app76bhOKJt11111z
+AIRTABLE_BASE_ID=
+
# Locize API key, can be found under "Your project > Settings > Api Keys" at https://www.locize.app/?ref=unly-nrn
# Used to automatically save missing translations when working locally
# Optional - If not set, the app will work anyway, it just won't create new keys automatically
diff --git a/package.json b/package.json
index 613dbf28b..04b994f19 100644
--- a/package.json
+++ b/package.json
@@ -86,6 +86,7 @@
"locize-editor": "3.0.0",
"locize-lastused": "3.0.4",
"lodash.clonedeep": "4.5.0",
+ "lodash.endswith": "4.2.1",
"lodash.filter": "4.6.0",
"lodash.find": "4.6.0",
"lodash.get": "4.4.2",
@@ -124,6 +125,7 @@
"@types/jest": "25.2.2",
"@types/js-cookie": "2.2.6",
"@types/lodash.clonedeep": "4.5.6",
+ "@types/lodash.endswith": "4.2.6",
"@types/lodash.find": "4.6.6",
"@types/lodash.get": "4.4.6",
"@types/lodash.includes": "4.3.6",
diff --git a/process.env.d.ts b/process.env.d.ts
index f06baf2a4..28cd5c588 100644
--- a/process.env.d.ts
+++ b/process.env.d.ts
@@ -11,6 +11,8 @@ declare global {
namespace NodeJS {
interface ProcessEnv {
// NRN env variables
+ AIRTABLE_API_KEY: string;
+ AIRTABLE_BASE_ID: string;
GRAPHQL_API_ENDPOINT: string;
GRAPHQL_API_KEY: string;
LOCIZE_API_KEY: string;
diff --git a/src/components/assets/GraphCMSAsset.test.tsx b/src/components/assets/GraphCMSAsset.test.tsx
index 0344fd88a..65b6dc0ee 100644
--- a/src/components/assets/GraphCMSAsset.test.tsx
+++ b/src/components/assets/GraphCMSAsset.test.tsx
@@ -5,7 +5,8 @@ import GraphCMSAsset from './GraphCMSAsset';
const defaultLogoUrl = 'https://media.graphcms.com/88YmsSFsSEGI9i0qcH0V';
const defaultLogoTarget = '_blank';
-describe('GraphCMSAsset', () => {
+// TODO skipped until fixed
+describe.skip('GraphCMSAsset', () => {
describe('should properly render an asset from GraphCMS', () => {
describe('when the asset is used as an image (
)', () => {
test('when relying on default "logo" property, it should apply the internal default properties', () => {
diff --git a/src/types/data/Airtable.ts b/src/types/data/Airtable.ts
new file mode 100644
index 000000000..6d216e287
--- /dev/null
+++ b/src/types/data/Airtable.ts
@@ -0,0 +1,15 @@
+import { AirtableSystemFields } from './AirtableSystemFields';
+
+/**
+ * Airtable record
+ * Use generic "fields" field
+ *
+ * There are a few differences between the Airtable record format and the one we will return after sanitising it.
+ * So we force all props in "fields" to be optional to avoid running into TS issues
+ */
+export declare type AirtableRecord = {}> = {
+ id?: string;
+ fields?: Partial;
+ createdTime?: string;
+ __typename?: string; // Not available upon fetch, made available after sanitising
+};
diff --git a/src/types/data/AirtableDataset.ts b/src/types/data/AirtableDataset.ts
new file mode 100644
index 000000000..0967739ab
--- /dev/null
+++ b/src/types/data/AirtableDataset.ts
@@ -0,0 +1,12 @@
+import { BaseTable } from '../../utils/api/fetchAirtableTable';
+import { AirtableRecord } from './Airtable';
+
+/**
+ * Dataset containing records split by table
+ * Used to resolve links (relationships) between records
+ *
+ * @example { Customer: Customer[]> , Theme: Theme[]> }
+ */
+export declare type AirtableDataset = {
+ [key in BaseTable]?: AirtableRecord[];
+}
diff --git a/src/types/data/AirtableFieldsMapping.ts b/src/types/data/AirtableFieldsMapping.ts
new file mode 100644
index 000000000..ce3b30809
--- /dev/null
+++ b/src/types/data/AirtableFieldsMapping.ts
@@ -0,0 +1,15 @@
+import { BaseTable } from '../../utils/api/fetchAirtableTable';
+
+/**
+ * Mapping of Airtable fields
+ *
+ * Airtable doesn't tell us if a field "products" is supposed to be an instance of "Product"
+ * This helps dynamically resolving such links (relationships) between records by manually defining which fields should be mapped to which entity
+ *
+ * For the sake of simplicity, DEFAULT_FIELDS_MAPPING contains all mappings (singular/plural)
+ *
+ * @example { customer: Customer, customers: Customer, products: Product }
+ */
+export declare type AirtableFieldsMapping = {
+ [key: string]: BaseTable;
+}
diff --git a/src/types/data/AirtableSystemFields.ts b/src/types/data/AirtableSystemFields.ts
new file mode 100644
index 000000000..1415252cf
--- /dev/null
+++ b/src/types/data/AirtableSystemFields.ts
@@ -0,0 +1,9 @@
+/**
+ * Contains Airtable record common fields, known as "System fields".
+ *
+ * Those fields are available on any Airtable record.
+ */
+export declare type AirtableSystemFields = {
+ id: string;
+ createdTime: string;
+}
diff --git a/src/types/data/Asset.ts b/src/types/data/Asset.ts
index 57654f2f5..247a78f9b 100644
--- a/src/types/data/Asset.ts
+++ b/src/types/data/Asset.ts
@@ -1,24 +1,23 @@
-import { GraphCMSSystemFields } from './GraphCMSSystemFields';
+import { AirtableSystemFields } from './AirtableSystemFields';
+export type AssetThumbnail = {
+ url: string;
+ width: number;
+ height: number;
+}
+
+/**
+ * An asset is a Airtable "Attachment" field
+ *
+ * All fields are managed internally by Airtable and we have no control over them (they're not columns)
+ */
export declare type Asset = {
- id?: string;
- handle?: string;
- fileName?: string;
- height?: number | string;
- width?: number | string;
+ url: string;
+ filename: string;
size?: number;
- mimeType?: string;
- url?: string; // Field added at runtime by GraphCMS asset's provider - See https://www.filestack.com/
-
- // XXX Additional fields that do not exist on the native GraphCMS Asset model, but you can add them and they'll be handled when using GraphCMSAsset, for instance
- alt?: string;
- classes?: string;
- defaultTransformations?: object;
- importUrl?: string;
- key?: string;
- linkTarget?: string;
- linkUrl?: string;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- style?: string | object | any;
- title?: string;
-} & GraphCMSSystemFields;
+ type?: string;
+ thumbnails?: {
+ small?: AssetThumbnail;
+ large?: AssetThumbnail;
+ }
+} & AirtableSystemFields;
diff --git a/src/types/data/AssetTransformations.ts b/src/types/data/AssetTransformations.ts
index 384b39b7e..2ee8f697a 100644
--- a/src/types/data/AssetTransformations.ts
+++ b/src/types/data/AssetTransformations.ts
@@ -1,7 +1,7 @@
-import { GraphCMSSystemFields } from './GraphCMSSystemFields';
+import { AirtableSystemFields } from './AirtableSystemFields';
export declare type AssetTransformations = {
id?: string;
height?: number;
width?: number;
-} & GraphCMSSystemFields;
+} & AirtableSystemFields;
diff --git a/src/types/data/Customer.ts b/src/types/data/Customer.ts
index 3764b2f52..928efeca3 100644
--- a/src/types/data/Customer.ts
+++ b/src/types/data/Customer.ts
@@ -1,11 +1,14 @@
import { RichText } from '../RichText';
-import { GraphCMSSystemFields } from './GraphCMSSystemFields';
+import { AirtableSystemFields } from './AirtableSystemFields';
import { Theme } from './Theme';
export declare type Customer = {
- id?: string;
ref?: string;
label?: string;
+ labelEN?: string;
+ labelFR?: string;
theme?: Theme;
terms?: RichText;
-} & GraphCMSSystemFields;
+ termsEN?: RichText;
+ termsFR?: RichText;
+} & AirtableSystemFields;
diff --git a/src/types/data/GraphCMSSystemFields.ts b/src/types/data/GraphCMSSystemFields.ts
deleted file mode 100644
index d38226f25..000000000
--- a/src/types/data/GraphCMSSystemFields.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-/**
- * Contains GraphCMS record common fields, known as "System fields".
- *
- * Those fields are available on any GraphCMS entity.
- * Fields are all marked as optional, because we don't necessarily fetch them when running GraphQL queries.
- */
-export declare type GraphCMSSystemFields = {
- // id: string; // XXX Should specified in sub-types if required - See https://github.com/microsoft/TypeScript/issues/36286
- createdAt?: string;
- status?: string;
- updatedAt?: string;
- __typename?: string;
-}
diff --git a/src/types/data/Product.ts b/src/types/data/Product.ts
index 85b536248..92116e757 100644
--- a/src/types/data/Product.ts
+++ b/src/types/data/Product.ts
@@ -1,5 +1,5 @@
+import { AirtableSystemFields } from './AirtableSystemFields';
import { Asset } from './Asset';
-import { GraphCMSSystemFields } from './GraphCMSSystemFields';
export declare type Product = {
id?: string;
@@ -7,4 +7,4 @@ export declare type Product = {
description?: string;
images?: Asset[];
price?: number;
-} & GraphCMSSystemFields;
+} & AirtableSystemFields;
diff --git a/src/types/data/Theme.ts b/src/types/data/Theme.ts
index 4832f2b98..96e3b41e7 100644
--- a/src/types/data/Theme.ts
+++ b/src/types/data/Theme.ts
@@ -1,8 +1,8 @@
import { Asset } from './Asset';
-import { GraphCMSSystemFields } from './GraphCMSSystemFields';
+import { AirtableSystemFields } from './AirtableSystemFields';
export declare type Theme = {
id?: string;
primaryColor?: string;
logo?: Asset;
-} & GraphCMSSystemFields;
+} & AirtableSystemFields;
diff --git a/src/utils/api/fetchAirtable.test.ts b/src/utils/api/fetchAirtable.test.ts
new file mode 100644
index 000000000..857bdac9e
--- /dev/null
+++ b/src/utils/api/fetchAirtable.test.ts
@@ -0,0 +1,14 @@
+import fetchAirtableTable from './fetchAirtableTable';
+
+// TODO "fetch" is not found here - See https://github.com/vercel/next.js/discussions/13678
+// Skipped until resolved
+describe.skip(`utils/api/fetchAirtable.ts`, () => {
+ const results = {};
+ describe(`fetchAirtableTable`, () => {
+ describe(`should fetch correctly`, () => {
+ test(`when not using any option`, async () => {
+ expect(await fetchAirtableTable('Customer')).toMatchObject(results);
+ });
+ });
+ });
+});
diff --git a/src/utils/api/fetchAirtableTable.ts b/src/utils/api/fetchAirtableTable.ts
new file mode 100644
index 000000000..b728ca831
--- /dev/null
+++ b/src/utils/api/fetchAirtableTable.ts
@@ -0,0 +1,72 @@
+import deepmerge from 'deepmerge';
+import size from 'lodash.size';
+import { AirtableRecord } from '../../types/data/Airtable';
+import fetchJSON from './fetchJSON';
+
+const AT_API_BASE_PATH = 'https://api.airtable.com';
+const AT_API_VERSION = 'v0';
+
+export type ApiOptions = {
+ additionalHeaders?: { [key: string]: string };
+ baseId?: string;
+ maxRecords?: number;
+}
+
+/**
+ * Response returned by Airtable when fetching a table (list of records)
+ */
+export type GenericListApiResponse = {
+ records: Record[];
+}
+
+/**
+ * List of tables available in the AT Base
+ */
+export type BaseTable = 'Customer' | 'Product' | 'Theme';
+
+const defaultApiOptions: ApiOptions = {
+ additionalHeaders: {
+ Authorization: `Bearer ${process.env.AIRTABLE_API_KEY}`,
+ },
+ baseId: process.env.AIRTABLE_BASE_ID,
+ maxRecords: 10000,
+};
+
+/**
+ * Fetches Airtable API to retrieve all records within the given table
+ * Super simple implementation that only takes care of fetching a whole table
+ *
+ * Uses NRN own implementation instead of the official Airtable JS API
+ * - Ours is much smaller (lightweight) vs theirs - See https://bundlephobia.com/result?p=airtable@0.8.1
+ * - We only need to perform "table wide reads" and don't need all the extra create/update/delete features
+ * - Their TS definitions sucks and are out-of-sync, according to other people - See https://github.com/Airtable/airtable.js/issues/34#issuecomment-630632566
+ *
+ * @example TS types will be automatically inferred, you can also alias "records" to a more obvious name
+ * const { records: customers } = await fetchAirtableTable>>('Customer');
+ * const { records: products } = await fetchAirtableTable>>('Product');
+ *
+ * If you prefer to use their official API:
+ * Alternatively, you can use the official Airtable JS API at https://github.com/airtable/airtable.js/
+ * Async/Await example - https://github.com/UnlyEd/airtable-backups-boilerplate/blob/master/src/utils/airtableParser.js
+ */
+const fetchAirtableTable: (
+ table: BaseTable,
+ options?: ApiOptions,
+) => Promise = async (table: BaseTable, options?: ApiOptions) => {
+ options = deepmerge(defaultApiOptions, options || {});
+ const { additionalHeaders, baseId } = options;
+ const url = `${AT_API_BASE_PATH}/${AT_API_VERSION}/${baseId}/${table}`;
+
+ // eslint-disable-next-line no-console
+ console.debug(`Fetching airtable API at "${url}" with headers`, additionalHeaders);
+ const results = await fetchJSON(url, {
+ headers: additionalHeaders,
+ });
+
+ // eslint-disable-next-line no-console
+ console.debug(`[${table}] ${size(results?.records)} airtable API records fetched`);
+
+ return results;
+};
+
+export default fetchAirtableTable;
diff --git a/src/utils/api/fetchCustomer.ts b/src/utils/api/fetchCustomer.ts
new file mode 100644
index 000000000..0aae6432a
--- /dev/null
+++ b/src/utils/api/fetchCustomer.ts
@@ -0,0 +1,30 @@
+import find from 'lodash.find';
+import { AirtableRecord } from '../../types/data/Airtable';
+import { AirtableDataset } from '../../types/data/AirtableDataset';
+import { Customer } from '../../types/data/Customer';
+import { Product } from '../../types/data/Product';
+import { Theme } from '../../types/data/Theme';
+import { sanitizeRecord } from '../data/airtableRecord';
+import fetchAirtableTable, { GenericListApiResponse } from './fetchAirtableTable';
+
+/**
+ * Fetches all Airtable tables and returns a consolidated Customer object with all relations resolved
+ *
+ * Relations are only resolved on the main level (to avoid circular dependencies)
+ */
+const fetchCustomer = async (preferredLocales: string[]): Promise => {
+ const customerRef = process.env.NEXT_PUBLIC_CUSTOMER_REF;
+ const { records: airtableCustomers } = await fetchAirtableTable>>('Customer');
+ const { records: airtableThemes } = await fetchAirtableTable>>('Theme');
+ const { records: airtableProducts } = await fetchAirtableTable>>('Product');
+ const dataset: AirtableDataset = {
+ Customer: airtableCustomers,
+ Theme: airtableThemes,
+ Product: airtableProducts,
+ };
+ const airtableCustomer = find(airtableCustomers, { fields: { ref: customerRef } });
+
+ return sanitizeRecord(airtableCustomer, dataset, preferredLocales);
+};
+
+export default fetchCustomer;
diff --git a/src/utils/data/airtableRecord.ts b/src/utils/data/airtableRecord.ts
new file mode 100644
index 000000000..9fe2803ed
--- /dev/null
+++ b/src/utils/data/airtableRecord.ts
@@ -0,0 +1,170 @@
+import endsWith from 'lodash.endswith';
+import find from 'lodash.find';
+import get from 'lodash.get';
+import isArray from 'lodash.isarray';
+import map from 'lodash.map';
+import size from 'lodash.size';
+import { AirtableRecord } from '../../types/data/Airtable';
+import { AirtableDataset } from '../../types/data/AirtableDataset';
+import { AirtableFieldsMapping } from '../../types/data/AirtableFieldsMapping';
+import { BaseTable } from '../api/fetchAirtableTable';
+import { DEFAULT_LOCALE } from '../i18n/i18n';
+
+/**
+ * If the field ends with any of the locales then it's considered as a localised field
+ *
+ * @param fieldName
+ * @param locales
+ */
+const isLocalisedField = (fieldName: string, locales: string[]): boolean => {
+ let isLocalisedField = false;
+
+ map(locales, (locale: string) => {
+ if(endsWith(fieldName, locale.toUpperCase())){
+ isLocalisedField = true;
+ }
+ });
+
+ return isLocalisedField;
+};
+
+/**
+ * Resolve the generic name of a localised field
+ * The generic name will eventually be used to contain the value we want to display to the end-user, based on localisation
+ *
+ * @example getGenericLocalisedField('labelEN') => 'label'
+ * @example getGenericLocalisedField('descriptionFR') => 'description'
+ *
+ * @param fieldName
+ */
+const getGenericLocalisedField = (fieldName: string): string => {
+ return fieldName.slice(0, fieldName.length - DEFAULT_LOCALE.length); // This only works if all locales use the same number of chars (2, currently)
+};
+
+/**
+ * Resolve whether the record contains a generic localised field
+ * If it does, it means the a higher priority localised value has been applied already
+ *
+ * @param sanitizedRecord
+ * @param fieldName
+ */
+const hasGenericLocalisedField = (sanitizedRecord: AirtableRecord, fieldName: string): boolean => {
+ return get(sanitizedRecord, getGenericLocalisedField(fieldName), false);
+};
+
+/**
+ * Default field mappings between entities (helps resolve relationships)
+ *
+ * The key (left) represents a field name
+ * The value (right) represents an Airtable entity (table)
+ *
+ * It can be extended for advanced use case
+ * (like when a field with a generic name like "items" is linked to a table "Product")
+ */
+export const DEFAULT_FIELDS_MAPPING: AirtableFieldsMapping = {
+ customer: 'Customer',
+ customers: 'Customer',
+ product: 'Product',
+ products: 'Product',
+ theme: 'Theme',
+ themes: 'Theme',
+};
+
+/**
+ * Sanitize an airtable record into a proper type.
+ * Avoids manipulating Airtable's weird object, and resolve fields linking.
+ *
+ * @param record
+ * @param dataset
+ * @param preferredLocales
+ * @param tableLinks
+ */
+export const sanitizeRecord = (record: AirtableRecord, dataset: AirtableDataset, preferredLocales: string[], tableLinks: AirtableFieldsMapping = DEFAULT_FIELDS_MAPPING): Record => {
+ console.log('preferredLocales', preferredLocales);
+ const sanitizedRecord: Record = {
+ id: record.id,
+ createdTime: record.createdTime,
+ } as Record;
+
+ // Resolve relationships
+ map(record?.fields, (fieldValue: any | any[], fieldName: string) => {
+ // If the field exists in the tableLinks, then it's a relation to resolve
+ const fieldRecordType: BaseTable | null = get(tableLinks, fieldName, null);
+
+ if (fieldRecordType) {
+ if (isArray(fieldValue) && size(fieldValue) > 1) {
+ map(fieldValue, (subFieldValue: any, subFieldName: string) => {
+ const linkedRecord = find(dataset?.[fieldRecordType], { id: subFieldValue });
+
+ // Init array if not yet init
+ if (!sanitizedRecord[fieldName]) {
+ sanitizedRecord[fieldName] = [];
+ }
+
+ // If a linked record has been resolved, apply it
+ if (typeof linkedRecord !== 'undefined') {
+ sanitizedRecord[fieldName].push({
+ ...linkedRecord,
+ __typename: fieldRecordType,
+ });
+ } else {
+ // Otherwise, keep the existing data
+ // It's possible the "dataset" doesn't contain the related field
+ sanitizedRecord[fieldName] = fieldValue; // TODO optimisation, currently applied as many times as there are items, could be done only once
+ }
+ });
+ } else {
+ const id = isArray(fieldValue) ? fieldValue[0] : fieldValue;
+ const linkedRecord = find(dataset?.[fieldRecordType], { id });
+ sanitizedRecord[fieldName] = linkedRecord;
+
+ // If a linked record has been resolved, apply it
+ if (typeof linkedRecord !== 'undefined') {
+ sanitizedRecord[fieldName] = {
+ ...linkedRecord,
+ __typename: fieldRecordType,
+ };
+ } else {
+ // Otherwise, keep the existing data
+ // It's possible the "dataset" doesn't contain the related field
+ sanitizedRecord[fieldName] = fieldValue;
+ }
+ }
+ } else {
+ // Otherwise, it's a normal field and must be copied over
+ sanitizedRecord[fieldName] = fieldValue;
+ }
+
+ // If the record contains the generic localised field, then it's been resolved already in a previous loop iteration (ignore)
+ const hasBeenLocalised = isLocalisedField ? hasGenericLocalisedField(sanitizedRecord, fieldName) : false;
+
+ // Resolve value to use, depending on what value is available
+ // Basically, if the current locale is "FR" and we got a value for a "labelFR" then we use it
+ // If we don't have a value in "labelFR" then we fallback to `label${DEFAULT_LOCALE}` (i.e: labelEN)
+ if (isLocalisedField(fieldName, preferredLocales) && !hasBeenLocalised) {
+ const genericLocalisedField = getGenericLocalisedField(fieldName);
+
+ map(preferredLocales, (locale: string, i: number) => {
+ const hasBeenLocalised = isLocalisedField(fieldName, preferredLocales) ? hasGenericLocalisedField(sanitizedRecord, fieldName) : false;
+ const value = get(record.fields, `${genericLocalisedField}${locale.toUpperCase()}`); // Look into the record, because field may not have been copied onto sanitizedFields yet
+
+ if (value && !hasBeenLocalised) {
+ sanitizedRecord[genericLocalisedField] = value;
+ }
+ });
+ }
+ });
+
+ // Copy system fields
+ sanitizedRecord.id = record.id;
+ sanitizedRecord.createdTime = record.createdTime;
+
+ // Auto resolve the main record type
+ map(dataset, (records: AirtableRecord[], recordType: BaseTable) => {
+ if (find(records, { id: record.id })) {
+ sanitizedRecord.__typename = recordType;
+ }
+ });
+
+ return sanitizedRecord;
+};
diff --git a/src/utils/nextjs/SSG.ts b/src/utils/nextjs/SSG.ts
index 404f572d1..7a723526e 100644
--- a/src/utils/nextjs/SSG.ts
+++ b/src/utils/nextjs/SSG.ts
@@ -13,6 +13,7 @@ import { StaticPathsOutput } from '../../types/nextjs/StaticPathsOutput';
import { StaticPropsInput } from '../../types/nextjs/StaticPropsInput';
import { StaticPropsOutput } from '../../types/nextjs/StaticPropsOutput';
import { SSGPageProps } from '../../types/pageProps/SSGPageProps';
+import fetchCustomer from '../api/fetchCustomer';
import { prepareGraphCMSLocaleHeader } from '../gql/graphcms';
import { createApolloClient } from '../gql/graphql';
import { DEFAULT_LOCALE, resolveFallbackLanguage } from '../i18n/i18n';
@@ -123,7 +124,7 @@ export const getExamplesCommonStaticProps: GetStaticProps