Improve usability and efficiency

Allow "saving" a set with no changes
Update cleanseVocabString function to be inline with Cloud Function
Combine CreateSet and EditSet components
Remove redundancy and significantly improve efficiency in the EditSet component
This commit is contained in:
2022-02-22 14:48:31 +00:00
parent 8dc5db3fa5
commit 22ea9bcccf
4 changed files with 170 additions and 453 deletions

View File

@@ -318,13 +318,13 @@ class App extends React.Component {
}
</Route>
<Route path="/create-set" exact>
<CreateSet db={db} user={this.state.user} logEvent={analytics.logEvent} page={this.page} />
<EditSet db={db} user={this.state.user} logEvent={analytics.logEvent} page={this.page} createSet={true} />
</Route>
<Route path="/my-sets" exact>
<UserSets db={db} functions={functions} user={this.state.user} logEvent={analytics.logEvent} page={this.page} />
</Route>
<Route path="/sets/:setId/edit" exact>
<EditSet db={db} user={this.state.user} logEvent={analytics.logEvent} page={this.page} />
<EditSet db={db} user={this.state.user} logEvent={analytics.logEvent} page={this.page} createSet={false} />
</Route>
<Route path="/history" exact>
<History db={db} user={this.state.user} logEvent={analytics.logEvent} page={this.page} />

View File

@@ -1,295 +0,0 @@
import React from 'react';
import { withRouter } from 'react-router-dom';
import NavBar from "./NavBar";
import Button from "./Button";
import Footer from "./Footer";
import { HomeRounded as HomeRoundedIcon, CheckCircleOutlineRounded as CheckCircleOutlineRoundedIcon } from "@material-ui/icons";
import Checkbox from '@material-ui/core/Checkbox';
// import { doc, collection, setDoc, writeBatch } from "firebase/firestore";
import "./css/Form.css";
export default withRouter(class CreateSet extends React.Component {
constructor(props) {
super(props);
this.state = {
user: props.user,
db: props.db,
loading: false,
canCreateSet: false,
inputContents: [],
inputs: {
title: "",
public: false,
},
navbarItems: [
{
type: "link",
link: "/",
icon: <HomeRoundedIcon />,
hideTextMobile: true,
}
],
};
let isMounted = true;
Object.defineProperty(this, "isMounted", {
get: () => isMounted,
set: (value) => isMounted = value,
});
}
setState = (state, callback = null) => {
if (this.isMounted) super.setState(state, callback);
}
componentDidMount() {
document.title = "Create Set | Parandum";
this.setNameInput.focus();
this.props.page.load();
this.props.logEvent("page_view");
}
componentWillUnmount() {
this.isMounted = false;
this.props.page.unload();
}
stopLoading = () => {
this.setState({
canCreateSet: true,
loading: false,
});
}
cleanseVocabString = (item) => {
const chars = " .,()-_'\"";
let newString = item;
chars.split("").forEach((char) => {
newString = newString.replace(char, "");
});
return newString;
}
handleSetDataChange = () => {
let vocabWithTextExists = false;
const noInvalidPairs = this.state.inputContents.map(contents => {
const cleansedTerm = this.cleanseVocabString(contents.term);
const cleansedDefinition = this.cleanseVocabString(contents.definition);
const emptyVocabTermPresent = cleansedTerm === "" || cleansedDefinition === "";
if (emptyVocabTermPresent && cleansedTerm !== cleansedDefinition) {
return true;
} else if (!emptyVocabTermPresent) {
vocabWithTextExists = true;
}
return false;
})
.filter(x => x === true)
.length === 0;
if (this.state.inputs.title.trim() !== "" && noInvalidPairs && vocabWithTextExists) {
this.setState({
canCreateSet: true,
})
} else {
this.setState({
canCreateSet: false,
})
}
}
onTermInputChange = (event) => {
const index = Number(event.target.name.replace("term_", ""));
const input = event.target.value;
let inputContents = this.state.inputContents;
if (index >= this.state.inputContents.length && input !== "") {
inputContents.push({
term: input,
definition: "",
});
} else {
if (index === this.state.inputContents.length - 1 && input === "" && this.state.inputContents[index].definition === "") {
inputContents.splice(-1);
} else {
inputContents[index].term = input;
}
}
this.setState({
inputContents: inputContents,
}, this.handleSetDataChange);
}
onDefinitionInputChange = (event) => {
const index = Number(event.target.name.replace("definition_", ""));
const input = event.target.value;
let inputContents = this.state.inputContents;
if (index >= this.state.inputContents.length && input !== "") {
inputContents.push({
term: "",
definition: input,
});
} else {
if (index === this.state.inputContents.length - 1 && input === "" && this.state.inputContents[index].term === "") {
inputContents.splice(-1);
} else {
inputContents[index].definition = input;
}
}
this.setState({
inputContents: inputContents,
}, this.handleSetDataChange);
}
onSetTitleInputChange = (event) => {
this.setState({
inputs: {
...this.state.inputs,
title: event.target.value,
}
}, this.handleSetDataChange);
}
onPublicSetInputChange = (event) => {
this.setState({
inputs: {
...this.state.inputs,
public: event.target.checked,
}
});
}
createSet = () => {
if (this.state.canCreateSet) {
this.setState({
loading: true,
canCreateSet: false,
});
const db = this.state.db;
const setDocRef = db.collection("sets").doc();
setDocRef.set({
title: this.state.inputs.title,
public: this.state.inputs.public,
owner: this.state.user.uid,
groups: [],
})
.then(() => {
let promises = [];
let batches = [db.batch()];
this.state.inputContents
.filter(contents =>
this.cleanseVocabString(contents.term) !== "" &&
this.cleanseVocabString(contents.definition) !== ""
)
.map((contents, index) => {
if (index % 248 === 0) {
promises.push(batches[batches.length - 1].commit());
batches.push(db.batch());
}
const vocabDocRef = setDocRef.collection("vocab").doc();
if (contents.term === "") {
return batches[batches.length - 1].delete(vocabDocRef);
} else {
return batches[batches.length - 1].set(vocabDocRef, {
term: contents.term,
definition: contents.definition,
sound: true,
});
}
});
if (!batches[batches.length - 1]._delegate._committed) promises.push(batches[batches.length - 1].commit().catch(() => null));
Promise.all(promises).then(() => {
this.stopLoading();
this.props.history.push("/sets/" + setDocRef.id);
}).catch((error) => {
console.log("Couldn't save set: " + error);
this.stopLoading();
});
});
}
}
render() {
return (
<div>
<NavBar items={this.state.navbarItems} />
<main>
<h2 className="page-header">
<input
type="text"
name="set_title"
onChange={this.onSetTitleInputChange}
placeholder="Set Title"
className="set-title-input"
ref={inputEl => (this.setNameInput = inputEl)}
autoComplete="off"
/>
</h2>
<div className="form create-set-vocab-list">
<label>
<Checkbox
checked={this.state.inputs.public}
onChange={this.onPublicSetInputChange}
inputProps={{ 'aria-label': 'checkbox' }}
/>
<span>Public</span>
</label>
<div className="create-set-header">
<h3>Terms</h3>
<h3>Definitions</h3>
</div>
{this.state.inputContents.concat({term: "", definition: ""}).map((contents, index) =>
<div className="create-set-input-row" key={index}>
<input
type="text"
name={`term_${index}`}
onChange={this.onTermInputChange}
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
/>
<input
type="text"
name={`definition_${index}`}
onChange={this.onDefinitionInputChange}
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
/>
</div>
)}
</div>
<Button
onClick={this.createSet}
icon={<CheckCircleOutlineRoundedIcon />}
loading={this.state.loading}
disabled={!this.state.canCreateSet}
>
Create
</Button>
</main>
<Footer />
</div>
)
}
})

View File

@@ -14,7 +14,7 @@ export default withRouter(class EditSet extends Component {
user: props.user,
db: props.db,
loading: false,
canSaveSet: true,
canSaveSet: !(props.createSet === true),
inputs: {
title: "",
public: false,
@@ -31,6 +31,9 @@ export default withRouter(class EditSet extends Component {
}
],
canMakeSetNonPublic: true,
changesMade: false,
totalCompliantVocabPairs: 0,
totalUncompliantVocabPairs: 0,
};
let isMounted = true;
@@ -45,7 +48,7 @@ export default withRouter(class EditSet extends Component {
}
alertLeavingWithoutSaving = (e = null) => {
if (this.state.canSaveSet) {
if (this.state.canSaveSet && this.state.changesMade) {
var confirmationMessage = "Are you sure you want to leave? You will lose any unsaved changes.";
(e || window.event).returnValue = confirmationMessage; //Gecko + IE
@@ -54,35 +57,31 @@ export default withRouter(class EditSet extends Component {
return "";
}
componentDidMount() {
async componentDidMount() {
window.addEventListener("beforeunload", this.alertLeavingWithoutSaving);
if (this.props.createSet !== true) {
const setId = this.props.match.params.setId;
const setRef = this.state.db.collection("sets")
.doc(setId);
const setVocabRef = setRef.collection("vocab")
.orderBy("term");
setRef.get().then((setDoc) => {
await setRef.get().then(async (setDoc) => {
document.title = `Edit | ${setDoc.data().title} | Parandum`;
setVocabRef.get().then((querySnapshot) => {
await setVocabRef.get().then((querySnapshot) => {
let vocab = [];
let vocabPairsCount = 0;
querySnapshot.docs.map((doc) => {
const data = doc.data();
if (this.cleanseVocabString(data.term) !== "" &&
this.cleanseVocabString(data.definition) !== "") {
vocabPairsCount++;
}
return vocab.push({
vocabId: doc.id,
term: data.term,
definition: data.definition,
sound: data.sound,
validInput: true,
});
});
@@ -94,30 +93,33 @@ export default withRouter(class EditSet extends Component {
inputContents: vocab,
originalInputContents: JSON.parse(JSON.stringify(vocab)),
canMakeSetNonPublic: !(setDoc.data().groups && setDoc.data().groups.length > 0),
totalCompliantVocabPairs: vocab.length,
};
if (!(newState.inputs.title !== "" && vocabPairsCount > 0 && vocabPairsCount === this.state.inputContents.length)) {
newState.canSaveSet = false;
}
if (setDoc.data().owner !== this.state.user.uid) {
newState.setInaccessible = true;
}
this.setState(newState);
this.props.page.load();
});
}).catch(() => {
this.setState({
setInaccessible: true,
});
this.props.page.load();
});
this.props.logEvent("select_content", {
content_type: "edit_set",
item_id: this.props.match.params.setId,
});
} else {
document.title = "Create Set | Parandum";
this.props.logEvent("page_view");
}
!this.state.setInaccessible && this.setNameInput.focus();
this.props.page.load();
}
componentWillUnmount = () => {
@@ -126,63 +128,67 @@ export default withRouter(class EditSet extends Component {
this.props.page.unload();
}
stopLoading = () => {
stopLoading = (changesMade=this.state.changesMade) => {
this.setState({
canStartTest: true,
canSaveSet: true,
loading: false,
changesMade,
});
}
cleanseVocabString = (item) => {
const chars = " .,()-_'\"";
let newString = item;
chars.split("").forEach((char) => {
newString = newString.replace(char, "");
});
return newString;
const chars = /[\p{P}\p{S} ]+/ug;
return item.replace(chars, "");
}
handleSetDataChange = () => {
let vocabWithTextExists = false;
const noInvalidPairs = this.state.inputContents.map(contents => {
const cleansedTerm = this.cleanseVocabString(contents.term);
const cleansedDefinition = this.cleanseVocabString(contents.definition);
const emptyVocabTermPresent = cleansedTerm === "" || cleansedDefinition === "";
if (emptyVocabTermPresent && cleansedTerm !== cleansedDefinition) {
return true;
} else if (!emptyVocabTermPresent) {
vocabWithTextExists = true;
}
return false;
})
.filter(x => x === true)
.length === 0;
handleSetDataChange = (vocabIndex=null) => {
let inputContents = [...this.state.inputContents];
let totalCompliantVocabPairs = this.state.totalCompliantVocabPairs;
let totalUncompliantVocabPairs = this.state.totalUncompliantVocabPairs;
if (this.state.inputs.title.trim() !== "" && noInvalidPairs && vocabWithTextExists) {
this.setState({
canSaveSet: true,
})
if (vocabIndex !== null) {
const emptyTerm = this.cleanseVocabString(inputContents[vocabIndex].term) === "";
const emptyDefinition = this.cleanseVocabString(inputContents[vocabIndex].definition) === "";
let oldCompliance = inputContents[vocabIndex].validInput;
if (oldCompliance === false) {
totalUncompliantVocabPairs--;
} else if (oldCompliance === true) {
totalCompliantVocabPairs--;
}
if (emptyTerm ? !emptyDefinition : emptyDefinition) {
inputContents[vocabIndex].validInput = false;
totalUncompliantVocabPairs++;
} else if (!emptyTerm && !emptyDefinition) {
inputContents[vocabIndex].validInput = true;
totalCompliantVocabPairs++;
} else {
this.setState({
canSaveSet: false,
})
inputContents[vocabIndex].validInput = null;
}
}
onTermInputChange = (event) => {
this.setState({
canSaveSet: totalUncompliantVocabPairs === 0 && totalCompliantVocabPairs > 0 && this.state.inputs.title.trim() !== "",
changesMade: true,
inputContents,
totalCompliantVocabPairs,
totalUncompliantVocabPairs,
});
}
onTermInputChange = (event, vocabIndex) => {
const index = Number(event.target.name.replace("term_", ""));
const input = event.target.value;
let inputContents = this.state.inputContents;
let inputContents = [...this.state.inputContents];
if (index >= this.state.inputContents.length && input !== "") {
inputContents.push({
term: input,
definition: "",
sound: false,
validInput: null,
});
} else {
if (index === this.state.inputContents.length - 1 && input === "" && this.state.inputContents[index].definition === "") {
@@ -194,10 +200,10 @@ export default withRouter(class EditSet extends Component {
this.setState({
inputContents: inputContents,
}, this.handleSetDataChange);
}, () => this.handleSetDataChange(vocabIndex));
}
onDefinitionInputChange = (event) => {
onDefinitionInputChange = (event, vocabIndex) => {
const index = Number(event.target.name.replace("definition_", ""));
const input = event.target.value;
@@ -208,6 +214,7 @@ export default withRouter(class EditSet extends Component {
term: "",
definition: input,
sound: false,
validInput: null,
});
} else {
if (index === this.state.inputContents.length - 1 && input === "" && this.state.inputContents[index].term === "") {
@@ -219,7 +226,7 @@ export default withRouter(class EditSet extends Component {
this.setState({
inputContents: inputContents,
}, this.handleSetDataChange);
}, () => this.handleSetDataChange(vocabIndex));
}
onSetTitleInputChange = (event) => {
@@ -228,7 +235,7 @@ export default withRouter(class EditSet extends Component {
...this.state.inputs,
title: event.target.value,
}
}, this.handleSetDataChange);
}, () => this.handleSetDataChange());
}
onPublicSetInputChange = (event) => {
@@ -240,16 +247,45 @@ export default withRouter(class EditSet extends Component {
});
}
getVocabDocRef = (vocabCollectionRef, contents) => {
if (this.props.createSet === true) {
return vocabCollectionRef.doc();
} else {
return vocabCollectionRef.doc(contents.vocabId);
}
}
saveSet = async () => {
if (this.state.canSaveSet) {
const noChangesMade = !this.state.changesMade;
this.setState({
loading: true,
canSaveSet: false,
changesMade: false,
});
if (noChangesMade) {
this.props.history.push("/sets/" + this.props.match.params.setId);
} else {
const db = this.state.db;
const setId = this.props.match.params.setId;
const setDocRef = db.collection("sets").doc(setId);
const setCollectionRef = db.collection("sets");
let vocabCollectionRef;
let setId;
if (this.props.createSet === true) {
let setDocRef = setCollectionRef.doc();
await setDocRef.set({
title: this.state.inputs.title,
public: this.state.inputs.public,
owner: this.state.user.uid,
groups: [],
});
setId = setDocRef.id;
vocabCollectionRef = setDocRef.collection("vocab");
} else {
vocabCollectionRef = setCollectionRef.doc(this.props.match.params.setId).collection("vocab");
setId = this.props.match.params.setId;
}
let promises = [];
let batches = [db.batch()];
@@ -259,48 +295,35 @@ export default withRouter(class EditSet extends Component {
promises.push(batches[batches.length - 1].commit());
batches.push(db.batch());
}
const vocabDocRef = setDocRef.collection("vocab").doc(contents.vocabId);
if (contents.term === "") {
if (this.props.createSet !== true
&& this.cleanseVocabString(contents.term) === "") {
let vocabDocRef = this.getVocabDocRef(vocabCollectionRef, contents);
return batches[batches.length - 1].delete(vocabDocRef);
} else {
} else if (this.props.createSet === true || JSON.stringify(contents) !== JSON.stringify(this.state.originalInputContents[index])) {
let vocabDocRef = this.getVocabDocRef(vocabCollectionRef, contents);
return batches[batches.length - 1].set(vocabDocRef, {
term: contents.term,
definition: contents.definition,
sound: contents.sound,
});
}
})
// TODO: sound files
if (this.state.inputContents.length < this.state.originalInputContents.length) {
let batchItems = this.state.inputContents.length % 248;
for (let i = this.state.inputContents.length; i < this.state.originalInputContents.length; i++) {
if (batchItems + i % 248 === 0) {
if (batchItems !== 0) batchItems = 0;
promises.push(batches[batches.length - 1].commit());
batches.push(db.batch());
}
const vocabDocRef = setDocRef
.collection("vocab")
.doc(this.state.originalInputContents[i].vocabId);
batches[batches.length - 1].delete(vocabDocRef);
}
}
return true;
});
if (!batches[batches.length - 1]._delegate._committed) promises.push(batches[batches.length - 1].commit().catch(() => null));
Promise.all(promises).then(() => {
this.stopLoading();
this.props.history.push("/sets/" + setDocRef.id);
this.props.history.push("/sets/" + setId);
}).catch((error) => {
console.log("Couldn't update set: " + error);
this.stopLoading();
this.stopLoading(true);
});
}
}
}
render() {
return (
@@ -310,7 +333,7 @@ export default withRouter(class EditSet extends Component {
:
<div>
<Prompt
when={this.state.canSaveSet}
when={this.state.changesMade}
message="Are you sure you want to leave? You will lose any unsaved changes."
/>
@@ -327,6 +350,7 @@ export default withRouter(class EditSet extends Component {
value={this.state.inputs.title}
className="set-title-input"
autoComplete="off"
ref={inputEl => (this.setNameInput = inputEl)}
/>
</h2>
<Button
@@ -358,8 +382,8 @@ export default withRouter(class EditSet extends Component {
<input
type="text"
name={`term_${index}`}
onChange={this.onTermInputChange}
value={this.state.inputContents[index] ? this.state.inputContents[index].term : ""}
onChange={event => this.onTermInputChange(event, index)}
value={contents.term}
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
@@ -367,8 +391,8 @@ export default withRouter(class EditSet extends Component {
<input
type="text"
name={`definition_${index}`}
onChange={this.onDefinitionInputChange}
value={this.state.inputContents[index] ? this.state.inputContents[index].definition : ""}
onChange={event => this.onDefinitionInputChange(event, index)}
value={contents.definition}
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"

View File

@@ -355,18 +355,6 @@ export default withRouter(class Progress extends React.Component {
}
}
cleanseVocabString = (item) => {
const chars = " .,()-_'\"";
let newString = item;
chars.split("").forEach((char) => {
newString = newString.replace(char, "");
});
return newString;
}
processAnswer = async (referrer="physical") => {
if (this.state.canProceed) {
this.startLoading();