Add ability to search public sets! Also bump version

Public sets are now paginated and searchable, on a separate search page
This commit is contained in:
2021-11-25 21:57:50 +00:00
parent c0483ea701
commit c649ca1211
4 changed files with 405 additions and 59 deletions

View File

@@ -19,6 +19,7 @@ import MistakesHistory from "./MistakesHistory";
import TermsOfService from "./TermsOfService"; import TermsOfService from "./TermsOfService";
import PrivacyPolicy from "./PrivacyPolicy"; import PrivacyPolicy from "./PrivacyPolicy";
import Button from "./Button"; import Button from "./Button";
import SearchSets from './SearchSets';
import { CheckRounded as CheckRoundedIcon } from "@material-ui/icons"; import { CheckRounded as CheckRoundedIcon } from "@material-ui/icons";
import Loader from "./puff-loader.svg"; import Loader from "./puff-loader.svg";
@@ -294,6 +295,9 @@ class App extends React.Component {
<Route path="/sets/:setId" exact> <Route path="/sets/:setId" exact>
<SetPage db={db} functions={functions} user={this.state.user} logEvent={analytics.logEvent} page={this.page} /> <SetPage db={db} functions={functions} user={this.state.user} logEvent={analytics.logEvent} page={this.page} />
</Route> </Route>
<Route path="/search" exact>
<SearchSets db={db} functions={functions} user={this.state.user} logEvent={analytics.logEvent} page={this.page} />
</Route>
<Route path="/groups" exact> <Route path="/groups" exact>
<UserGroups db={db} functions={functions} user={this.state.user} logEvent={analytics.logEvent} page={this.page} /> <UserGroups db={db} functions={functions} user={this.state.user} logEvent={analytics.logEvent} page={this.page} />
</Route> </Route>

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import NavBar from "./NavBar"; import NavBar from "./NavBar";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { TimelineRounded as TimelineRoundedIcon, ExitToAppRounded as ExitToAppRoundedIcon, HistoryRounded as HistoryRoundedIcon, SettingsRounded as SettingsRoundedIcon, PersonRounded as PersonRoundedIcon, PublicRounded as PublicRoundedIcon, GroupRounded as GroupRoundedIcon, AddRounded as AddRoundedIcon, SwapHorizRounded as SwapHorizRoundedIcon, PeopleRounded as PeopleRoundedIcon, DeleteRounded as DeleteRoundedIcon, QuestionAnswerRounded as QuestionAnswerRoundedIcon } from "@material-ui/icons"; import { TimelineRounded as TimelineRoundedIcon, ExitToAppRounded as ExitToAppRoundedIcon, HistoryRounded as HistoryRoundedIcon, SettingsRounded as SettingsRoundedIcon, PersonRounded as PersonRoundedIcon, GroupRounded as GroupRoundedIcon, AddRounded as AddRoundedIcon, SwapHorizRounded as SwapHorizRoundedIcon, PeopleRounded as PeopleRoundedIcon, DeleteRounded as DeleteRoundedIcon, QuestionAnswerRounded as QuestionAnswerRoundedIcon, SearchRounded as SearchRoundedIcon } from "@material-ui/icons";
import Checkbox from '@material-ui/core/Checkbox'; import Checkbox from '@material-ui/core/Checkbox';
import Xarrow from 'react-xarrows'; import Xarrow from 'react-xarrows';
@@ -93,11 +93,6 @@ export default withRouter(class LoggedInHome extends React.Component {
const userSetsRef = this.state.db.collection("sets") const userSetsRef = this.state.db.collection("sets")
.where("owner", "==", this.state.user.uid) .where("owner", "==", this.state.user.uid)
.orderBy("title"); .orderBy("title");
const publicSetsRef = this.state.db.collection("sets")
.where("public", "==", true)
.where("owner", "!=", this.state.user.uid)
.orderBy("owner")
.orderBy("title");
const userGroupsRef = this.state.db.collection("users") const userGroupsRef = this.state.db.collection("users")
.doc(this.state.user.uid) .doc(this.state.user.uid)
.collection("groups"); .collection("groups");
@@ -115,12 +110,6 @@ export default withRouter(class LoggedInHome extends React.Component {
userSetsQuerySnapshot.docs.map((doc) => newState.selections[doc.id] = false); userSetsQuerySnapshot.docs.map((doc) => newState.selections[doc.id] = false);
});
const publicSetsQuery = publicSetsRef.get().then((publicSetsQuerySnapshot) => {
newState.publicSets = publicSetsQuerySnapshot.docs;
publicSetsQuerySnapshot.docs.map((doc) => newState.selections[doc.id] = false);
}); });
const userGroupsQuery = userGroupsRef.get().then(async (userGroupsQuerySnapshot) => { const userGroupsQuery = userGroupsRef.get().then(async (userGroupsQuerySnapshot) => {
newState.user.groups = []; newState.user.groups = [];
@@ -179,7 +168,6 @@ export default withRouter(class LoggedInHome extends React.Component {
Promise.all([ Promise.all([
userSetsQuery, userSetsQuery,
publicSetsQuery,
userGroupsQuery, userGroupsQuery,
progressQuery progressQuery
]).then(() => { ]).then(() => {
@@ -378,11 +366,6 @@ export default withRouter(class LoggedInHome extends React.Component {
> >
Test ({Object.values(this.state.selections).filter(x => x === true).length}) Test ({Object.values(this.state.selections).filter(x => x === true).length})
</Button> </Button>
<LinkButton
to="/groups"
>
Groups
</LinkButton>
{ {
(!this.props.page.loaded() || (this.state.userSets && this.state.userSets.length > 0)) && (!this.props.page.loaded() || (this.state.userSets && this.state.userSets.length > 0)) &&
<LinkButton <LinkButton
@@ -391,9 +374,15 @@ export default withRouter(class LoggedInHome extends React.Component {
My Sets My Sets
</LinkButton> </LinkButton>
} }
<LinkButton id="create-set-button-desktop" to="/create-set" icon={<AddRoundedIcon/>} className="button--round" title="Create set"></LinkButton> <LinkButton to="/create-set" icon={<AddRoundedIcon/>} className="button--round" title="Create set"></LinkButton>
<LinkButton to="/search" icon={<SearchRoundedIcon/>} className="button--round" title="Search sets"></LinkButton>
<LinkButton to="/groups" icon={<GroupRoundedIcon/>} className="button--round" title="Groups"></LinkButton>
</div> </div>
<div className="button-container buttons--mobile">
<LinkButton id="create-set-button-mobile" to="/create-set" icon={<AddRoundedIcon />} className="button--round buttons--mobile" title="Create set"></LinkButton> <LinkButton id="create-set-button-mobile" to="/create-set" icon={<AddRoundedIcon />} className="button--round buttons--mobile" title="Create set"></LinkButton>
<LinkButton to="/search" icon={<SearchRoundedIcon/>} className="button--round" title="Search sets"></LinkButton>
<LinkButton to="/groups" icon={<GroupRoundedIcon/>} className="button--round" title="Groups"></LinkButton>
</div>
</div> </div>
<div className="page-header page-header--left buttons--mobile"> <div className="page-header page-header--left buttons--mobile">
<Button <Button
@@ -402,11 +391,6 @@ export default withRouter(class LoggedInHome extends React.Component {
> >
Test ({Object.values(this.state.selections).filter(x => x === true).length}) Test ({Object.values(this.state.selections).filter(x => x === true).length})
</Button> </Button>
<LinkButton
to="/groups"
>
Groups
</LinkButton>
{ {
this.state.userSets && this.state.userSets.length > 0 && this.state.userSets && this.state.userSets.length > 0 &&
<LinkButton <LinkButton
@@ -493,7 +477,11 @@ export default withRouter(class LoggedInHome extends React.Component {
<div className="form set-list"> <div className="form set-list">
{this.state.userSets && this.state.userSets.length > 0 && {this.state.userSets && this.state.userSets.length > 0 &&
<div className="checkbox-list-container"> <div className="checkbox-list-container">
<h3><PersonRoundedIcon /> Personal Sets</h3> <Link
to="/my-sets"
>
<h3><PersonRoundedIcon /> My Sets</h3>
</Link>
<div className="checkbox-list"> <div className="checkbox-list">
{this.state.userSets {this.state.userSets
.sort((a, b) => { .sort((a, b) => {
@@ -561,38 +549,6 @@ export default withRouter(class LoggedInHome extends React.Component {
</div> </div>
</div> </div>
)} )}
{this.state.publicSets && this.state.publicSets.length > 0 &&
<div className="checkbox-list-container">
<h3><PublicRoundedIcon /> Public Sets</h3>
<div className="checkbox-list">
{this.state.publicSets
.sort((a, b) => {
if (a.data().title < b.data().title) {
return -1;
}
if (a.data().title > b.data().title) {
return 1;
}
return 0;
}).map(set =>
<div key={set.id}>
<label>
<Checkbox
name={set.id}
checked={this.state.selections[set.id]}
onChange={this.handleSetSelectionChange}
inputProps={{ 'aria-label': 'checkbox' }}
/>
<Link to={`/sets/${set.id}`}>
{set.data().title}
</Link>
</label>
</div>
)
}
</div>
</div>
}
</div> </div>
</main> </main>
<Footer /> <Footer />

366
src/SearchSets.js Normal file
View File

@@ -0,0 +1,366 @@
import React, { Component } from "react";
import { HomeRounded as HomeRoundedIcon, ArrowForwardRounded as ArrowForwardRoundedIcon } from "@material-ui/icons";
import NavBar from "./NavBar";
import Footer from "./Footer";
import Button from "./Button";
import { Link, withRouter } from "react-router-dom";
import Checkbox from '@material-ui/core/Checkbox';
import TestStart from "./TestStart";
import ClassicTestStart from "./ClassicTestStart";
import LivesTestStart from "./LivesTestStart";
import CountdownTestStart from "./CountdownTestStart";
import "./css/SearchSets.css";
const paginationFrequency = 30;
export default withRouter(class SearchSets extends Component {
constructor(props) {
super(props);
this.state = {
user: props.user,
db: props.db,
loading: false,
loadingSets: false,
functions: {
createProgress: props.functions.httpsCallable("createProgress"),
},
navbarItems: [
{
type: "link",
link: "/",
icon: <HomeRoundedIcon />,
hideTextMobile: true,
}
],
selections: {},
sets: [],
pageNumber: 0,
loadedAllSets: false,
showTestStart: false,
showClassicTestStart: false,
showLivesTestStart: false,
searchInput: "",
};
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() {
this.loadSets().then(() => {
if (this.searchInputRef) this.searchInputRef.focus();
this.props.page.load();
this.props.logEvent("page_view");
});
}
componentWillUnmount() {
this.isMounted = false;
this.props.page.unload();
}
loadSets = (reload = false) => {
if (!this.state.loadingSets) {
this.setState({
loadingSets: true,
});
const setsRef = this.state.db.collection("sets")
.where("public", "==", true)
.where('title', '>=', this.state.searchInput)
.where('title', '<=', this.state.searchInput + '\uf8ff')
.orderBy("title")
.orderBy("owner");
let completeSetsRef;
if (this.state.pageNumber === 0 || reload) {
completeSetsRef = setsRef.limit(paginationFrequency);
} else {
completeSetsRef = setsRef.startAfter(this.state.sets[this.state.sets.length - 1]).limit(paginationFrequency);
}
return completeSetsRef.get().then((querySnapshot) => {
let selections = this.state.selections;
querySnapshot.docs.map((doc) => selections[doc.id] = false);
this.setState({
sets: reload ? querySnapshot.docs : this.state.sets.concat(querySnapshot.docs),
selections: selections,
pageNumber: this.state.pageNumber + 1,
loadedAllSets: querySnapshot.docs.length === 0,
loadingSets: false,
});
});
}
}
stopLoading = () => {
this.setState({
canStartTest: true,
loading: false,
});
}
showTestStart = () => {
if (this.state.canStartTest) {
this.setState({
showTestStart: true,
totalTestQuestions: 1,
});
}
}
hideTestStart = () => {
this.setState({
showTestStart: false,
});
}
startTest = (mode) => {
if (this.state.canStartTest) {
const selections = Object.keys(this.state.selections)
.filter(x => this.state.selections[x]);
this.state.functions.createProgress({
sets: selections,
switch_language: this.state.switchLanguage,
mode: mode,
limit: this.state.sliderValue,
}).then((result) => {
const progressId = result.data;
this.stopLoading();
this.props.history.push("/progress/" + progressId);
this.props.logEvent("start_test", {
progress_id: progressId,
});
}).catch((error) => {
console.log(`Couldn't start test: ${error}`);
this.stopLoading();
});
this.setState({
canStartTest: false,
loading: true,
});
}
}
handleSetSelectionChange = (event) => {
let newState = { ...this.state };
newState.selections[event.target.name] = event.target.checked;
this.setState(newState);
if (Object.values(this.state.selections).indexOf(true) > -1) {
this.setState({ canStartTest: true });
} else {
this.setState({ canStartTest: false });
}
}
showIndividualTestPrompt = async (mode) => {
if (!this.state.loading) {
if (mode === "classic") {
this.setState({
loading: true,
})
const setIds = Object.keys(this.state.selections)
.filter(x => this.state.selections[x]);
const totalTestQuestions = (await Promise.all(setIds.map((setId) =>
this.state.db.collection("sets")
.doc(setId)
.collection("vocab")
.get()
.then(querySnapshot => querySnapshot.docs.length)
))).reduce((a, b) => a + b);
this.setState({
showTestStart: false,
showClassicTestStart: true,
sliderValue: totalTestQuestions,
switchLanguage: false,
totalTestQuestions: totalTestQuestions,
loading: false,
});
} else if (mode === "lives") {
this.setState({
showTestStart: false,
showLivesTestStart: true,
switchLanguage: false,
sliderValue: 5,
});
} else {
// countdown
// this.setState({
// showTestStart: false,
// showCountdownTestStart: true,
// switchLanguage: false,
// });
}
}
}
hideClassicTestStart = () => {
this.setState({
showClassicTestStart: false,
});
}
hideLivesTestStart = () => {
this.setState({
showLivesTestStart: false,
});
}
hideCountdownTestStart = () => {
this.setState({
showCountdownTestStart: false,
});
}
changeSliderValue = (value) => {
if (value >= 1 && value <= 999) this.setState({
sliderValue: value,
});
}
handleSwitchLanguageChange = (event) => {
this.setState({
switchLanguage: event.target.checked,
});
}
handleSearchInput = (event) => {
if (!this.state.loadingSets) {
this.setState({
searchInput: event.target.value,
});
}
}
search = () => {
this.loadSets(true);
}
render() {
return (
<div>
<NavBar items={this.state.navbarItems} />
<main>
<div className="page-header">
<h1>Search Sets</h1>
<div className="button-container">
<Button
onClick={this.showTestStart}
disabled={!this.state.canStartTest}
>
Test ({Object.values(this.state.selections).filter(x => x === true).length})
</Button>
</div>
</div>
<form className="search-box-container" onSubmit={(e) => e.preventDefault()} >
<input type="submit" className="form-submit" onClick={this.search} />
<input
type="text"
className="search-box"
onChange={this.handleSearchInput}
value={this.state.searchInput}
ref={inputEl => (this.searchInputRef = inputEl)}
autoComplete="off"
placeholder="Search is case-sensitive"
/>
<Button
onClick={this.search}
icon={<ArrowForwardRoundedIcon />}
className="button--round"
disabled={this.state.loadingSets}
loading={this.state.loadingSets}
></Button>
</form>
<div className="checkbox-list">
{
this.state.sets.slice(-50).map(set =>
<div key={set.id}>
<label>
<Checkbox
name={set.id}
checked={this.state.selections[set.id]}
onChange={this.handleSetSelectionChange}
inputProps={{ 'aria-label': 'checkbox' }}
/>
<Link to={`/sets/${set.id}`}>
{set.data().title}
</Link>
</label>
</div>
)}
</div>
{
!this.state.loadedAllSets && this.state.sets.length === paginationFrequency &&
<Button
onClick={() => this.loadSets()}
disabled={this.state.loadingSets}
className="load-more-button"
>
Load More
</Button>
}
</main>
<Footer />
{
this.state.showTestStart &&
<TestStart
hideTestStart={this.hideTestStart}
showIndividualTestPrompt={this.showIndividualTestPrompt}
loading={this.state.loading}
/>
}
{
this.state.showClassicTestStart &&
<ClassicTestStart
hide={this.hideClassicTestStart}
startTest={this.startTest}
max={this.state.totalTestQuestions}
sliderValue={this.state.sliderValue}
onSliderChange={this.changeSliderValue}
switchLanguage={this.state.switchLanguage}
handleSwitchLanguageChange={this.handleSwitchLanguageChange}
loading={this.state.loading}
/>
}
{
this.state.showLivesTestStart &&
<LivesTestStart
hide={this.hideLivesTestStart}
startTest={this.startTest}
max={20}
sliderValue={this.state.sliderValue}
onSliderChange={this.changeSliderValue}
switchLanguage={this.state.switchLanguage}
handleSwitchLanguageChange={this.handleSwitchLanguageChange}
loading={this.state.loading}
/>
}
{
this.state.showCountdownTestStart &&
<CountdownTestStart
hide={this.hideCountdownTestStart}
startTest={this.startTest}
/>
}
</div>
)
}
})

20
src/css/SearchSets.css Normal file
View File

@@ -0,0 +1,20 @@
.search-box-container {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12px;
}
.search-box {
border: 1px solid var(--text-color-tinted);
border-radius: 6px;
flex-grow: 1;
margin-right: 8px;
font-size: 18px;
padding: 8px 12px;
}
.load-more-button {
margin: auto;
}