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