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

Feat: Add ra-data-localforage package #7959

Merged
merged 4 commits into from
Sep 29, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ build-ra-data-json-server:
@echo "Transpiling ra-data-json-server files...";
@cd ./packages/ra-data-json-server && yarn build

build-ra-data-localforage:
@echo "Transpiling ra-data-localforage files...";
@cd ./packages/ra-data-localforage && yarn build

build-ra-data-localstorage:
@echo "Transpiling ra-data-localstorage files...";
@cd ./packages/ra-data-localstorage && yarn build
Expand Down Expand Up @@ -108,7 +112,7 @@ build-data-generator:
@echo "Transpiling data-generator files...";
@cd ./examples/data-generator && yarn build

build: build-ra-core build-ra-ui-materialui build-ra-data-fakerest build-ra-data-json-server build-ra-data-localstorage build-ra-data-simple-rest build-ra-data-graphql build-ra-data-graphql-simple build-ra-i18n-polyglot build-ra-input-rich-text build-data-generator build-ra-language-english build-ra-language-french build-react-admin build-ra-no-code ## compile ES6 files to JS
build: build-ra-core build-ra-ui-materialui build-ra-data-fakerest build-ra-data-json-server build-ra-data-localforage build-ra-data-localstorage build-ra-data-simple-rest build-ra-data-graphql build-ra-data-graphql-simple build-ra-i18n-polyglot build-ra-input-rich-text build-data-generator build-ra-language-english build-ra-language-french build-react-admin build-ra-no-code ## compile ES6 files to JS

doc: ## compile doc as html and launch doc web server
@yarn doc
Expand Down
1 change: 1 addition & 0 deletions docs/DataProviderList.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ If you don't know where to start, use any of the following:
* [marmelab/ra-data-json-server](https://github.com/marmelab/react-admin/tree/master/packages/ra-data-json-server): Similar to the previous one, but requires an API powered by JSONServer.
* [marmelab/ra-data-simple-rest](https://github.com/marmelab/react-admin/tree/master/packages/ra-data-simple-rest): A basic REST adapter that reflects the structure of many APIs
* [marmelab/ra-data-localstorage](https://github.com/marmelab/react-admin/tree/master/packages/ra-data-localstorage): Persists user editions in local storage. This allows local-first apps, and can be useful in tests.
* [marmelab/ra-data-localforage](https://github.com/marmelab/react-admin/tree/master/packages/ra-data-localforage): Persists user editions in IndexedDB. Fallback to WebSQL or localStorage. This allows local-first apps, and can be useful in tests.

**Tip**: Since dataProviders all present the same interface, you can use one dataProvider during early prototyping / development phases, then switch to the dataProvider that fits your production infrastructure.

Expand Down
96 changes: 96 additions & 0 deletions packages/ra-data-localforage/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# ra-data-localForage

A data provider for [react-admin](https://github.com/marmelab/react-admin) that uses [localForage](https://localforage.github.io/localForage/). It uses asynchronous storage (IndexedDB or WebSQL) with a simple, localStorage-like API. It fallback to localStorage in browsers with no IndexedDB or WebSQL support.

The provider issues no HTTP requests, every operation happen locally in the browser. User editions are persisted across refreshes and between sessions. This allows local-first apps and can be useful in tests.

## Installation

```sh
npm install --save ra-data-local-forage
```

## Usage

```jsx
// in src/App.js
import * as React from "react";
import { Admin, Resource } from 'react-admin';
import localForageDataProvider from 'ra-data-local-forage';

import { PostList } from './posts';

const App = () => {
const [dataProvider, setDataProvider] = React.useState<DataProvider | null>(null);

React.useEffect(() => {
async function startDataProvider() {
const localForageProvider = await localForageDataProvider();
setDataProvider(localForageProvider);
}

if (dataProvider === null) {
startDataProvider();
}
}, [dataProvider]);

// hide the admin until the data provider is ready
if (!dataProvider) return <p>Loading...</p>;

return (
<Admin dataProvider={dataProvider}>
<Resource name="posts" list={ListGuesser}/>
</Admin>
);
};

export default App;
```

### defaultData

By default, the data provider starts with no resource. To set default data if the IndexedDB is empty, pass a JSON object as the `defaultData` argument:

```js
const dataProvider = await 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' },
],
}
});
```

The `defaultData` parameter must be an object literal with one key for each resource type. Values are arrays of resources. Resources must be object literals with at least an `id` key.

Foreign keys are also supported: just name the field `{related_resource_name}_id` and give an existing value.

### loggingEnabled

As this data provider doesn't use the network, you can't debug it using the network tab of your browser developer tools. However, it can log all calls (input and output) in the console, provided you set the `loggingEnabled` parameter:

```js
const dataProvider = await localForageDataProvider({
loggingEnabled: true
});
```

## Features

This data provider uses [FakeRest](https://github.com/marmelab/FakeRest) under the hood. That means that it offers the same features:

- pagination
- sorting
- filtering by column
- filtering by the `q` full-text search
- filtering numbers and dates greater or less than a value
- embedding related resources

## License

This data provider is licensed under the MIT License and sponsored by [marmelab](https://marmelab.com).
55 changes: 55 additions & 0 deletions packages/ra-data-localforage/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"name": "ra-data-local-forage",
"version": "4.2.3",
"description": "LocalForage data provider for react-admin",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "dist/cjs/index.d.ts",
"sideEffects": false,
"files": [
"LICENSE",
"*.md",
"dist",
"src"
],
"repository": {
"type": "git",
"url": "git+https://github.com/marmelab/react-admin.git"
},
"keywords": [
"reactjs",
"react",
"react-admin",
"rest",
"fakerest",
"local",
"localForage",
"IndexedDB",
"WebSQL"
],
"author": "Anthony RIMET",
"license": "MIT",
"bugs": {
"url": "https://github.com/marmelab/react-admin/issues"
},
"homepage": "https://github.com/marmelab/react-admin#readme",
"scripts": {
"build": "yarn run build-cjs && yarn run build-esm",
"build-cjs": "rimraf ./dist/cjs && tsc --outDir dist/cjs",
"build-esm": "rimraf ./dist/esm && tsc --outDir dist/esm --module es2015",
"watch": "tsc --outDir dist/esm --module es2015 --watch"
},
"dependencies": {
"localforage": "^1.7.1",
"lodash": "~4.17.5",
"ra-data-fakerest": "^4.2.3"
},
"devDependencies": {
"cross-env": "^5.2.0",
"rimraf": "^3.0.2",
"typescript": "^4.4.0"
},
"peerDependencies": {
"ra-core": "*"
}
}
201 changes: 201 additions & 0 deletions packages/ra-data-localforage/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/* eslint-disable import/no-anonymous-default-export */
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';
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 = await 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 = await 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 async (
params?: LocalForageDataProviderParams
): Promise<DataProvider> => {
const {
defaultData = {},
prefixLocalForageKey = 'ra-data-local-forage-',
loggingEnabled = false,
} = params || {};

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 localForageData = await getLocalForageData();
const data = localForageData ? localForageData : defaultData;
arimet marked this conversation as resolved.
Show resolved Hide resolved

// Persist in localForage
const updateLocalForage = (resource: string) => {
localforage.setItem(
`${prefixLocalForageKey}${resource}`,
data[resource]
);
};

const baseDataProvider = fakeRestProvider(
data,
loggingEnabled
) as DataProvider;

return {
// read methods are just proxies to FakeRest
getList: <RecordType extends RaRecord = any>(
resource: string,
params: GetListParams
) => {
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: <RecordType extends RaRecord = any>(
resource: string,
params: GetOneParams<any>
) => baseDataProvider.getOne<RecordType>(resource, params),
getMany: <RecordType extends RaRecord = any>(
resource: string,
params: GetManyParams
) => baseDataProvider.getMany<RecordType>(resource, params),
getManyReference: <RecordType extends RaRecord = any>(
resource: string,
params: GetManyReferenceParams
) =>
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: <RecordType extends RaRecord = any>(
resource: string,
params: UpdateParams<any>
) => {
const index = data[resource].findIndex(
(record: { id: any }) => record.id === params.id
);
data[resource][index] = {

Check notice

Code scanning

Prototype-polluting assignment

This assignment may alter Object.prototype if a malicious '__proto__' string is injected from [library input](1).
...data[resource][index],
...params.data,
};
updateLocalForage(resource);
return baseDataProvider.update<RecordType>(resource, params);
},
updateMany: (resource: string, params: UpdateManyParams<any>) => {
params.ids.forEach((id: Identifier) => {
const index = data[resource].findIndex(
(record: { id: Identifier }) => record.id === id
);
data[resource][index] = {

Check notice

Code scanning

Prototype-polluting assignment

This assignment may alter Object.prototype if a malicious '__proto__' string is injected from [library input](1).
...data[resource][index],
...params.data,
};
});
updateLocalForage(resource);
return baseDataProvider.updateMany(resource, params);
},
create: <RecordType extends RaRecord = any>(
resource: string,
params: CreateParams<any>
) => {
// we need to call the fakerest provider first to get the generated id
return baseDataProvider
.create<RecordType>(resource, params)
.then(response => {
if (!data.hasOwnProperty(resource)) {
data[resource] = [];
}
data[resource].push(response.data);
updateLocalForage(resource);
return response;
});
},
delete: <RecordType extends RaRecord = any>(
resource: string,
params: DeleteParams<RecordType>
) => {
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: (resource: string, params: DeleteManyParams<any>) => {
const indexes = params.ids.map((id: any) =>
data[resource].findIndex((record: any) => record.id === id)
);
pullAt(data[resource], indexes);
updateLocalForage(resource);
return baseDataProvider.deleteMany(resource, params);
},
};
};

export interface LocalForageDataProviderParams {
defaultData?: any;
prefixLocalForageKey?: string;
loggingEnabled?: boolean;
}
12 changes: 12 additions & 0 deletions packages/ra-data-localforage/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "lib",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"allowJs": false
},
"exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.spec.js"],
"include": ["src"]
}
Loading