ra-data-local-forage
Version:
LocalForage data provider for react-admin
289 lines (273 loc) • 10.1 kB
text/typescript
import fakeRestProvider from 'ra-data-fakerest';
import {
CreateParams,
DataProvider,
GetListParams,
GetOneParams,
GetManyParams,
GetManyReferenceParams,
Identifier,
DeleteParams,
RaRecord,
UpdateParams,
UpdateManyParams,
DeleteManyParams,
} from 'ra-core';
import pullAt from 'lodash/pullAt.js';
import localforage from 'localforage';
/**
* Respond to react-admin data queries using a localForage for storage.
*
* Useful for local-first web apps.
*
* @example // initialize with no data
*
* import localForageDataProvider from 'ra-data-local-forage';
* const dataProvider = localForageDataProvider();
*
* @example // initialize with default data (will be ignored if data has been modified by user)
*
* import localForageDataProvider from 'ra-data-local-forage';
* const dataProvider = localForageDataProvider({
* defaultData: {
* posts: [
* { id: 0, title: 'Hello, world!' },
* { id: 1, title: 'FooBar' },
* ],
* comments: [
* { id: 0, post_id: 0, author: 'John Doe', body: 'Sensational!' },
* { id: 1, post_id: 0, author: 'Jane Doe', body: 'I agree' },
* ],
* }
* });
*/
export default (params?: LocalForageDataProviderParams): DataProvider => {
const {
defaultData = {},
prefixLocalForageKey = 'ra-data-local-forage-',
loggingEnabled = false,
} = params || {};
let data: Record<string, any> | undefined;
let baseDataProvider: DataProvider | undefined;
let initializePromise: Promise<void> | undefined;
const getLocalForageData = async (): Promise<any> => {
const keys = await localforage.keys();
const keyFiltered = keys.filter(key => {
return key.includes(prefixLocalForageKey);
});
if (keyFiltered.length === 0) {
return undefined;
}
const localForageData: Record<string, any> = {};
for (const key of keyFiltered) {
const keyWithoutPrefix = key.replace(prefixLocalForageKey, '');
const res = await localforage.getItem(key);
localForageData[keyWithoutPrefix] = res || [];
}
return localForageData;
};
const initialize = async () => {
if (!initializePromise) {
initializePromise = initializeProvider();
}
return initializePromise;
};
const initializeProvider = async () => {
const localForageData = await getLocalForageData();
data = localForageData ?? defaultData;
baseDataProvider = fakeRestProvider(
data,
loggingEnabled
) as DataProvider;
};
// Persist in localForage
const updateLocalForage = (resource: string) => {
if (!data) {
throw new Error('The dataProvider is not initialized.');
}
localforage.setItem(
`${prefixLocalForageKey}${resource}`,
data[resource]
);
};
return {
// read methods are just proxies to FakeRest
getList: async <RecordType extends RaRecord = any>(
resource: string,
params: GetListParams
) => {
await initialize();
if (!baseDataProvider) {
throw new Error('The dataProvider is not initialized.');
}
return baseDataProvider
.getList<RecordType>(resource, params)
.catch(error => {
if (error.code === 1) {
// undefined collection error: hide the error and return an empty list instead
return { data: [], total: 0 };
} else {
throw error;
}
});
},
getOne: async <RecordType extends RaRecord = any>(
resource: string,
params: GetOneParams<any>
) => {
await initialize();
if (!baseDataProvider) {
throw new Error('The dataProvider is not initialized.');
}
return baseDataProvider.getOne<RecordType>(resource, params);
},
getMany: async <RecordType extends RaRecord = any>(
resource: string,
params: GetManyParams<RecordType>
) => {
await initialize();
if (!baseDataProvider) {
throw new Error('The dataProvider is not initialized.');
}
return baseDataProvider.getMany<RecordType>(resource, params);
},
getManyReference: async <RecordType extends RaRecord = any>(
resource: string,
params: GetManyReferenceParams
) => {
await initialize();
if (!baseDataProvider) {
throw new Error('The dataProvider is not initialized.');
}
return baseDataProvider
.getManyReference<RecordType>(resource, params)
.catch(error => {
if (error.code === 1) {
// undefined collection error: hide the error and return an empty list instead
return { data: [], total: 0 };
} else {
throw error;
}
});
},
// update methods need to persist changes in localForage
update: async <RecordType extends RaRecord = any>(
resource: string,
params: UpdateParams<any>
) => {
checkResource(resource);
await initialize();
if (!data) {
throw new Error('The dataProvider is not initialized.');
}
if (!baseDataProvider) {
throw new Error('The dataProvider is not initialized.');
}
const index = data[resource].findIndex(
(record: { id: any }) => record.id === params.id
);
data[resource][index] = {
...data[resource][index],
...params.data,
};
updateLocalForage(resource);
return baseDataProvider.update<RecordType>(resource, params);
},
updateMany: async (resource: string, params: UpdateManyParams<any>) => {
checkResource(resource);
await initialize();
if (!baseDataProvider) {
throw new Error('The dataProvider is not initialized.');
}
params.ids.forEach((id: Identifier) => {
if (!data) {
throw new Error('The dataProvider is not initialized.');
}
const index = data[resource].findIndex(
(record: { id: Identifier }) => record.id === id
);
data[resource][index] = {
...data[resource][index],
...params.data,
};
});
updateLocalForage(resource);
return baseDataProvider.updateMany(resource, params);
},
create: async <RecordType extends Omit<RaRecord, 'id'> = any>(
resource: string,
params: CreateParams<any>
) => {
checkResource(resource);
await initialize();
if (!baseDataProvider) {
throw new Error('The dataProvider is not initialized.');
}
// we need to call the fakerest provider first to get the generated id
return baseDataProvider
.create<RecordType>(resource, params)
.then(response => {
if (!data) {
throw new Error('The dataProvider is not initialized.');
}
if (!data.hasOwnProperty(resource)) {
data[resource] = [];
}
data[resource].push(response.data);
updateLocalForage(resource);
return response;
});
},
delete: async <RecordType extends RaRecord = any>(
resource: string,
params: DeleteParams<RecordType>
) => {
checkResource(resource);
await initialize();
if (!baseDataProvider) {
throw new Error('The dataProvider is not initialized.');
}
if (!data) {
throw new Error('The dataProvider is not initialized.');
}
const index = data[resource].findIndex(
(record: { id: any }) => record.id === params.id
);
pullAt(data[resource], [index]);
updateLocalForage(resource);
return baseDataProvider.delete<RecordType>(resource, params);
},
deleteMany: async (resource: string, params: DeleteManyParams<any>) => {
checkResource(resource);
await initialize();
if (!baseDataProvider) {
throw new Error('The dataProvider is not initialized.');
}
if (!data) {
throw new Error('The dataProvider is not initialized.');
}
const indexes = params.ids.map((id: any) => {
if (!data) {
throw new Error('The dataProvider is not initialized.');
}
return data[resource].findIndex(
(record: any) => record.id === id
);
});
pullAt(data[resource], indexes);
updateLocalForage(resource);
return baseDataProvider.deleteMany(resource, params);
},
};
};
const checkResource = resource => {
if (['__proto__', 'constructor', 'prototype'].includes(resource)) {
// protection against prototype pollution
throw new Error(`Invalid resource key: ${resource}`);
}
};
export interface LocalForageDataProviderParams {
defaultData?: any;
prefixLocalForageKey?: string;
loggingEnabled?: boolean;
}