Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
170 changes: 145 additions & 25 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as Firebase from 'firebase';
import { Dispatch } from 'redux';
import {
AsyncThunkAction,
ActionCreatorWithPreparedPayload,
} from '@reduxjs/toolkit';

/**
* Action types used within actions dispatched internally. These action types
Expand Down Expand Up @@ -99,40 +103,44 @@ export interface Config {
};
}

type ArrayUnion = ['::arrayUnion', unknown];
type ArrayRemove = ['::arrayRemove', unknown];
/**
*
* mutate - Data-driven Design for writing, batching & transacting with Firestore
*
*/
type ArrayUnion = ['::arrayUnion', any | any[]];
type ArrayRemove = ['::arrayRemove', any | any[]];
type Increment = ['::increment', number];
type ServerTimestamp = ['::serverTimestamp'];
type FieldValueTuple = ArrayUnion | ArrayRemove | Increment | ServerTimestamp;

export type MutateData =
| FieldValueTuple
| number
| null
| boolean
| string
| object
| array;

export type Read = { collection: string; doc: string };

export type Write = Read & { data: Record<string, MutateData> };

export type Batch = Write[];

export type WriteFn = (readKeys: string) => Write | Write[];

type PathId = { id: string; path: string };
export type Read = PathId;
/**
* As of Mar 2021, Firestore does not support transactional queries.
* As of Nov 2021, Firestore does not support transactional queries.
* Queries are run as a standard read. Each document returned is read
* a second time in the transaction. This is a best effort. Full
* Transactional Query support is only available with firebase-admin.
*/
export type ReadQuery = Omit<Read, 'doc'> & { _slowCollectionRead: true };
export type ReadQuery = ReduxFirestoreQuerySetting;
export type ReadProvides = () => unknown;

export type Transaction = {
reads: (Read | ReadQuery)[];
writes: (WriteFn | Writes)[];
export type Write = PathId & { [key: string]: FieldValueTuple | unknown };
export type WriteFn<Reads extends string> = (reads: { [Key in Reads]: any }) =>
| Write
| Write[];

export type Batch = Write[];

export type Transaction<Type extends Record<string, unknown>> = {
reads: {
[P in keyof Type]: ReadQuery | Read | ReadProvides;
};
writes:
| WriteFn<Extract<keyof Type, string>>[]
| WriteFn<Extract<keyof Type, string>>
| Write
| Write[];
};

/**
Expand All @@ -144,7 +152,119 @@ export type Transaction = {
* cache reducer. When the change is accepted or rejected it updated the
* cache reducer to reflect data in firestore.
*/
export type mutate = (operations: Write | Batch | Transaction) => Promise;
export type mutate = <Reads extends Record<string, unknown>>(
operations: Transaction<Reads> | Batch | Write,
) => Promise;

/**
*
* createMutate - Simple wrapper for Redux Toolkit async action creators
*
*/
type Writer<ReadType extends Record<string, unknown>> =
| WriteFn<Extract<keyof ReadType, string>>
| Write;
type Writers<ReadType extends Record<string, unknown>> =
| Writer<ReadType>
| Writer<ReadType>[];

type SimpleRead<ReadType extends Record<string, unknown>> = {
[P in keyof ReadType]: ReadProvides;
};
type TransactionRead<ReadType extends Record<string, unknown>> = {
[P in keyof ReadType]: ReadQuery | Read | ReadProvides;
};

type SimpleReadFn<Payload, ReadType extends Record<string, unknown>> = (
payload: Payload,
) => SimpleRead<ReadType>;

type TransactionReadFn<Payload, ReadType extends Record<string, unknown>> = (
payload: Payload,
) => TransactionRead<ReadType>;

type SimpleMutate<Payload, ReadType extends Record<string, unknown>> = {
action: string;
read: SimpleReadFn<Payload, ReadType>;
write: Writer<ReadType>;
};
type ComplexMutate<Payload, ReadType extends Record<string, unknown>> = {
action: string;
readwrite: (payload: Payload, thunkAPI: FirebaseThunkAPI) => Writer<ReadType>;
};

type BatchMutate<Payload, ReadType extends Record<string, unknown>> = {
action: string;
read: SimpleReadFn<Payload, ReadType>;
write: Writers<ReadType>;
};
type ComplexBatch<Payload, ReadType extends Record<string, unknown>> = {
action: string;
readwrite: (
payload: Payload,
thunkAPI: FirebaseThunkAPI,
) => Writers<ReadType>;
};

type TransactionMutate<Payload, ReadType extends Record<string, unknown>> = {
action: string;
read: TransactionReadFn<Payload, ReadType>;
write: Writers<ReadType>;
};
type ComplexTransaction<Payload, ReadType extends Record<string, unknown>> = {
action: string;
readwrite: (
payload: Payload,
thunkAPI: FirebaseThunkAPI,
) => {
read: TransactionRead<ReadType>;
write: Writers<ReadType>;
};
};

type Mutations<Payload, ReadType extends Record<string, unknown>> =
| SimpleMutate<Payload, ReadType>
| ComplexMutate<Payload, ReadType>
| BatchMutate<Payload, ReadType>
| ComplexBatch<Payload, ReadType>
| TransactionMutate<Payload, ReadType>
| ComplexTransaction<Payload, ReadType>;

export function createMutate<Payload, ReadType extends Record<string, unknown>>(
mutation: Mutations<Payload, ReadType, ReadType>,
): ((arg: Payload) => AsyncThunkAction<any, void, {}>) & {
pending: ActionCreatorWithPreparedPayload<
[string, void],
undefined,
string,
never,
{
arg: Payload;
requestId: string;
}
>;
rejected: ActionCreatorWithPreparedPayload<
[string, void],
undefined,
string,
never,
{
arg: Payload;
requestId: string;
}
>;
fulfilled: ActionCreatorWithPreparedPayload<
[string, void],
undefined,
string,
never,
{
arg: Payload;
requestId: string;
}
>;
typePrefix: string;
};

/**
* A redux store enhancer that adds store.firebase (passed to React component
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
},
"dependencies": {
"debug": "^4.3.2",
"immer": "9.0.5",
"immer": "9.0.6",
"lodash": "^4.17.21",
"reduce-reducers": "^1.0.4"
},
Expand All @@ -52,6 +52,7 @@
"@babel/preset-react": "^7.14.5",
"@babel/register": "^7.14.5",
"@babel/runtime": "^7.14.6",
"@reduxjs/toolkit": "^1.6.2",
"babel-eslint": "^10.1.0",
"babel-loader": "^8.2.2",
"babel-plugin-lodash": "^3.3.4",
Expand Down
3 changes: 3 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import createFirestoreInstance, {
import constants, { actionTypes } from './constants';
import middleware, { CALL_FIRESTORE } from './middleware';
import { getSnapshotByObject } from './utils/query';
import createMutate from './utils/createMutate';

// converted with transform-inline-environment-variables
export const version = process.env.npm_package_version;
Expand Down Expand Up @@ -34,6 +35,7 @@ export {
middleware,
CALL_FIRESTORE,
mockMutate,
createMutate,
};

export default {
Expand All @@ -51,4 +53,5 @@ export default {
middleware,
CALL_FIRESTORE,
mockMutate,
createMutate,
};
104 changes: 104 additions & 0 deletions src/jest.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { unwrapResult } from '@reduxjs/toolkit';

const noop = () => null;

/**
* shouldPass
*
* it.each([{
* payload: { id: '999' },
* mutation: [{id: '999', path: 'orgs/mock-org-id/tasks', archived: true }],
* returned: '999',
* }])(...shouldPass(archiveTask));
*
* @param {Function} actionCreator
* @returns {Array<string, Function>} - Test Name and Test Function
*/
const shouldPass = (...actionCreator) => {
const [testname, actionCreatorFnc] =
actionCreator.length === 1
? [
`${actionCreator[0].typePrefix || ''} $payload should pass.`,
actionCreator[0],
]
: [actionCreator[0], actionCreator[1]];
return [
testname,
async ({ payload, mutation, returned, provider }) => {
const mutate = jest.fn();
const getFirestore = jest.fn().mockReturnValue({
mutate,
});

const thunk = [noop, noop, { ...provider, getFirestore }];
const dispatched = actionCreatorFnc(payload)(...thunk).then(unwrapResult);
const output = await expect(dispatched).resolves.not.toThrow();

if (returned !== undefined) {
expect(output).toStrictEqual(returned);
}

if (mutation !== undefined) {
expect(mutate).toHaveBeenCalledWith(mutation);
}
},
];
};

/**
* shouldFail
*
* it.each([{
* payload: { id: '' },
* returned: new Error('`id` is empty string.'),
* }])(...shouldFail(archiveTask));
*
* @param {Function} actionCreator
* @returns {Array<string, Function>} - Test Name and Test Function
*/
const shouldFail = (...actionCreator) => {
const [testname, actionCreatorFnc] =
actionCreator.length === 1
? [
`${actionCreator[0].typePrefix || ''} $payload fails properly.`,
actionCreator[0],
]
: [actionCreator[0], actionCreator[1]];
return [
testname,
async ({ payload, mutation, returned, provider }) => {
const mutate = jest.fn();
const getFirestore = jest.fn().mockReturnValue({
mutate,
});
const thunk = [noop, noop, { ...provider, getFirestore }];
const dispatched = actionCreatorFnc(payload)(...thunk).then(unwrapResult);
await expect(dispatched).rejects.not.toBeUndefined();

if (mutation !== undefined) {
expect(mutate).toHaveBeenCalledWith(mutation);
}

try {
await dispatched;
} catch (error) {
if (returned === undefined) {
if (!(error instanceof Error) && error.stack) {
return expect(error).not.toBeNull();
}
return expect(error).toBeInstanceOf(Error);
}

if (returned instanceof Error) {
return expect(error).toStrictEqual(returned);
}

Object.keys(returned).map((key) => {
expect(error[key]).toStrictEqual(returned[key]);
});
}
},
];
};

export { shouldFail, shouldPass };
Loading