Unverified Commit ed8cb5cf authored by Andrey Azov's avatar Andrey Azov Committed by GitHub
Browse files

Save contact us form state (#553)

parent afaaebb2
Pipeline #193805 passed with stages
in 4 minutes and 38 seconds
......@@ -27,6 +27,7 @@
"graphql": "15.5.1",
"graphql-request": "3.5.0",
"http-proxy-middleware": "2.0.1",
"idb": "6.1.2",
"lodash": "4.17.21",
"query-string": "7.0.1",
"react": "17.0.2",
......@@ -22718,6 +22719,11 @@
"postcss": "^8.1.0"
}
},
"node_modules/idb": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/idb/-/idb-6.1.2.tgz",
"integrity": "sha512-1DNDVu3yDhAZkFDlJf0t7r+GLZ248F5pTAtA7V0oVG3yjmV125qZOx3g0XpAEkGZVYQiFDAsSOnGet2bhugc3w=="
},
"node_modules/identity-obj-proxy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz",
......@@ -60241,6 +60247,11 @@
"dev": true,
"requires": {}
},
"idb": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/idb/-/idb-6.1.2.tgz",
"integrity": "sha512-1DNDVu3yDhAZkFDlJf0t7r+GLZ248F5pTAtA7V0oVG3yjmV125qZOx3g0XpAEkGZVYQiFDAsSOnGet2bhugc3w=="
},
"identity-obj-proxy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz",
......@@ -49,6 +49,7 @@
"graphql": "15.5.1",
"graphql-request": "3.5.0",
"http-proxy-middleware": "2.0.1",
"idb": "6.1.2",
"lodash": "4.17.21",
"query-string": "7.0.1",
"react": "17.0.2",
......
......@@ -20,6 +20,8 @@ import { Provider } from 'react-redux';
import { CookiesProvider } from 'react-cookie';
import { ConnectedRouter } from 'connected-react-router';
import { HelmetProvider } from 'react-helmet-async';
import { Provider as IndexedDBProvider } from 'src/shared/contexts/IndexedDBContext';
import configureStore, { history } from './store';
import Root from './root/Root';
......@@ -32,13 +34,15 @@ const store = configureStore();
hydrate(
<StrictMode>
<CookiesProvider>
<Provider store={store}>
<ConnectedRouter history={history}>
<HelmetProvider>
<Root />
</HelmetProvider>
</ConnectedRouter>
</Provider>
<IndexedDBProvider>
<Provider store={store}>
<ConnectedRouter history={history}>
<HelmetProvider>
<Root />
</HelmetProvider>
</ConnectedRouter>
</Provider>
</IndexedDBProvider>
</CookiesProvider>
</StrictMode>,
document.getElementById('ens-app')
......
/**
* See the NOTICE file distributed with this work for additional information
* regarding copyright ownership.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { openDB, IDBPDatabase } from 'idb';
const DB_NAME = 'ensembl-website';
const DB_VERSION = 1;
const getDbPromise = () => {
return openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains('contact-forms')) {
db.createObjectStore('contact-forms');
}
}
});
};
class IndexedDB {
static db: IDBPDatabase | null = null;
private static async getDB() {
if (!this.db) {
this.db = await getDbPromise();
}
return this.db;
}
static async get(store: string, key: string) {
const db = await this.getDB();
return db.get(store, key);
}
static async set(store: string, key: string, value: any) {
const db = await this.getDB();
return db.put(store, value, key);
}
static async delete(store: string, key: string) {
const db = await this.getDB();
return db.delete(store, key);
}
static async clear(store: string) {
const db = await this.getDB();
return db.clear(store);
}
static async keys(store: string) {
const db = await this.getDB();
return db.getAllKeys(store);
}
}
export default IndexedDB;
/**
* See the NOTICE file distributed with this work for additional information
* regarding copyright ownership.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect, useRef, useContext } from 'react';
import IndexedDBContext from 'src/shared/contexts/IndexedDBContext';
type FormFieldType = string | File | File[] | null;
type Form = Record<string, FormFieldType>;
type Params<S> = {
formName: string;
currentState: S;
updateState: (state: S) => void;
};
type UseSavedForm = <State extends Form>(
params: Params<State>
) => {
clearSavedForm: () => void;
};
const STORE_NAME = 'contact-forms'; // storage used to save data of different contact forms
const useSavedForm: UseSavedForm = (params) => {
const { formName, currentState, updateState } = params;
const indexedDB = useContext(IndexedDBContext);
const stateRef = useRef<typeof currentState>(currentState);
stateRef.current = currentState; // <-- to be able to save the latest state despite the closures
useEffect(() => {
// recover form state from the storage
indexedDB.get(STORE_NAME, formName).then((savedState) => {
if (savedState) {
updateState(savedState);
}
});
}, []);
useEffect(() => {
// save the form before user refreshes or closes the tab
window.addEventListener('beforeunload', saveFormState);
return () => {
window.removeEventListener('beforeunload', saveFormState);
saveFormState(); // <-- also save the form on every unmount
};
}, []);
const isEmptyForm = (state: typeof currentState) => {
let isEmpty = true;
for (const value of Object.values(state)) {
if (Array.isArray(value) && value.length) {
isEmpty = false;
} else if (value !== null && value !== '') {
isEmpty = false;
}
}
return isEmpty;
};
const saveFormState = () => {
// only save the form if it is not empty
if (isEmptyForm(stateRef.current)) {
return;
}
const formWithoutHugeFiles = withoutHugeFiles(stateRef.current);
indexedDB.set(STORE_NAME, params.formName, formWithoutHugeFiles);
};
const withoutHugeFiles = (state: Form) => {
// the user isn't allowed to submit files larger than 10MB anyway;
// so there is no point is saving such files to IndexedDB
const fileSizeLimit = 10e6;
const clonedState = { ...state };
for (const [fieldName, fieldValue] of Object.entries(clonedState)) {
if (fieldValue instanceof File && fieldValue.size > fileSizeLimit) {
clonedState[fieldName] = null;
} else if (Array.isArray(fieldValue)) {
clonedState[fieldName] = fieldValue.filter(
(file) => file.size <= fileSizeLimit
);
}
}
return clonedState;
};
const clearSavedForm = () => {
indexedDB.delete(STORE_NAME, formName);
};
return {
clearSavedForm
};
};
export default useSavedForm;
......@@ -26,6 +26,7 @@ import noop from 'lodash/noop';
import { submitForm } from '../submitForm';
import noEarlierThan from 'src/shared/utils/noEarlierThan';
import useSavedForm from '../hooks/useSavedForm';
import SubmissionSuccess from '../submission-success/SubmissionSuccess';
import ShadedInput from 'src/shared/components/input/ShadedInput';
......@@ -85,13 +86,19 @@ type RemoveFileAction = {
payload: number; // index of the file in the array of files
};
type ReplaceStateAction = {
type: 'replace-state';
payload: State;
};
type Action =
| UpdateNameAction
| UpdateEmailAction
| UpdateSubjectAction
| UpdateMessageAction
| AddFileAction
| RemoveFileAction;
| RemoveFileAction
| ReplaceStateAction;
const reducer = (state: State, action: Action): State => {
switch (action.type) {
......@@ -109,6 +116,8 @@ const reducer = (state: State, action: Action): State => {
const newFiles = [...state.files];
newFiles.splice(action.payload, 1);
return { ...state, files: newFiles };
case 'replace-state':
return action.payload;
default:
return state;
}
......@@ -130,6 +139,13 @@ const ContactUsInitialForm = () => {
const stateRef = useRef<typeof state>();
stateRef.current = state;
const { clearSavedForm } = useSavedForm({
formName: FORM_NAME,
currentState: state,
updateState: (savedState) =>
dispatch({ type: 'replace-state', payload: savedState })
});
useEffect(() => {
// TODO: this useEffect will be unnecessary when the Input is refactored to include forwardRef
const emailInput = formRef.current?.querySelector('#email');
......@@ -194,6 +210,7 @@ const ContactUsInitialForm = () => {
noEarlierThan(submitPromise, 1000)
.then(() => {
clearSavedForm();
setSubmissionState(LoadingState.SUCCESS);
})
.catch(() => {
......
/**
* See the NOTICE file distributed with this work for additional information
* regarding copyright ownership.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { createContext, ReactNode } from 'react';
import IndexedDB from 'src/services/indexeddb-service';
const IndexedDBContext = createContext<typeof IndexedDB>(IndexedDB);
const Provider = ({ children }: { children: ReactNode }) => {
return (
<IndexedDBContext.Provider value={IndexedDB}>
{children}
</IndexedDBContext.Provider>
);
};
export { Provider };
export default IndexedDBContext;
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment