Skip to content

Commit ccdc54b

Browse files
authored
Firestore document preview and basic-editing (#35)
Breaking this up into smaller bits... this includes: a basic list view an editor (can only edit values right now) adding field in the preview to maps/arrays deleting fields (preview and editor) Still to come: better presentation that JSON.stringify in the ListItem (indentation too) changing field type specific field type editors conditionally changing field-key (especially in document-creation/add-field)
1 parent f234a3b commit ccdc54b

24 files changed

+1152
-279
lines changed

package-lock.json

Lines changed: 45 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
"express": "^4.17.1",
1414
"firebase": "^7.6.0",
1515
"font-awesome": "^4.7.0",
16+
"immer": "^6.0.1",
1617
"keycode": "^2.2.0",
18+
"lodash.get": "^4.4.2",
19+
"lodash.last": "^3.0.0",
1720
"node-fetch": "^2.6.0",
1821
"react": "^16.11.0",
1922
"react-dom": "^16.11.0",
@@ -25,7 +28,7 @@
2528
"redux-saga": "^1.1.1",
2629
"rmwc": "^5.7.1",
2730
"typesafe-actions": "^5.1.0",
28-
"typescript": "3.6.4"
31+
"typescript": "^3.8.3"
2932
},
3033
"scripts": {
3134
"start": "react-scripts start",
@@ -56,6 +59,8 @@
5659
"@firebase/auth-interop-types": "^0.1.1",
5760
"@firebase/component": "^0.1.1",
5861
"@testing-library/react": "^9.3.2",
62+
"@types/lodash.get": "^4.4.6",
63+
"@types/lodash.last": "^3.0.6",
5964
"@types/react-router-dom": "^5.1.2",
6065
"husky": "^3.0.9",
6166
"jest-fetch-mock": "^3.0.1",

src/components/Firestore/Collection.test.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
*/
1616

1717
import React from 'react';
18-
import { render } from '@testing-library/react';
18+
import { act, render } from '@testing-library/react';
1919
import { MemoryRouter } from 'react-router-dom';
2020
import { useCollection, useDocumentData } from 'react-firebase-hooks/firestore';
2121

@@ -44,6 +44,16 @@ it('shows the list of documents in the collection', () => {
4444
</MemoryRouter>
4545
);
4646

47+
act(() =>
48+
collectionReference.setSnapshot({
49+
docs: [
50+
fakeDocumentSnapshot({
51+
ref: fakeDocumentReference({ id: 'cool-doc-1' }),
52+
}),
53+
],
54+
})
55+
);
56+
4757
expect(getByText(/my-stuff/)).not.toBeNull();
4858
expect(queryByText(/Loading documents/)).toBeNull();
4959
expect(getByText(/cool-doc-1/)).not.toBeNull();

src/components/Firestore/Document.test.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import { ApiProvider } from './ApiContext';
2929

3030
import { Document, Root } from './Document';
3131

32+
import { DocumentStateContext } from './Field/DocumentStore';
33+
3234
jest.mock('react-firebase-hooks/firestore');
3335

3436
it('shows the root-id', () => {
@@ -46,8 +48,6 @@ it('shows the root-id', () => {
4648
});
4749

4850
it('shows the document-id', () => {
49-
useDocumentData.mockReturnValueOnce([]);
50-
5151
const { getByText } = render(
5252
<MemoryRouter>
5353
<ApiProvider value={fakeFirestoreApi()}>
@@ -60,8 +60,6 @@ it('shows the document-id', () => {
6060
});
6161

6262
it('shows the root collection-list', () => {
63-
useDocumentData.mockReturnValueOnce([]);
64-
6563
const { getByTestId } = render(
6664
<MemoryRouter>
6765
<ApiProvider value={fakeFirestoreApi()}>

src/components/Firestore/Field/DocumentRefContext.tsx renamed to src/components/Firestore/DocumentEditor/actions.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,17 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { createContext, useContext } from 'react';
18-
import { firestore } from 'firebase';
17+
import { createAction } from 'typesafe-actions';
1918

20-
const DocumentRefContext = createContext<firestore.DocumentReference | null>(
21-
null
22-
);
19+
import { FirestoreAny, FirestoreMap } from '../models';
2320

24-
export const DocumentRefProvider = DocumentRefContext.Provider;
25-
export const useDocumentRef = () => {
26-
const documentRef = useContext(DocumentRefContext);
27-
if (!documentRef) {
28-
throw new Error('You are missing a <DocumentRefProvider>.');
29-
}
30-
return documentRef;
31-
};
21+
export const reset = createAction('@document/reset')<FirestoreMap>();
22+
export const addField = createAction('@document/add')<{
23+
path: string[];
24+
value: FirestoreAny;
25+
}>();
26+
export const updateField = createAction('@document/update')<{
27+
path: string[];
28+
value: FirestoreAny;
29+
}>();
30+
export const deleteField = createAction('@document/delete')<string[]>();
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Copyright 2020 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import React from 'react';
18+
import { fireEvent, render } from '@testing-library/react';
19+
import { firestore } from 'firebase';
20+
21+
import DocumentEditor from './index';
22+
23+
it('renders an editable field', () => {
24+
const onChange = jest.fn();
25+
const { getByPlaceholderText } = render(
26+
<DocumentEditor value={{ hello: 'world' }} onChange={onChange} />
27+
);
28+
expect(getByPlaceholderText('Field').value).toBe('hello');
29+
expect(getByPlaceholderText('Type').value).toBe('string');
30+
expect(getByPlaceholderText('Value').value).toBe('"world"');
31+
32+
fireEvent.change(getByPlaceholderText('Value'), {
33+
target: { value: 'new' },
34+
});
35+
36+
expect(getByPlaceholderText('Value').value).toBe('"new"');
37+
expect(onChange).toHaveBeenCalledWith({ hello: 'new' });
38+
});
39+
40+
it('renders an editable field with children', () => {
41+
const onChange = jest.fn();
42+
43+
const { getByDisplayValue } = render(
44+
<DocumentEditor
45+
value={{ hello: { foo: ['bar', { spam: 'eggs' }] } }}
46+
onChange={onChange}
47+
/>
48+
);
49+
50+
fireEvent.change(getByDisplayValue('"eggs"'), {
51+
target: { value: 'new' },
52+
});
53+
54+
expect(onChange).toHaveBeenCalledWith({
55+
hello: { foo: ['bar', { spam: 'new' }] },
56+
});
57+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* Copyright 2020 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import React, { useEffect } from 'react';
18+
import last from 'lodash.last';
19+
import { TextField } from '@rmwc/textfield';
20+
21+
import * as actions from './actions';
22+
import {
23+
useDocumentState,
24+
useDocumentDispatch,
25+
useFieldState,
26+
DocumentProvider,
27+
} from './store';
28+
import { FirestoreMap } from '../models';
29+
import { isMap, isArray, getFieldType } from '../utils';
30+
31+
/** Entry point for a Document/Field editor */
32+
const DocumentEditor: React.FC<{
33+
value: FirestoreMap;
34+
onChange?: (value: FirestoreMap) => void;
35+
}> = ({ value, onChange }) => {
36+
return (
37+
<DocumentProvider value={value}>
38+
<RootField onChange={onChange} />
39+
</DocumentProvider>
40+
);
41+
};
42+
43+
/**
44+
* Special representation of a Document Root, where we don't want to show
45+
* the implicit top-level map.
46+
*/
47+
const RootField: React.FC<{
48+
onChange?: (value: FirestoreMap) => void;
49+
}> = ({ onChange }) => {
50+
const state = useDocumentState();
51+
52+
useEffect(() => {
53+
onChange && onChange(state);
54+
}, [onChange, state]);
55+
56+
return (
57+
<>
58+
{Object.keys(state).map(field => (
59+
<NestedEditor key={field} path={[field]} />
60+
))}
61+
</>
62+
);
63+
};
64+
65+
/**
66+
* Field with call-to-actions for editing as well as rendering applicable child-nodes
67+
*/
68+
const NestedEditor: React.FC<{ path: string[] }> = ({ path }) => {
69+
const state = useFieldState(path);
70+
const dispatch = useDocumentDispatch()!;
71+
72+
let childEditors = null;
73+
if (isMap(state)) {
74+
childEditors = Object.keys(state).map(childLeaf => {
75+
const childPath = [...path, childLeaf];
76+
return <NestedEditor key={childLeaf} path={childPath} />;
77+
});
78+
} else if (isArray(state)) {
79+
childEditors = state.map((value, index) => {
80+
const childPath = [...path, `${index}`];
81+
return <NestedEditor key={index} path={childPath} />;
82+
});
83+
}
84+
85+
function handleEditValue(e: React.FormEvent<HTMLInputElement>) {
86+
dispatch(actions.updateField({ path, value: e.currentTarget.value }));
87+
}
88+
89+
return (
90+
<>
91+
<div style={{ display: 'flex' }}>
92+
<TextField readOnly value={last(path)} placeholder="Field" />
93+
<TextField readOnly value={getFieldType(state)} placeholder="Type" />
94+
<TextField
95+
value={JSON.stringify(state)}
96+
onChange={handleEditValue}
97+
placeholder="Value"
98+
/>
99+
</div>
100+
{childEditors && <div>{childEditors}</div>}
101+
</>
102+
);
103+
};
104+
105+
export default DocumentEditor;

0 commit comments

Comments
 (0)