diff --git a/.eslintrc.js b/.eslintrc.js index fecd7b37c..1af7ec0b4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -100,6 +100,10 @@ module.exports = { "import/no-relative-packages": "off", "import/no-import-module-exports": "off", "no-use-before-define": "off", + "no-param-reassign": [ + "error", + { props: true, ignorePropertyModificationsFor: ["draftState"] }, + ], "@typescript-eslint/no-use-before-define": ["error"], }, diff --git a/package.json b/package.json index c7cb11e46..778cc65b9 100644 --- a/package.json +++ b/package.json @@ -28,20 +28,16 @@ "cors": "^2.8.3", "dayjs": "^1.11.7", "dotenv": "^2.0.0", - "eventemitter3": "^1.2.0", "express": "^4.17.3", "express-body-parser-error-handler": "^1.0.4", "express-jwt": "^8.4.1", "express-session": "^1.17.3", "express-sslify": "^1.2.0", "express-ws": "^5.0.2", - "fastclick": "^1.0.6", - "fbjs": "^3.0.4", "fetch-mock": "^9.11.0", "flip-toolkit": "^7.1.0", "google-map-react": "^2.2.0", "history": "^5.3.0", - "immutability-helper": "^3.1.1", "isomorphic-style-loader": "^5.3.2", "jsonwebtoken": "^9.0.0", "lodash.get": "^4.4.2", @@ -55,7 +51,6 @@ "pg": "^8.9.0", "pretty-error": "^3.0.4", "prop-types": "^15.8.1", - "qs": "^6.11.2", "react": "^18.2.0", "react-autosuggest": "^10.0.2", "react-bootstrap": "^2.7.0", @@ -63,7 +58,6 @@ "react-flip-toolkit": "^7.0.17", "react-geosuggest": "^2.14.1", "react-icons": "^4.7.1", - "react-intl": "^6.4.2", "react-redux": "^8.0.5", "react-scroll": "^1.8.9", "react-transition-group": "^4.4.5", @@ -78,9 +72,7 @@ "sequelize-typescript": "^2.1.5", "serialize-javascript": "^6.0.1", "source-map-support": "^0.5.21", - "sqlite3": "^5.1.5", - "universal-router": "^8.1.0", - "uuid": "^9.0.0" + "universal-router": "^8.1.0" }, "devDependencies": { "@istanbuljs/nyc-config-typescript": "^1.0.2", @@ -99,7 +91,6 @@ "@types/express-session": "^1.17.6", "@types/express-sslify": "^1.2.2", "@types/express-ws": "^3.0.1", - "@types/fbjs": "^3.0.4", "@types/google-map-react": "^2.1.7", "@types/google.analytics": "^0.0.42", "@types/google.maps": "^3.52.1", @@ -119,12 +110,10 @@ "@types/serialize-javascript": "^5.0.2", "@types/sinon": "^10.0.15", "@types/supertest": "^2.0.12", - "@types/uuid": "^9.0.0", "@types/validator": "^13.7.17", "@types/webpack-env": "^1.18.0", "@typescript-eslint/eslint-plugin": "^5.59.1", "@typescript-eslint/parser": "^5.59.1", - "assets-webpack-plugin": "^7.1.1", "autoprefixer": "^9.1.5", "browser-sync": "2.29.1", "chai": "4.3.7", @@ -132,10 +121,7 @@ "chokidar": "^3.5.3", "cross-env": "^5.0.1", "css-loader": "^6.7.3", - "custom-event-polyfill": "^0.3.0", "cypress": "^12.5.1", - "del": "^2.2.2", - "es6-promise": "^4.1.0", "eslint": "^8.34.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^8.8.0", @@ -148,13 +134,10 @@ "eslint-plugin-react-hooks": "^4.6.0", "file-loader": "^6.2.0", "fork-ts-checker-webpack-plugin": "^7.3.0", - "git-repository": "^0.1.4", "glob": "^7.1.3", "global-jsdom": "^9.0.1", "husky": "^8.0.3", - "identity-obj-proxy": "^3.0.0", "jsdom": "^22.0.0", - "json-loader": "^0.5.7", "lint-staged": "^13.2.2", "mkdirp": "^2.1.3", "mocha": "^10.2.0", @@ -170,13 +153,11 @@ "react-dev-utils": "^12.0.1", "react-error-overlay": "^4.0.1", "react-refresh": "^0.14.0", - "react-test-renderer": "^18.2.0", "rimraf": "^2.6.2", "sass": "^1.58.0", "sass-loader": "^13.2.0", "sequelize-mock": "^0.7.0", "sinon": "^15.1.0", - "style-loader": "^0.13.2", "stylelint": "^15.6.2", "stylelint-config-standard-scss": "^9.0.0", "stylelint-order": "^6.0.3", diff --git a/src/actions/flash.ts b/src/actions/flash.ts index a18c9fbc6..4134eb04c 100644 --- a/src/actions/flash.ts +++ b/src/actions/flash.ts @@ -1,11 +1,14 @@ -import { v1 } from "uuid"; +import nodeCrypto from "crypto"; import { Action } from "../interfaces"; +import canUseDOM from "../helpers/canUseDOM"; + +const crypto = canUseDOM ? window.crypto : nodeCrypto; export function flashError(message: string): Action { return { type: "FLASH_ERROR", message, - id: v1(), + id: crypto.randomUUID(), }; } @@ -13,7 +16,7 @@ export function flashSuccess(message: string): Action { return { type: "FLASH_SUCCESS", message, - id: v1(), + id: crypto.randomUUID(), }; } diff --git a/src/actions/index.ts b/src/actions/index.ts new file mode 100644 index 000000000..2c67d5deb --- /dev/null +++ b/src/actions/index.ts @@ -0,0 +1,22 @@ +import { ThunkAction } from "@reduxjs/toolkit"; +import { Action, State } from "../interfaces"; +import { removeRestaurant } from "./restaurants"; +import { removeTag } from "./tags"; +import { changeUserRole, removeUser } from "./users"; + +const generateConfirmableActions = < + T extends { + [K in keyof T]: ( + ...args: Parameters + ) => ThunkAction, State, unknown, Action>; + } +>( + actions: T +) => actions; + +export const confirmableActions = generateConfirmableActions({ + changeUserRole, + removeRestaurant, + removeTag, + removeUser, +}); diff --git a/src/actions/modals.ts b/src/actions/modals.ts index 8ac4675c7..0346c7399 100644 --- a/src/actions/modals.ts +++ b/src/actions/modals.ts @@ -1,3 +1,4 @@ +import { confirmableActions } from "."; import { Action, ConfirmOpts, PastDecisionsOpts } from "../interfaces"; export function showModal(name: string): Action; @@ -5,7 +6,10 @@ export function showModal( name: "pastDecisions", opts?: PastDecisionsOpts ): Action; -export function showModal(name: "confirm", opts?: ConfirmOpts): Action; +export function showModal( + name: "confirm", + opts?: ConfirmOpts +): Action; export function showModal(name: unknown, opts?: unknown): unknown { return { diff --git a/src/actions/restaurants.ts b/src/actions/restaurants.ts index d5eab3d42..4abef9db5 100644 --- a/src/actions/restaurants.ts +++ b/src/actions/restaurants.ts @@ -274,7 +274,7 @@ export function addRestaurant( export function removeRestaurant( id: number -): ThunkAction, State, unknown, Action> { +): ThunkAction, State, unknown, Action> { return (dispatch) => { dispatch(deleteRestaurant(id)); return fetch(`/api/restaurants/${id}`, { diff --git a/src/actions/users.ts b/src/actions/users.ts index c4f9fe6fa..fecc4c14d 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -1,6 +1,6 @@ import { ThunkAction } from "@reduxjs/toolkit"; -import { canUseDOM } from "fbjs/lib/ExecutionEnvironment"; import { credentials, jsonHeaders, processResponse } from "../core/ApiClient"; +import canUseDOM from "../helpers/canUseDOM"; import { Action, RoleType, State, Team, User } from "../interfaces"; import { getCurrentUser } from "../selectors/user"; @@ -93,7 +93,7 @@ export function userDeleted(id: number, team: Team, isSelf: boolean): Action { export function removeUser( id: number, team: Team -): ThunkAction { +): ThunkAction, State, unknown, Action> { return (dispatch, getState) => { const state = getState(); let isSelf = false; @@ -182,7 +182,7 @@ export function userPatched( export function changeUserRole( id: number, type: RoleType -): ThunkAction { +): ThunkAction, State, unknown, Action> { const payload = { id, type }; return (dispatch, getState) => { const state = getState(); diff --git a/src/api/tests/decisions.test.ts b/src/api/tests/decisions.test.ts index b377b63bd..ea46d3cfc 100644 --- a/src/api/tests/decisions.test.ts +++ b/src/api/tests/decisions.test.ts @@ -4,8 +4,7 @@ import { expect } from "chai"; import { SinonSpy, match, spy, stub } from "sinon"; import bodyParser from "body-parser"; -import { Response } from "superagent"; -import request from "supertest"; +import request, { Response } from "supertest"; import express, { Application, RequestHandler } from "express"; import proxyquire from "proxyquire"; import SequelizeMock from "sequelize-mock"; diff --git a/src/api/tests/user.test.ts b/src/api/tests/user.test.ts index 461bc8fa0..4e40bf052 100644 --- a/src/api/tests/user.test.ts +++ b/src/api/tests/user.test.ts @@ -4,8 +4,7 @@ import { expect } from "chai"; import { SinonSpy, spy, stub } from "sinon"; import bodyParser from "body-parser"; -import { Response } from "superagent"; -import request from "supertest"; +import request, { Response } from "supertest"; import express, { Application } from "express"; import proxyquire from "proxyquire"; import SequelizeMock from "sequelize-mock"; diff --git a/src/api/tests/users.test.ts b/src/api/tests/users.test.ts index f7faca52d..c6f42ac4e 100644 --- a/src/api/tests/users.test.ts +++ b/src/api/tests/users.test.ts @@ -4,8 +4,7 @@ import { expect } from "chai"; import { SinonSpy, SinonStub, match, spy, stub } from "sinon"; import bodyParser from "body-parser"; -import { Response } from "superagent"; -import request from "supertest"; +import request, { Response } from "supertest"; import express, { Application, RequestHandler } from "express"; import proxyquire from "proxyquire"; import SequelizeMock from "sequelize-mock"; diff --git a/src/client.tsx b/src/client.tsx index fced1d19b..d1bcc7a46 100644 --- a/src/client.tsx +++ b/src/client.tsx @@ -7,10 +7,8 @@ * LICENSE.txt file in the root directory of this source tree. */ -import es6Promise from "es6-promise"; import React, { useEffect } from "react"; import { createRoot, hydrateRoot } from "react-dom/client"; -import qs from "qs"; import { Action, createPath, Location } from "history"; import App from "./components/App"; import configureStore from "./store/configureStore"; @@ -19,8 +17,6 @@ import { updateMeta } from "./DOMUtils"; import routerCreator from "./router"; import { AppContext, App as AppType } from "./interfaces"; -es6Promise.polyfill(); - let subdomain: string | undefined; interface WindowWithApp extends Window { @@ -41,6 +37,11 @@ if (teamSlug && host.indexOf(teamSlug) === 0) { } window.App.state.host = host; +// Hack for Electron (Cypress) +window.crypto.randomUUID = + window.crypto.randomUUID || + (() => Math.round(Math.random() * 10 ** 16).toString()); + if (!subdomain) { // escape domain periods to not appear as regex wildcards const subdomainMatch = window.location.host.match( @@ -114,7 +115,7 @@ const onLocationChange = async ({ const isInitialRender = !action; try { context.pathname = location.pathname; - context.query = qs.parse(location.search, { ignoreQueryPrefix: true }); + context.query = new URLSearchParams(location.search); // Traverses the list of routes in the order they are defined until // it finds the first route that matches provided URL path string diff --git a/src/components/AddUserForm/AddUserForm.test.tsx b/src/components/AddUserForm/AddUserForm.test.tsx index 6f67bc5e3..19cd1dff7 100644 --- a/src/components/AddUserForm/AddUserForm.test.tsx +++ b/src/components/AddUserForm/AddUserForm.test.tsx @@ -4,27 +4,11 @@ /* eslint-disable no-unused-expressions */ import React from "react"; import { expect } from "chai"; -import proxyquire from "proxyquire"; -import PropTypes from "prop-types"; import { render, screen, within } from "../../../test/test-utils"; -import { AddUserFormProps } from "./AddUserForm"; - -const proxyquireStrict = proxyquire.noCallThru(); - -const AddUserForm = proxyquireStrict("./AddUserForm", { - "react-intl": { - intlShape: { - isRequired: PropTypes.shape({}).isRequired, - }, - }, -}).default; - -interface MockAddUserFormProps extends Omit { - intl: unknown; -} +import AddUserForm, { AddUserFormProps } from "./AddUserForm"; describe("AddUserForm", () => { - let props: MockAddUserFormProps; + let props: AddUserFormProps; beforeEach(() => { props = { @@ -32,9 +16,6 @@ describe("AddUserForm", () => { hasGuestRole: false, hasMemberRole: false, hasOwnerRole: false, - intl: { - formatMessage: () => "", - }, }; }); diff --git a/src/components/AddUserForm/AddUserForm.tsx b/src/components/AddUserForm/AddUserForm.tsx index 315b1e659..42c212d0e 100644 --- a/src/components/AddUserForm/AddUserForm.tsx +++ b/src/components/AddUserForm/AddUserForm.tsx @@ -3,8 +3,6 @@ import Button from "react-bootstrap/Button"; import Col from "react-bootstrap/Col"; import Form from "react-bootstrap/Form"; import Row from "react-bootstrap/Row"; -import { IntlShape } from "react-intl"; -import { globalMessageDescriptor as gm } from "../../helpers/generateMessageDescriptor"; import { RoleType } from "../../interfaces"; interface AddUserFormState { @@ -18,7 +16,6 @@ export interface AddUserFormProps { hasGuestRole: boolean; hasMemberRole: boolean; hasOwnerRole: boolean; - intl: IntlShape; } class AddUserForm extends Component { @@ -50,12 +47,7 @@ class AddUserForm extends Component { }; render() { - const { - hasGuestRole, - hasMemberRole, - hasOwnerRole, - intl: { formatMessage: f }, - } = this.props; + const { hasGuestRole, hasMemberRole, hasOwnerRole } = this.props; const { email, name, type } = this.state; return ( @@ -96,15 +88,9 @@ class AddUserForm extends Component { value={type} required > - {hasGuestRole && ( - - )} - {hasMemberRole && ( - - )} - {hasOwnerRole && ( - - )} + {hasGuestRole && } + {hasMemberRole && } + {hasOwnerRole && } diff --git a/src/components/AddUserForm/AddUserFormContainer.ts b/src/components/AddUserForm/AddUserFormContainer.ts index 05248ad0a..af92c3396 100644 --- a/src/components/AddUserForm/AddUserFormContainer.ts +++ b/src/components/AddUserForm/AddUserFormContainer.ts @@ -1,5 +1,4 @@ import { connect } from "react-redux"; -import { injectIntl } from "react-intl"; import { addUser } from "../../actions/users"; import { currentUserHasRole, isUserListReady } from "../../selectors"; import AddUserForm from "./AddUserForm"; @@ -16,7 +15,4 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({ addUserToTeam: (payload: Partial) => dispatch(addUser(payload)), }); -export default connect( - mapStateToProps, - mapDispatchToProps -)(injectIntl(AddUserForm)); +export default connect(mapStateToProps, mapDispatchToProps)(AddUserForm); diff --git a/src/components/App.tsx b/src/components/App.tsx index 3a0886ec8..cba74cd8d 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -13,7 +13,6 @@ import React, { Children, ReactNode } from "react"; import { Provider as ReduxProvider } from "react-redux"; import { Libraries, Loader } from "@googlemaps/js-api-loader"; import { AppContext } from "../interfaces"; -import IntlProviderContainer from "./IntlProvider/IntlProviderContainer"; import GoogleMapsLoaderContext from "./GoogleMapsLoaderContext/GoogleMapsLoaderContext"; const ContextType = { @@ -88,11 +87,9 @@ class App extends React.PureComponent { return ( - - - {Children.only(this.props.children)} - - + + {Children.only(this.props.children)} + ); diff --git a/src/components/ConfirmModal/ConfirmModalContainer.ts b/src/components/ConfirmModal/ConfirmModalContainer.ts index 71a8396dc..1a9abffdc 100644 --- a/src/components/ConfirmModal/ConfirmModalContainer.ts +++ b/src/components/ConfirmModal/ConfirmModalContainer.ts @@ -1,30 +1,36 @@ import { connect } from "react-redux"; +import { confirmableActions } from "../../actions"; import { hideModal } from "../../actions/modals"; import ConfirmModal from "./ConfirmModal"; -import { Dispatch, State } from "../../interfaces"; +import { + ConfirmModal as ConfirmModalType, + Dispatch, + State, +} from "../../interfaces"; const modalName = "confirm"; -const mapStateToProps = (state: State) => ({ - actionLabel: state.modals[modalName].actionLabel, - body: state.modals[modalName].body, - action: state.modals[modalName].action, - shown: !!state.modals[modalName].shown, -}); +const mapStateToProps = ( + state: State +) => state.modals[modalName] as ConfirmModalType; const mapDispatchToProps = (dispatch: Dispatch) => ({ dispatch, hideModal: () => dispatch(hideModal("confirm")), }); -const mergeProps = ( - stateProps: ReturnType, +const mergeProps = ( + stateProps: ConfirmModalType, dispatchProps: ReturnType ) => ({ ...stateProps, ...dispatchProps, handleSubmit: () => { - dispatchProps.dispatch(stateProps.action); + dispatchProps.dispatch( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + confirmableActions[stateProps.action](...stateProps.actionArgs) + ); dispatchProps.hideModal(); }, }); diff --git a/src/components/IntlProvider/IntlProviderContainer.ts b/src/components/IntlProvider/IntlProviderContainer.ts deleted file mode 100644 index 493901319..000000000 --- a/src/components/IntlProvider/IntlProviderContainer.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { connect } from "react-redux"; -import { IntlConfig, IntlProvider } from "react-intl"; -import { State } from "../../interfaces"; -import { getLocale, getMessages } from "../../selectors/locale"; - -const mapStateToProps = (state: State, ownProps: Partial) => ({ - locale: getLocale(state), - messages: getMessages(state), - ...ownProps, -}); - -export default connect(mapStateToProps)(IntlProvider); diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index 8700afb05..7904e00f3 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -9,16 +9,17 @@ import React, { Component, ReactNode } from "react"; import PropTypes from "prop-types"; -import { canUseDOM } from "fbjs/lib/ExecutionEnvironment"; -import emptyFunction from "fbjs/lib/emptyFunction"; +import { InsertCSS } from "isomorphic-style-loader/StyleContext"; +// eslint-disable-next-line css-modules/no-unused-class, no-unused-vars +import globalCss from "../../styles/globalCss.scss"; +import canUseDOM from "../../helpers/canUseDOM"; import HeaderContainer from "../Header/HeaderContainer"; import FooterContainer from "../Footer/FooterContainer"; import NotificationListContainer from "../NotificationList/NotificationListContainer"; import ConfirmModalContainer from "../ConfirmModal/ConfirmModalContainer"; import s from "./Layout.scss"; -// eslint-disable-next-line css-modules/no-unused-class, no-unused-vars -import globalCss from "../../styles/globalCss.scss"; -import { InsertCSS } from "isomorphic-style-loader/StyleContext"; + +const emptyFunction = () => undefined; export interface LayoutProps { children: ReactNode; diff --git a/src/components/PastDecisionsModal/PastDecisionsModalContainer.ts b/src/components/PastDecisionsModal/PastDecisionsModalContainer.ts index 5a6997f45..b9c3a9e52 100644 --- a/src/components/PastDecisionsModal/PastDecisionsModalContainer.ts +++ b/src/components/PastDecisionsModal/PastDecisionsModalContainer.ts @@ -1,7 +1,11 @@ import { connect } from "react-redux"; import { decide } from "../../actions/decisions"; import { hideModal } from "../../actions/modals"; -import { Dispatch, State } from "../../interfaces"; +import { + Dispatch, + State, + PastDecisionsModal as PastDecisionsModalType, +} from "../../interfaces"; import { getDecisionsByDay } from "../../selectors/decisions"; import { getRestaurantEntities } from "../../selectors/restaurants"; import PastDecisionsModal from "./PastDecisionsModal"; @@ -10,7 +14,8 @@ const modalName = "pastDecisions"; const mapStateToProps = (state: State) => ({ decisionsByDay: getDecisionsByDay(state), - restaurantId: state.modals[modalName].restaurantId, + restaurantId: (state.modals[modalName] as PastDecisionsModalType) + .restaurantId, restaurantEntities: getRestaurantEntities(state), shown: !!state.modals[modalName].shown, }); diff --git a/src/components/RestaurantAddForm/RestaurantAddForm.tsx b/src/components/RestaurantAddForm/RestaurantAddForm.tsx index 1b9c1bbe1..197f62742 100644 --- a/src/components/RestaurantAddForm/RestaurantAddForm.tsx +++ b/src/components/RestaurantAddForm/RestaurantAddForm.tsx @@ -2,18 +2,14 @@ import React, { Component, RefObject, Suspense, createRef, lazy } from "react"; import GeosuggestClass, { GeosuggestProps, Suggest } from "react-geosuggest"; -import { IntlShape } from "react-intl"; -import { canUseDOM } from "fbjs/lib/ExecutionEnvironment"; import withStyles from "isomorphic-style-loader/withStyles"; -import generateMessageDescriptor from "../../helpers/generateMessageDescriptor"; +import canUseDOM from "../../helpers/canUseDOM"; import { LatLng } from "../../interfaces"; import GoogleMapsLoaderContext, { IGoogleMapsLoaderContext, } from "../GoogleMapsLoaderContext/GoogleMapsLoaderContext"; import s from "./RestaurantAddForm.scss"; -const m = generateMessageDescriptor("RestaurantAddForm"); - const Geosuggest = lazy( () => import(/* webpackChunkName: 'geosuggest' */ "react-geosuggest") ); @@ -27,7 +23,6 @@ interface RestaurantAddFormProps geosuggest: GeosuggestClass ) => void; latLng: LatLng; - intl: IntlShape; } class RestaurantAddForm extends Component { @@ -76,10 +71,6 @@ class RestaurantAddForm extends Component { }; render() { - const { - intl: { formatMessage: f }, - } = this.props; - return (
{this.maps ? ( @@ -101,7 +92,7 @@ class RestaurantAddForm extends Component { suggestItemClassName={s.suggestItem} suggestItemActiveClassName={s.suggestItemActive} suggestsClassName={s.suggests} - placeholder={f(m("addPlaces"))} + placeholder="Add places" onBlur={this.props.clearTempMarker} onActivateSuggest={this.getCoordsForMarker} onSuggestSelect={this.handleSuggestSelect} diff --git a/src/components/RestaurantAddForm/RestaurantAddFormContainer.ts b/src/components/RestaurantAddForm/RestaurantAddFormContainer.ts index e0fe92e09..74b9d1726 100644 --- a/src/components/RestaurantAddForm/RestaurantAddFormContainer.ts +++ b/src/components/RestaurantAddForm/RestaurantAddFormContainer.ts @@ -1,7 +1,6 @@ import Geosuggest, { Suggest } from "react-geosuggest"; import { connect } from "react-redux"; import { scroller } from "react-scroll"; -import { injectIntl } from "react-intl"; import { Dispatch, State } from "../../interfaces"; import { getRestaurants } from "../../selectors/restaurants"; import { getTeamLatLng } from "../../selectors/team"; @@ -95,4 +94,4 @@ export default connect( mapStateToProps, mapDispatchToProps, mergeProps -)(injectIntl(RestaurantAddForm)); +)(RestaurantAddForm); diff --git a/src/components/RestaurantDropdown/RestaurantDropdownContainer.ts b/src/components/RestaurantDropdown/RestaurantDropdownContainer.ts index daea85225..f72f82066 100644 --- a/src/components/RestaurantDropdown/RestaurantDropdownContainer.ts +++ b/src/components/RestaurantDropdown/RestaurantDropdownContainer.ts @@ -56,7 +56,8 @@ const mergeProps = ( showModal("confirm", { actionLabel: "Delete", body: `Are you sure you want to delete ${stateProps.restaurant.name}?`, - action: removeRestaurant(ownProps.id), + action: "removeRestaurant", + actionArgs: [ownProps.id], }) ), showEditNameForm: () => { diff --git a/src/components/RestaurantMarker/RestaurantMarker.tsx b/src/components/RestaurantMarker/RestaurantMarker.tsx index 0d6cb5f0b..6d2e8106c 100644 --- a/src/components/RestaurantMarker/RestaurantMarker.tsx +++ b/src/components/RestaurantMarker/RestaurantMarker.tsx @@ -1,7 +1,6 @@ -import PropTypes from "prop-types"; import React from "react"; -import { canUseDOM } from "fbjs/lib/ExecutionEnvironment"; import withStyles from "isomorphic-style-loader/withStyles"; +import canUseDOM from "../../helpers/canUseDOM"; import { AppContext, Restaurant } from "../../interfaces"; import App from "../App"; import RestaurantContainer from "../Restaurant/RestaurantContainer"; @@ -110,8 +109,4 @@ const RestaurantMarker = ({ restaurant, ...props }: RestaurantMarkerProps) => { ); }; -RestaurantMarker.defaultProps = { - query: undefined, -}; - export default RestaurantMarker; diff --git a/src/components/TagManagerItem/TagManagerItemContainer.ts b/src/components/TagManagerItem/TagManagerItemContainer.ts index 578a32059..885bbd492 100644 --- a/src/components/TagManagerItem/TagManagerItemContainer.ts +++ b/src/components/TagManagerItem/TagManagerItemContainer.ts @@ -1,8 +1,7 @@ import { connect } from "react-redux"; import { getTagById } from "../../selectors/tags"; import { showModal } from "../../actions/modals"; -import { removeTag } from "../../actions/tags"; -import { Dispatch, State } from "../../interfaces"; +import { ConfirmOpts, Dispatch, State } from "../../interfaces"; import TagManagerItem from "./TagManagerItem"; interface OwnProps { @@ -28,11 +27,12 @@ const mergeProps = ( handleDeleteClicked() { dispatchProps.dispatch( showModal("confirm", { + action: "removeTag", + actionArgs: [ownProps.id], actionLabel: "Delete", body: `Are you sure you want to delete the “${stateProps.tag.name}” tag? All restaurants will be untagged.`, - action: removeTag(ownProps.id), - }) + } as ConfirmOpts<"removeTag">) ); }, }); diff --git a/src/components/TeamForm/TeamFormContainer.ts b/src/components/TeamForm/TeamFormContainer.ts index f8913b60b..933aec9cb 100644 --- a/src/components/TeamForm/TeamFormContainer.ts +++ b/src/components/TeamForm/TeamFormContainer.ts @@ -1,5 +1,4 @@ import { connect } from "react-redux"; -import { injectIntl } from "react-intl"; import { flashSuccess } from "../../actions/flash"; import { updateTeam } from "../../actions/team"; import { Dispatch, State, Team } from "../../interfaces"; @@ -19,7 +18,4 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({ ), }); -export default connect( - mapStateToProps, - mapDispatchToProps -)(injectIntl(TeamForm)); +export default connect(mapStateToProps, mapDispatchToProps)(TeamForm); diff --git a/src/components/TeamGeosuggest/TeamGeosuggest.tsx b/src/components/TeamGeosuggest/TeamGeosuggest.tsx index 816a83a0c..e877ec4bc 100644 --- a/src/components/TeamGeosuggest/TeamGeosuggest.tsx +++ b/src/components/TeamGeosuggest/TeamGeosuggest.tsx @@ -1,6 +1,6 @@ import React, { Component, Suspense, lazy } from "react"; import { Suggest } from "react-geosuggest"; -import { canUseDOM } from "fbjs/lib/ExecutionEnvironment"; +import canUseDOM from "../../helpers/canUseDOM"; import { LatLng } from "../../interfaces"; import GoogleMapsLoaderContext, { IGoogleMapsLoaderContext, diff --git a/src/core/messages/en.json b/src/core/messages/en.json deleted file mode 100644 index d750d470a..000000000 --- a/src/core/messages/en.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "RestaurantAddForm": { - "addPlaces": "Add places" - }, - "memberRole": "Member", - "noUserName": "(Name not entered)", - "ownerRole": "Owner", - "guestRole": "Guest" -} diff --git a/src/core/messages/index.ts b/src/core/messages/index.ts deleted file mode 100644 index da7e48846..000000000 --- a/src/core/messages/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as en } from "./en.json"; diff --git a/src/helpers/canUseDOM.ts b/src/helpers/canUseDOM.ts new file mode 100644 index 000000000..05df21497 --- /dev/null +++ b/src/helpers/canUseDOM.ts @@ -0,0 +1,5 @@ +export default !!( + typeof window !== "undefined" && + window.document && + window.document.createElement +); diff --git a/src/helpers/generateMessageDescriptor.ts b/src/helpers/generateMessageDescriptor.ts deleted file mode 100644 index 864c3b0ef..000000000 --- a/src/helpers/generateMessageDescriptor.ts +++ /dev/null @@ -1,12 +0,0 @@ -import get from "lodash.get"; -import * as messages from "../core/messages"; - -export const globalMessageDescriptor = (id: string) => ({ - id, - defaultMessage: get(messages.en, id), -}); - -export default (component: string) => (id: string) => { - const namespacedId = `${component}.${id}`; - return globalMessageDescriptor(namespacedId); -}; diff --git a/src/initialState.ts b/src/initialState.ts index ccc6ab128..2f333bf4a 100644 --- a/src/initialState.ts +++ b/src/initialState.ts @@ -20,7 +20,6 @@ const getInitialState = (): NonNormalizedState => ({ listUi: { flipMove: true, }, - locale: "en", mapUi: { infoWindow: {}, showUnvoted: true, diff --git a/src/interfaces.ts b/src/interfaces.ts index f7c1f9ff5..0e54a6852 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,11 +1,11 @@ import { Application, RequestHandler } from "express"; -import { EnhancedStore, ThunkAction, ThunkDispatch } from "@reduxjs/toolkit"; +import { EnhancedStore, ThunkDispatch } from "@reduxjs/toolkit"; import { BrowserHistory } from "history"; import { InsertCSS } from "isomorphic-style-loader/StyleContext"; -import { ParsedQs } from "qs"; import { ReactNode } from "react"; import { ResolveContext } from "universal-router"; import { WebSocket } from "ws"; +import { confirmableActions } from "./actions"; import { Decision as DecisionModel, Restaurant as RestaurantModel, @@ -388,7 +388,11 @@ export type Action = | { type: "SHOW_MODAL"; name: "confirm"; - opts: ConfirmOpts; + opts: ConfirmOpts; + } + | { + type: "SHOW_MODAL"; + name: string; } | { type: "HIDE_MODAL"; @@ -448,12 +452,25 @@ export interface Notification { ); } -export type ConfirmOpts = { +export type ConfirmOpts = { actionLabel: string; body: string; - action: Action | ThunkAction; + action: T; + actionArgs: Parameters<(typeof confirmableActions)[T]>; }; +export type BaseModal = { + shown: boolean; +}; + +export type ConfirmModal = + BaseModal & ConfirmOpts; +export type PastDecisionsModal = BaseModal & PastDecisionsOpts; +export type Modal = + | BaseModal + | ConfirmModal + | PastDecisionsModal; + export interface ListUiItem { isEditingName?: boolean; editNameFormValue?: string; @@ -486,13 +503,7 @@ interface BaseState { host: string; notifications: Notification[]; modals: { - [index: string]: { - action: () => void; - actionLabel: string; - body: ReactNode; - restaurantId?: number; - shown: boolean; - }; + [index: string]: Modal; }; listUi: { [index: number]: ListUiItem; @@ -500,7 +511,6 @@ interface BaseState { flipMove: boolean; newlyAdded?: NewlyAdded; }; - locale: "en"; mapUi: { center?: { lat: number; @@ -600,7 +610,7 @@ export interface AppContext extends ResolveContext { insertCss: InsertCSS; googleApiKey: string; pathname: string; - query?: ParsedQs; + query?: URLSearchParams; store: EnhancedStore; } diff --git a/src/middlewares/invitation.ts b/src/middlewares/invitation.ts index 5d8932ac1..288a0a90d 100644 --- a/src/middlewares/invitation.ts +++ b/src/middlewares/invitation.ts @@ -1,5 +1,4 @@ import { Request, Router } from "express"; -import qs from "qs"; import { bsHost } from "../config"; import generateToken from "../helpers/generateToken"; import generateUrl from "../helpers/generateUrl"; @@ -53,7 +52,7 @@ export default () => { Add them here: ${generateUrl( req, bsHost, - `/users/new?email=${qs.stringify(invitation.email)}` + `/users/new?email=${encodeURIComponent(invitation.email)}` )}`, }); } diff --git a/src/middlewares/tests/invitation.test.ts b/src/middlewares/tests/invitation.test.ts index 5b75b3cc6..84758f91c 100644 --- a/src/middlewares/tests/invitation.test.ts +++ b/src/middlewares/tests/invitation.test.ts @@ -4,9 +4,8 @@ import { expect } from "chai"; import { SinonSpy, match, spy, stub } from "sinon"; import bodyParser from "body-parser"; -import { Response } from "superagent"; -import request from "supertest"; -import express, { Application, RequestHandler } from "express"; +import request, { Response } from "supertest"; +import express, { Application } from "express"; import session, { Session } from "express-session"; import proxyquire from "proxyquire"; import SequelizeMock from "sequelize-mock"; diff --git a/src/middlewares/tests/login.test.ts b/src/middlewares/tests/login.test.ts index 9c90c2e49..eb8c92663 100644 --- a/src/middlewares/tests/login.test.ts +++ b/src/middlewares/tests/login.test.ts @@ -3,8 +3,7 @@ import { expect } from "chai"; import { SinonSpy, SinonStub, match, spy, stub } from "sinon"; -import { Response } from "superagent"; -import request from "supertest"; +import request, { Response } from "supertest"; import express, { Application, NextFunction, RequestHandler } from "express"; import proxyquire from "proxyquire"; import mockEsmodule from "../../../test/mockEsmodule"; diff --git a/src/reducerMaps/decisions.ts b/src/reducerMaps/decisions.ts deleted file mode 100644 index a1dff6429..000000000 --- a/src/reducerMaps/decisions.ts +++ /dev/null @@ -1,85 +0,0 @@ -import update from "immutability-helper"; -import { normalize } from "normalizr"; -import { Reducer } from "../interfaces"; -import * as schemas from "../schemas"; -import isFetching from "./helpers/isFetching"; - -const decisions: Reducer<"decisions"> = (state, action) => { - switch (action.type) { - case "INVALIDATE_DECISIONS": { - return update(state, { - $merge: { - didInvalidate: true, - }, - }); - } - case "REQUEST_DECISIONS": - case "POST_DECISION": - case "DELETE_DECISION": { - return isFetching(state); - } - case "RECEIVE_DECISIONS": { - return update(state, { - $merge: { - isFetching: false, - didInvalidate: false, - items: normalize(action.items, [schemas.decision]), - }, - }); - } - case "DECISION_POSTED": { - return update(state, { - isFetching: { - $set: false, - }, - items: { - entities: { - decisions: state.items.entities.decisions - ? { - $merge: { - [action.decision.id]: action.decision, - }, - } - : { - $set: { - [action.decision.id]: action.decision, - }, - }, - }, - result: { - $apply: (result: number[]) => { - const deselectedIds = action.deselected.map((d) => d.id); - return result.reduce( - (acc, curr) => { - if (deselectedIds.indexOf(curr) === -1) { - acc.push(curr); - } - return acc; - }, - [action.decision.id] - ); - }, - }, - }, - }); - } - case "DECISIONS_DELETED": { - const decisionIds = action.decisions.map((d) => d.id); - const newState = { - isFetching: { - $set: false, - }, - items: { - result: { - $apply: (result: number[]) => - result.filter((id) => decisionIds.indexOf(id) === -1), - }, - }, - }; - return update(state, newState); - } - } - return state; -}; - -export default decisions; diff --git a/src/reducerMaps/helpers/isFetching.ts b/src/reducerMaps/helpers/isFetching.ts deleted file mode 100644 index 256afd8e5..000000000 --- a/src/reducerMaps/helpers/isFetching.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { State } from "../../interfaces"; - -type StateWithFetching = Extract; - -const isFetching = (state: T) => ({ - ...state, - isFetching: true, -}); - -export default isFetching; diff --git a/src/reducerMaps/listUi.ts b/src/reducerMaps/listUi.ts deleted file mode 100644 index 7e226143c..000000000 --- a/src/reducerMaps/listUi.ts +++ /dev/null @@ -1,60 +0,0 @@ -import update from "immutability-helper"; -import { Reducer } from "../interfaces"; - -const listUi: Reducer<"listUi"> = (state, action) => { - switch (action.type) { - case "RESTAURANT_RENAMED": - case "RESTAURANT_DELETED": { - return update(state, { - $merge: { - [action.id]: {}, - }, - }); - } - case "RESTAURANT_POSTED": { - return update(state, { - newlyAdded: { - $set: { - id: action.restaurant.id, - userId: action.userId, - }, - }, - $merge: { - [action.restaurant.id]: {}, - }, - }); - } - case "SET_EDIT_NAME_FORM_VALUE": { - return update(state, { - [action.id]: (stateValue) => - update(stateValue || {}, { - $merge: { editNameFormValue: action.value }, - }), - }); - } - case "SHOW_EDIT_NAME_FORM": { - return update(state, { - [action.id]: (stateValue) => - update(stateValue || {}, { $merge: { isEditingName: true } }), - }); - } - case "HIDE_EDIT_NAME_FORM": { - return update(state, { - [action.id]: (stateValue) => - update(stateValue || {}, { $merge: { isEditingName: false } }), - }); - } - case "SET_FLIP_MOVE": { - return update(state, { - flipMove: { - $set: action.val, - }, - }); - } - default: - break; - } - return state; -}; - -export default listUi; diff --git a/src/reducerMaps/mapUi.ts b/src/reducerMaps/mapUi.ts deleted file mode 100644 index 1c04211ea..000000000 --- a/src/reducerMaps/mapUi.ts +++ /dev/null @@ -1,144 +0,0 @@ -import update, { Spec } from "immutability-helper"; -import { Reducer } from "../interfaces"; - -const mapUi: Reducer<"mapUi"> = (state, action) => { - switch (action.type) { - case "RECEIVE_RESTAURANTS": { - return update(state, { - infoWindow: { - $set: {}, - }, - showPOIs: { - $set: !action.items.length, - }, - showUnvoted: { - $set: true, - }, - }); - } - case "RESTAURANT_POSTED": { - return update(state, { - newlyAdded: { - $set: { - id: action.restaurant.id, - userId: action.userId, - }, - }, - }); - } - case "SHOW_GOOGLE_INFO_WINDOW": { - return update(state, { - center: { - $set: { - lat: action.latLng.lat, - lng: action.latLng.lng, - }, - }, - infoWindow: { - $set: { - latLng: action.latLng, - placeId: action.placeId, - }, - }, - }); - } - case "SHOW_RESTAURANT_INFO_WINDOW": { - return update(state, { - center: { - $set: { - lat: action.restaurant.lat, - lng: action.restaurant.lng, - }, - }, - infoWindow: { - $set: { - id: action.restaurant.id, - }, - }, - }); - } - case "HIDE_INFO_WINDOW": { - return update(state, { - infoWindow: { - $set: {}, - }, - }); - } - case "SET_SHOW_POIS": { - let updates = { - showPOIs: { - $set: action.val, - }, - } as Spec; - - if (!action.val) { - updates = { - ...updates, - infoWindow: { - latLng: { - $set: undefined, - }, - placeId: { - $set: undefined, - }, - }, - }; - } - - return update(state, updates); - } - case "SET_SHOW_UNVOTED": { - return update(state, { - $merge: { - showUnvoted: action.val, - }, - }); - } - case "SET_CENTER": { - return update(state, { - center: { - $set: action.center, - }, - }); - } - case "CLEAR_CENTER": { - return update(state, { - center: { - $set: undefined, - }, - }); - } - case "CREATE_TEMP_MARKER": { - return update(state, { - center: { - $set: action.result.latLng, - }, - tempMarker: { - $set: action.result, - }, - }); - } - case "CLEAR_TEMP_MARKER": { - return update(state, { - center: { - $set: undefined, - }, - tempMarker: { - $set: undefined, - }, - }); - } - case "CLEAR_MAP_UI_NEWLY_ADDED": { - return update(state, { - newlyAdded: { - $set: undefined, - }, - }); - } - default: - break; - } - return state; -}; - -export default mapUi; diff --git a/src/reducerMaps/modals.ts b/src/reducerMaps/modals.ts deleted file mode 100644 index cda11ad2e..000000000 --- a/src/reducerMaps/modals.ts +++ /dev/null @@ -1,28 +0,0 @@ -import update from "immutability-helper"; -import { Reducer } from "../interfaces"; - -const modals: Reducer<"modals"> = (state, action) => { - switch (action.type) { - case "SHOW_MODAL": { - return update(state, { - $merge: { - [action.name]: { - shown: true, - ...action.opts, - }, - }, - }); - } - case "HIDE_MODAL": { - return update(state, { - [action.name]: (stateValue) => - update(stateValue || {}, { $merge: { shown: false } }), - }); - } - default: - break; - } - return state; -}; - -export default modals; diff --git a/src/reducerMaps/pageUi.ts b/src/reducerMaps/pageUi.ts deleted file mode 100644 index ddd37508a..000000000 --- a/src/reducerMaps/pageUi.ts +++ /dev/null @@ -1,24 +0,0 @@ -import update from "immutability-helper"; -import { Reducer } from "../interfaces"; - -const pageUi: Reducer<"pageUi"> = (state, action) => { - switch (action.type) { - case "SCROLL_TO_TOP": { - return update(state, { - $merge: { - shouldScrollToTop: true, - }, - }); - } - case "SCROLLED_TO_TOP": { - return update(state, { - $merge: { - shouldScrollToTop: false, - }, - }); - } - } - return state; -}; - -export default pageUi; diff --git a/src/reducerMaps/restaurants.ts b/src/reducerMaps/restaurants.ts deleted file mode 100644 index 2aeb1e328..000000000 --- a/src/reducerMaps/restaurants.ts +++ /dev/null @@ -1,417 +0,0 @@ -import { normalize } from "normalizr"; -import update, { Spec } from "immutability-helper"; -import { getRestaurantIds, getRestaurantById } from "../selectors/restaurants"; -import * as schemas from "../schemas"; -import isFetching from "./helpers/isFetching"; -import { Reducer } from "../interfaces"; -import maybeAddToString from "../helpers/maybeAddToString"; - -const restaurants: Reducer<"restaurants"> = (state, action) => { - switch (action.type) { - case "SORT_RESTAURANTS": { - return update(state, { - items: { - result: { - $apply: (result: number[]) => { - const sortIndexes: { [index: number]: number } = {}; - result.forEach((id, index) => { - sortIndexes[id] = index; - }); - const sortedResult = Array.from(result).sort((a, b) => { - if ( - action.newlyAdded !== undefined && - action.user.id === action.newlyAdded.userId - ) { - if (a === action.newlyAdded.id) { - return -1; - } - if (b === action.newlyAdded.id) { - return 1; - } - } - if (action.decision !== undefined) { - if (action.decision.restaurantId === a) { - return -1; - } - if (action.decision.restaurantId === b) { - return 1; - } - } - const restaurantA = getRestaurantById( - { restaurants: state }, - a - ); - const restaurantB = getRestaurantById( - { restaurants: state }, - b - ); - - if (restaurantA.votes.length !== restaurantB.votes.length) { - return restaurantB.votes.length - restaurantA.votes.length; - } - if ( - restaurantA.all_decision_count !== - restaurantB.all_decision_count - ) { - return ( - Number(restaurantA.all_decision_count) - - Number(restaurantB.all_decision_count) - ); - } - if (restaurantA.all_vote_count !== restaurantB.all_vote_count) { - return ( - Number(restaurantB.all_vote_count) - - Number(restaurantA.all_vote_count) - ); - } - // stable sort - return sortIndexes[a] - sortIndexes[b]; - }); - // If array contents match, return original (for shallow comparison) - return sortedResult.some((r, i) => r !== result[i]) - ? sortedResult - : result; - }, - }, - }, - }); - } - case "INVALIDATE_RESTAURANTS": { - return update(state, { - $merge: { - didInvalidate: true, - }, - }); - } - case "REQUEST_RESTAURANTS": { - return update(state, { - $merge: { - isFetching: true, - }, - }); - } - case "RECEIVE_RESTAURANTS": { - return update(state, { - $merge: { - isFetching: false, - didInvalidate: false, - items: normalize(action.items, [schemas.restaurant]), - }, - }); - } - case "POST_RESTAURANT": - case "DELETE_RESTAURANT": - case "RENAME_RESTAURANT": - case "POST_VOTE": - case "DELETE_VOTE": - case "POST_NEW_TAG_TO_RESTAURANT": - case "POST_TAG_TO_RESTAURANT": - case "DELETE_TAG_FROM_RESTAURANT": { - return isFetching(state); - } - case "RESTAURANT_POSTED": { - return update(state, { - isFetching: { - $set: false, - }, - items: { - entities: { - restaurants: state.items.entities.restaurants - ? { - $merge: { - [action.restaurant.id]: action.restaurant, - }, - } - : { - $set: { - [action.restaurant.id]: action.restaurant, - }, - }, - }, - result: { - $apply: (result: number[]) => { - if (result.indexOf(action.restaurant.id) === -1) { - return [action.restaurant.id, ...result]; - } - return result; - }, - }, - }, - }); - } - case "RESTAURANT_DELETED": { - return update(state, { - isFetching: { - $set: false, - }, - items: { - result: { - $splice: [ - [getRestaurantIds({ restaurants: state }).indexOf(action.id), 1], - ], - }, - }, - }); - } - case "RESTAURANT_RENAMED": { - return update(state, { - isFetching: { - $set: false, - }, - items: { - entities: { - restaurants: { - [action.id]: { - $merge: action.fields, - }, - }, - }, - }, - }); - } - case "VOTE_POSTED": { - const updates: Spec = { - isFetching: { - $set: false, - }, - items: { - entities: { - votes: state.items.entities.votes - ? { - $merge: { - [action.vote.id]: action.vote, - }, - } - : { - $set: { - [action.vote.id]: action.vote, - }, - }, - restaurants: { - $apply: (r) => { - let ret = r; - if ( - r[action.vote.restaurantId].votes.indexOf(action.vote.id) === - -1 - ) { - const restaurant = r[action.vote.restaurantId]; - ret = { - ...r, - [action.vote.restaurantId]: { - ...restaurant, - votes: [...restaurant.votes, action.vote.id], - all_vote_count: maybeAddToString( - restaurant.all_vote_count, - 1 - ), - }, - }; - } - return ret; - }, - }, - }, - }, - }; - - return update(state, updates); - } - case "VOTE_DELETED": { - return update(state, { - isFetching: { - $set: false, - }, - items: { - entities: { - restaurants: { - [action.restaurantId]: { - votes: { - $splice: [ - [ - getRestaurantById( - { restaurants: state }, - action.restaurantId - ).votes.indexOf(action.id), - 1, - ], - ], - }, - all_vote_count: { - $apply: (count) => maybeAddToString(count, -1), - }, - }, - }, - }, - }, - }); - } - case "POSTED_NEW_TAG_TO_RESTAURANT": { - return update(state, { - isFetching: { - $set: false, - }, - items: { - entities: { - restaurants: { - [action.restaurantId]: { - tags: { - $push: [action.tag.id], - }, - }, - }, - }, - }, - }); - } - case "POSTED_TAG_TO_RESTAURANT": { - const updates: Spec = { - isFetching: { - $set: false, - }, - }; - - if ( - state.items.entities.restaurants[action.restaurantId].tags.indexOf( - action.id - ) === -1 - ) { - updates.items = { - entities: { - restaurants: { - [action.restaurantId]: { - tags: { - $push: [action.id], - }, - }, - }, - }, - }; - } - - return update(state, updates); - } - case "DELETED_TAG_FROM_RESTAURANT": { - return update(state, { - isFetching: { - $set: false, - }, - items: { - entities: { - restaurants: { - [action.restaurantId]: { - tags: { - $splice: [ - [ - getRestaurantById( - { restaurants: state }, - action.restaurantId - ).tags.indexOf(action.id), - 1, - ], - ], - }, - }, - }, - }, - }, - }); - } - case "TAG_DELETED": { - return update(state, { - items: { - entities: { - restaurants: { - $apply: (r) => { - const changedRestaurants = { ...r }; - Object.keys(changedRestaurants).forEach((i) => { - const index = Number(i); - const changedRestaurant = changedRestaurants[index]; - if (changedRestaurant.tags.indexOf(action.id) > -1) { - changedRestaurants[index] = update(changedRestaurant, { - $merge: { - tags: update(changedRestaurant.tags, { - $splice: [ - [changedRestaurant.tags.indexOf(action.id), 1], - ], - }), - }, - }); - } - }); - return changedRestaurants; - }, - }, - }, - }, - }); - } - case "DECISION_POSTED": { - return update(state, { - items: { - entities: { - restaurants: { - $apply: (r) => { - const decision = r[action.decision.restaurantId]; - // eslint-disable-next-line no-param-reassign - r[action.decision.restaurantId] = { - ...r[action.decision.restaurantId], - all_decision_count: maybeAddToString( - decision.all_decision_count, - 1 - ), - }; - action.deselected.forEach((i) => { - // eslint-disable-next-line no-param-reassign - r[i.restaurantId] = { - ...r[i.restaurantId], - all_decision_count: maybeAddToString( - r[i.restaurantId].all_decision_count, - -1 - ), - }; - }); - return r; - }, - }, - }, - }, - }); - } - case "DECISIONS_DELETED": { - return update(state, { - items: { - entities: { - restaurants: { - $apply: (r) => { - const ret = r; - action.decisions.forEach((decision) => { - const restaurant = r[decision.restaurantId]; - ret[decision.restaurantId] = { - ...restaurant, - all_decision_count: maybeAddToString( - restaurant.all_decision_count, - -1 - ), - }; - }); - return ret; - }, - }, - }, - }, - }); - } - case "SET_NAME_FILTER": { - return update(state, { - nameFilter: { - $set: action.val, - }, - }); - } - default: - break; - } - return state; -}; - -export default restaurants; diff --git a/src/reducerMaps/tags.ts b/src/reducerMaps/tags.ts deleted file mode 100644 index 9285a3d64..000000000 --- a/src/reducerMaps/tags.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { normalize } from "normalizr"; -import update from "immutability-helper"; -import { getTagIds, getTagById } from "../selectors/tags"; -import * as schemas from "../schemas"; -import isFetching from "./helpers/isFetching"; -import { Reducer } from "../interfaces"; -import maybeAddToString from "../helpers/maybeAddToString"; - -const tags: Reducer<"tags"> = (state, action) => { - switch (action.type) { - case "INVALIDATE_TAGS": { - return update(state, { - $merge: { - didInvalidate: true, - }, - }); - } - case "REQUEST_TAGS": { - return update(state, { - $merge: { - isFetching: true, - }, - }); - } - case "RECEIVE_TAGS": { - return update(state, { - $merge: { - isFetching: false, - didInvalidate: false, - items: normalize(action.items, [schemas.tag]), - }, - }); - } - case "POSTED_TAG_TO_RESTAURANT": { - return update(state, { - items: { - entities: { - tags: { - [action.id]: { - restaurant_count: { - $set: maybeAddToString( - getTagById({ tags: state }, action.id).restaurant_count, - 1 - ), - }, - }, - }, - }, - }, - }); - } - case "POSTED_NEW_TAG_TO_RESTAURANT": { - return update(state, { - items: { - result: { - $push: [action.tag.id], - }, - entities: { - tags: state.items.entities.tags - ? { - $merge: { - [action.tag.id]: action.tag, - }, - } - : { - $set: { - [action.tag.id]: action.tag, - }, - }, - }, - }, - }); - } - case "DELETED_TAG_FROM_RESTAURANT": { - return update(state, { - isFetching: { - $set: false, - }, - items: { - entities: { - tags: { - [action.id]: { - $merge: { - restaurant_count: maybeAddToString( - state.items.entities.tags[action.id].restaurant_count, - -1 - ), - }, - }, - }, - }, - }, - }); - } - case "DELETE_TAG": { - return isFetching(state); - } - case "TAG_DELETED": { - const tagIndex = getTagIds({ tags: state }).indexOf(action.id); - return update(state, { - isFetching: { - $set: false, - }, - items: { - $apply: (items) => { - if (tagIndex !== -1) { - const result = [...items.result]; - result.splice(tagIndex, 1); - return { - ...items, - result, - }; - } - return items; - }, - }, - }); - } - default: - break; - } - return state; -}; - -export default tags; diff --git a/src/reducerMaps/teams.ts b/src/reducerMaps/teams.ts deleted file mode 100644 index 2b112f264..000000000 --- a/src/reducerMaps/teams.ts +++ /dev/null @@ -1,53 +0,0 @@ -import update from "immutability-helper"; -import { Reducer } from "../interfaces"; -import isFetching from "./helpers/isFetching"; - -const teams: Reducer<"teams"> = (state, action) => { - switch (action.type) { - case "POST_TEAM": { - return isFetching(state); - } - case "TEAM_POSTED": { - return update(state, { - isFetching: { - $set: false, - }, - items: { - result: { - $push: [action.team.id], - }, - entities: { - teams: state.items.entities.teams - ? { - $merge: { - [action.team.id]: action.team, - }, - } - : { - $set: { - [action.team.id]: action.team, - }, - }, - }, - }, - }); - } - case "USER_DELETED": { - if (action.isSelf) { - return update(state, { - items: { - result: { - $splice: [[state.items.result.indexOf(action.team.id), 1]], - }, - }, - }); - } - return state; - } - default: - break; - } - return state; -}; - -export default teams; diff --git a/src/reducerMaps/user.ts b/src/reducerMaps/user.ts deleted file mode 100644 index 17599e390..000000000 --- a/src/reducerMaps/user.ts +++ /dev/null @@ -1,66 +0,0 @@ -import update from "immutability-helper"; -import { Reducer, User } from "../interfaces"; - -const user: Reducer<"user"> = (state, action) => { - switch (action.type) { - case "TEAM_POSTED": { - let newState: typeof state | undefined; - if (action.team.roles) { - action.team.roles.forEach((role) => { - if (role.userId === state!.id) { - newState = update(newState || state, { - roles: { - $push: [role], - }, - }); - } - }); - } - - return newState || state; - } - case "USER_DELETED": { - if (action.isSelf) { - return update(state, { - roles: { - $splice: [ - [ - state!.roles.map((role) => role.teamId).indexOf(action.team.id), - 1, - ], - ], - }, - }); - } - return state; - } - case "USER_PATCHED": { - if (action.isSelf) { - return { - ...state, - ...action.user, - roles: state!.roles.map((role) => { - if (role.teamId === action.team.id) { - return { - ...role, - type: action.user.type!, - }; - } - return role; - }), - type: undefined, - } as User; - } - return state; - } - case "CURRENT_USER_PATCHED": { - return action.user; - } - default: { - break; - } - } - return state; -}; - -export default user; diff --git a/src/reducerMaps/users.ts b/src/reducerMaps/users.ts deleted file mode 100644 index 3eac820f0..000000000 --- a/src/reducerMaps/users.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { normalize } from "normalizr"; -import update from "immutability-helper"; -import { getUserIds } from "../selectors/users"; -import * as schemas from "../schemas"; -import isFetching from "./helpers/isFetching"; -import { Reducer, State } from "../interfaces"; - -const users: Reducer<"users"> = (state, action) => { - switch (action.type) { - case "INVALIDATE_USERS": { - return update(state, { - $merge: { - didInvalidate: true, - }, - }); - } - case "REQUEST_USERS": { - return update(state, { - $merge: { - isFetching: true, - }, - }); - } - case "RECEIVE_USERS": { - return update(state, { - $merge: { - isFetching: false, - didInvalidate: false, - items: normalize(action.items, [schemas.user]), - }, - }); - } - case "DELETE_USER": - case "POST_USER": - case "PATCH_USER": { - return isFetching(state); - } - case "USER_DELETED": { - return update(state, { - isFetching: { - $set: false, - }, - items: { - result: { - $splice: [ - [getUserIds({ users: state } as State).indexOf(action.id), 1], - ], - }, - }, - }); - } - case "USER_POSTED": { - return update(state, { - items: { - result: { - $push: [action.user.id], - }, - entities: { - users: state.items.entities.users - ? { - $merge: { - [action.user.id]: action.user, - }, - } - : { - $set: { - [action.user.id]: action.user, - }, - }, - }, - }, - }); - } - case "USER_PATCHED": { - return update(state, { - isFetching: { - $set: false, - }, - items: { - entities: { - users: { - [action.id]: { - $merge: action.user, - }, - }, - }, - }, - }); - } - default: - break; - } - return state; -}; - -export default users; diff --git a/src/reducers/decisions.ts b/src/reducers/decisions.ts new file mode 100644 index 000000000..6fc3dcdf9 --- /dev/null +++ b/src/reducers/decisions.ts @@ -0,0 +1,56 @@ +import { createNextState } from "@reduxjs/toolkit"; +import { normalize } from "normalizr"; +import { Reducer } from "../interfaces"; +import * as schemas from "../schemas"; + +const decisions: Reducer<"decisions"> = (state, action) => + createNextState(state, (draftState) => { + switch (action.type) { + case "INVALIDATE_DECISIONS": { + draftState.didInvalidate = true; + return; + } + case "REQUEST_DECISIONS": + case "POST_DECISION": + case "DELETE_DECISION": { + draftState.isFetching = true; + return; + } + case "RECEIVE_DECISIONS": { + draftState.isFetching = false; + draftState.didInvalidate = false; + draftState.items = normalize(action.items, [schemas.decision]); + return; + } + case "DECISION_POSTED": { + draftState.isFetching = false; + draftState.items.entities.decisions = { + ...draftState.items.entities.decisions, + [action.decision.id]: action.decision, + }; + const deselectedIds = action.deselected.map((d) => d.id); + draftState.items.result = draftState.items.result.reduce( + (acc, curr) => { + if (deselectedIds.indexOf(curr) === -1) { + acc.push(curr); + } + return acc; + }, + [action.decision.id] + ); + return; + } + case "DECISIONS_DELETED": { + const decisionIds = action.decisions.map((d) => d.id); + draftState.isFetching = false; + draftState.items.result = draftState.items.result.filter( + (id) => decisionIds.indexOf(id) === -1 + ); + break; + } + default: + break; + } + }); + +export default decisions; diff --git a/src/reducerMaps/flashes.ts b/src/reducers/flashes.ts similarity index 96% rename from src/reducerMaps/flashes.ts rename to src/reducers/flashes.ts index 3a0238edc..65c3d93e7 100644 --- a/src/reducerMaps/flashes.ts +++ b/src/reducers/flashes.ts @@ -25,6 +25,8 @@ const flashes: Reducer<"flashes"> = (state, action) => { case "EXPIRE_FLASH": { return state.filter((arr) => arr.id !== action.id); } + default: + break; } return state; }; diff --git a/src/reducerMaps/index.ts b/src/reducers/index.ts similarity index 93% rename from src/reducerMaps/index.ts rename to src/reducers/index.ts index 6c0df0f24..6a4cd1d3f 100644 --- a/src/reducerMaps/index.ts +++ b/src/reducers/index.ts @@ -17,5 +17,4 @@ export { default as user } from "./user"; export { default as users } from "./users"; export const host: Reducer<"host"> = (state) => state; -export const locale: Reducer<"locale"> = (state) => state; export const wsPort: Reducer<"wsPort"> = (state) => state; diff --git a/src/reducers/listUi.ts b/src/reducers/listUi.ts new file mode 100644 index 000000000..5146e019b --- /dev/null +++ b/src/reducers/listUi.ts @@ -0,0 +1,50 @@ +import { createNextState } from "@reduxjs/toolkit"; +import { Reducer } from "../interfaces"; + +const listUi: Reducer<"listUi"> = (state, action) => + createNextState(state, (draftState) => { + switch (action.type) { + case "RESTAURANT_RENAMED": + case "RESTAURANT_DELETED": { + draftState[action.id] = {}; + return; + } + case "RESTAURANT_POSTED": { + draftState.newlyAdded = { + id: action.restaurant.id, + userId: action.userId, + }; + draftState[action.restaurant.id] = {}; + return; + } + case "SET_EDIT_NAME_FORM_VALUE": { + draftState[action.id] = { + ...draftState[action.id], + editNameFormValue: action.value, + }; + return; + } + case "SHOW_EDIT_NAME_FORM": { + draftState[action.id] = { + ...draftState[action.id], + isEditingName: true, + }; + return; + } + case "HIDE_EDIT_NAME_FORM": { + draftState[action.id] = { + ...draftState[action.id], + isEditingName: false, + }; + return; + } + case "SET_FLIP_MOVE": { + draftState.flipMove = action.val; + break; + } + default: + break; + } + }); + +export default listUi; diff --git a/src/reducers/mapUi.ts b/src/reducers/mapUi.ts new file mode 100644 index 000000000..f427b27ec --- /dev/null +++ b/src/reducers/mapUi.ts @@ -0,0 +1,87 @@ +import { createNextState } from "@reduxjs/toolkit"; +import { Reducer } from "../interfaces"; + +const mapUi: Reducer<"mapUi"> = (state, action) => + createNextState(state, (draftState) => { + switch (action.type) { + case "RECEIVE_RESTAURANTS": { + draftState.infoWindow = {}; + draftState.showPOIs = !action.items.length; + draftState.showUnvoted = true; + return; + } + case "RESTAURANT_POSTED": { + draftState.newlyAdded = { + id: action.restaurant.id, + userId: action.userId, + }; + return; + } + case "SHOW_GOOGLE_INFO_WINDOW": { + draftState.center = { + lat: action.latLng.lat, + lng: action.latLng.lng, + }; + draftState.infoWindow = { + latLng: action.latLng, + placeId: action.placeId, + }; + return; + } + case "SHOW_RESTAURANT_INFO_WINDOW": { + draftState.center = { + lat: action.restaurant.lat, + lng: action.restaurant.lng, + }; + draftState.infoWindow = { + id: action.restaurant.id, + }; + return; + } + case "HIDE_INFO_WINDOW": { + draftState.infoWindow = {}; + return; + } + case "SET_SHOW_POIS": { + draftState.showPOIs = action.val; + + if (!action.val) { + draftState.infoWindow = { + latLng: undefined, + placeId: undefined, + }; + } + return; + } + case "SET_SHOW_UNVOTED": { + draftState.showUnvoted = action.val; + return; + } + case "SET_CENTER": { + draftState.center = action.center; + return; + } + case "CLEAR_CENTER": { + draftState.center = undefined; + return; + } + case "CREATE_TEMP_MARKER": { + draftState.center = action.result.latLng; + draftState.tempMarker = action.result; + return; + } + case "CLEAR_TEMP_MARKER": { + draftState.center = undefined; + draftState.tempMarker = undefined; + return; + } + case "CLEAR_MAP_UI_NEWLY_ADDED": { + draftState.newlyAdded = undefined; + break; + } + default: + break; + } + }); + +export default mapUi; diff --git a/src/reducers/modals.ts b/src/reducers/modals.ts new file mode 100644 index 000000000..876db74a3 --- /dev/null +++ b/src/reducers/modals.ts @@ -0,0 +1,26 @@ +import { createNextState } from "@reduxjs/toolkit"; +import { Reducer } from "../interfaces"; + +const modals: Reducer<"modals"> = (state, action) => + createNextState(state, (draftState) => { + switch (action.type) { + case "SHOW_MODAL": { + draftState[action.name] = { + shown: true, + ...("opts" in action ? action.opts : undefined), + }; + return; + } + case "HIDE_MODAL": { + draftState[action.name] = { + ...draftState[action.name], + shown: false, + }; + break; + } + default: + break; + } + }); + +export default modals; diff --git a/src/reducerMaps/notifications.ts b/src/reducers/notifications.ts similarity index 95% rename from src/reducerMaps/notifications.ts rename to src/reducers/notifications.ts index b959dda85..bc26a78dc 100644 --- a/src/reducerMaps/notifications.ts +++ b/src/reducers/notifications.ts @@ -1,5 +1,8 @@ -import { v1 } from "uuid"; +import nodeCrypto from "crypto"; import { Notification, Reducer } from "../interfaces"; +import canUseDOM from "../helpers/canUseDOM"; + +const crypto = canUseDOM ? window.crypto : nodeCrypto; const notifications: Reducer<"notifications"> = (state, action) => { switch (action.type) { @@ -7,7 +10,7 @@ const notifications: Reducer<"notifications"> = (state, action) => { const { realAction } = action; const baseNotification = { actionType: realAction.type, - id: v1(), + id: crypto.randomUUID(), }; let notification: Notification; switch (realAction.type) { @@ -146,6 +149,8 @@ const notifications: Reducer<"notifications"> = (state, action) => { case "EXPIRE_NOTIFICATION": { return state.filter((n) => n.id !== action.id); } + default: + break; } return state; }; diff --git a/src/reducers/pageUi.ts b/src/reducers/pageUi.ts new file mode 100644 index 000000000..236960ab0 --- /dev/null +++ b/src/reducers/pageUi.ts @@ -0,0 +1,20 @@ +import { createNextState } from "@reduxjs/toolkit"; +import { Reducer } from "../interfaces"; + +const pageUi: Reducer<"pageUi"> = (state, action) => + createNextState(state, (draftState) => { + switch (action.type) { + case "SCROLL_TO_TOP": { + draftState.shouldScrollToTop = true; + return; + } + case "SCROLLED_TO_TOP": { + draftState.shouldScrollToTop = false; + break; + } + default: + break; + } + }); + +export default pageUi; diff --git a/src/reducers/restaurants.ts b/src/reducers/restaurants.ts new file mode 100644 index 000000000..208930dbc --- /dev/null +++ b/src/reducers/restaurants.ts @@ -0,0 +1,226 @@ +import { normalize } from "normalizr"; +import { createNextState } from "@reduxjs/toolkit"; +import { getRestaurantIds, getRestaurantById } from "../selectors/restaurants"; +import * as schemas from "../schemas"; +import { Reducer } from "../interfaces"; +import maybeAddToString from "../helpers/maybeAddToString"; + +const restaurants: Reducer<"restaurants"> = (state, action) => + createNextState(state, (draftState) => { + switch (action.type) { + case "SORT_RESTAURANTS": { + const sortIndexes: { [index: number]: number } = {}; + draftState.items.result.forEach((id, index) => { + sortIndexes[id] = index; + }); + const sortedResult = Array.from(draftState.items.result).sort( + (a, b) => { + if ( + action.newlyAdded !== undefined && + action.user.id === action.newlyAdded.userId + ) { + if (a === action.newlyAdded.id) { + return -1; + } + if (b === action.newlyAdded.id) { + return 1; + } + } + if (action.decision !== undefined) { + if (action.decision.restaurantId === a) { + return -1; + } + if (action.decision.restaurantId === b) { + return 1; + } + } + const restaurantA = getRestaurantById({ restaurants: state }, a); + const restaurantB = getRestaurantById({ restaurants: state }, b); + + if (restaurantA.votes.length !== restaurantB.votes.length) { + return restaurantB.votes.length - restaurantA.votes.length; + } + if ( + restaurantA.all_decision_count !== restaurantB.all_decision_count + ) { + return ( + Number(restaurantA.all_decision_count) - + Number(restaurantB.all_decision_count) + ); + } + if (restaurantA.all_vote_count !== restaurantB.all_vote_count) { + return ( + Number(restaurantB.all_vote_count) - + Number(restaurantA.all_vote_count) + ); + } + // stable sort + return sortIndexes[a] - sortIndexes[b]; + } + ); + // If array contents match, return original (for shallow comparison) + if (sortedResult.some((r, i) => r !== draftState.items.result[i])) + draftState.items.result = sortedResult; + return; + } + case "INVALIDATE_RESTAURANTS": { + draftState.didInvalidate = true; + return; + } + case "RECEIVE_RESTAURANTS": { + draftState.isFetching = false; + draftState.didInvalidate = false; + draftState.items = normalize(action.items, [schemas.restaurant]); + return; + } + case "REQUEST_RESTAURANTS": + case "POST_RESTAURANT": + case "DELETE_RESTAURANT": + case "RENAME_RESTAURANT": + case "POST_VOTE": + case "DELETE_VOTE": + case "POST_NEW_TAG_TO_RESTAURANT": + case "POST_TAG_TO_RESTAURANT": + case "DELETE_TAG_FROM_RESTAURANT": { + draftState.isFetching = true; + return; + } + case "RESTAURANT_POSTED": { + draftState.isFetching = false; + draftState.items.entities.restaurants = { + ...draftState.items.entities.restaurants, + [action.restaurant.id]: action.restaurant, + }; + if (draftState.items.result.indexOf(action.restaurant.id) === -1) { + draftState.items.result.unshift(action.restaurant.id); + } + return; + } + case "RESTAURANT_DELETED": { + draftState.isFetching = false; + draftState.items.result.splice( + getRestaurantIds({ restaurants: draftState }).indexOf(action.id), + 1 + ); + return; + } + case "RESTAURANT_RENAMED": { + draftState.isFetching = false; + draftState.items.entities.restaurants = { + ...draftState.items.entities.restaurants, + [action.id]: { + ...draftState.items.entities.restaurants[action.id], + ...action.fields, + }, + }; + return; + } + case "VOTE_POSTED": { + draftState.isFetching = false; + draftState.items.entities.votes = { + ...draftState.items.entities.votes, + [action.vote.id]: action.vote, + }; + const r = + draftState.items.entities.restaurants[action.vote.restaurantId]; + if (r.votes.indexOf(action.vote.id) === -1) { + r.votes.push(action.vote.id); + r.all_vote_count = maybeAddToString(r.all_vote_count, 1); + } + return; + } + case "VOTE_DELETED": { + draftState.isFetching = false; + draftState.items.entities.restaurants[action.restaurantId].votes.splice( + getRestaurantById( + { restaurants: state }, + action.restaurantId + ).votes.indexOf(action.id), + 1 + ); + draftState.items.entities.restaurants[ + action.restaurantId + ].all_vote_count = maybeAddToString( + draftState.items.entities.restaurants[action.restaurantId] + .all_vote_count, + -1 + ); + return; + } + case "POSTED_NEW_TAG_TO_RESTAURANT": { + draftState.isFetching = false; + draftState.items.entities.restaurants[action.restaurantId].tags.push( + action.tag.id + ); + return; + } + case "POSTED_TAG_TO_RESTAURANT": { + draftState.isFetching = false; + if ( + state.items.entities.restaurants[action.restaurantId].tags.indexOf( + action.id + ) === -1 + ) { + draftState.items.entities.restaurants[action.restaurantId].tags.push( + action.id + ); + } + return; + } + case "DELETED_TAG_FROM_RESTAURANT": { + draftState.isFetching = false; + draftState.items.entities.restaurants[action.restaurantId].tags.splice( + getRestaurantById( + { restaurants: state }, + action.restaurantId + ).tags.indexOf(action.id), + 1 + ); + return; + } + case "TAG_DELETED": { + const { restaurants: r } = draftState.items.entities; + if (r) { + Object.keys(r).forEach((i) => { + const index = Number(i); + const changedRestaurant = r[index]; + if (changedRestaurant.tags.indexOf(action.id) > -1) { + draftState.items.entities.restaurants[index].tags.splice( + changedRestaurant.tags.indexOf(action.id), + 1 + ); + } + }); + } + return; + } + case "DECISION_POSTED": { + const decision = + draftState.items.entities.restaurants[action.decision.restaurantId]; + draftState.items.entities.restaurants[ + action.decision.restaurantId + ].all_decision_count = maybeAddToString(decision.all_decision_count, 1); + action.deselected.forEach((i) => { + const r = draftState.items.entities.restaurants[i.restaurantId]; + r.all_decision_count = maybeAddToString(r.all_decision_count, -1); + }); + return; + } + case "DECISIONS_DELETED": { + action.decisions.forEach((decision) => { + const r = + draftState.items.entities.restaurants[decision.restaurantId]; + r.all_decision_count = maybeAddToString(r.all_decision_count, -1); + }); + return; + } + case "SET_NAME_FILTER": { + draftState.nameFilter = action.val; + break; + } + default: + break; + } + }); + +export default restaurants; diff --git a/src/reducerMaps/tagExclusions.ts b/src/reducers/tagExclusions.ts similarity index 94% rename from src/reducerMaps/tagExclusions.ts rename to src/reducers/tagExclusions.ts index 6efee6017..a9d641ba0 100644 --- a/src/reducerMaps/tagExclusions.ts +++ b/src/reducers/tagExclusions.ts @@ -11,6 +11,8 @@ const tagExclusions: Reducer<"tagExclusions"> = (state, action) => { case "CLEAR_TAG_EXCLUSIONS": { return []; } + default: + break; } return state; }; diff --git a/src/reducerMaps/tagFilters.ts b/src/reducers/tagFilters.ts similarity index 93% rename from src/reducerMaps/tagFilters.ts rename to src/reducers/tagFilters.ts index 7c4050b8f..1b21a7ef2 100644 --- a/src/reducerMaps/tagFilters.ts +++ b/src/reducers/tagFilters.ts @@ -11,6 +11,8 @@ const tagFilters: Reducer<"tagFilters"> = (state, action) => { case "CLEAR_TAG_FILTERS": { return []; } + default: + break; } return state; }; diff --git a/src/reducers/tags.ts b/src/reducers/tags.ts new file mode 100644 index 000000000..793d3d847 --- /dev/null +++ b/src/reducers/tags.ts @@ -0,0 +1,64 @@ +import { normalize } from "normalizr"; +import { createNextState } from "@reduxjs/toolkit"; +import { getTagIds, getTagById } from "../selectors/tags"; +import * as schemas from "../schemas"; +import { Reducer } from "../interfaces"; +import maybeAddToString from "../helpers/maybeAddToString"; + +const tags: Reducer<"tags"> = (state, action) => + createNextState(state, (draftState) => { + switch (action.type) { + case "INVALIDATE_TAGS": { + draftState.didInvalidate = true; + return; + } + case "REQUEST_TAGS": + case "DELETE_TAG": { + draftState.isFetching = true; + return; + } + case "RECEIVE_TAGS": { + draftState.isFetching = false; + draftState.didInvalidate = false; + draftState.items = normalize(action.items, [schemas.tag]); + return; + } + case "POSTED_TAG_TO_RESTAURANT": { + draftState.items.entities.tags[action.id].restaurant_count = + maybeAddToString( + getTagById({ tags: draftState }, action.id).restaurant_count, + 1 + ); + return; + } + case "POSTED_NEW_TAG_TO_RESTAURANT": { + draftState.items.result.push(action.tag.id); + draftState.items.entities.tags = { + ...draftState.items.entities.tags, + [action.tag.id]: action.tag, + }; + return; + } + case "DELETED_TAG_FROM_RESTAURANT": { + draftState.isFetching = false; + draftState.items.entities.tags[action.id].restaurant_count = + maybeAddToString( + state.items.entities.tags[action.id].restaurant_count, + -1 + ); + return; + } + case "TAG_DELETED": { + draftState.isFetching = false; + const tagIndex = getTagIds({ tags: draftState }).indexOf(action.id); + if (tagIndex !== -1) { + draftState.items.result.splice(tagIndex, 1); + } + break; + } + default: + break; + } + }); + +export default tags; diff --git a/src/reducerMaps/team.ts b/src/reducers/team.ts similarity index 89% rename from src/reducerMaps/team.ts rename to src/reducers/team.ts index 3df8dfe4f..2c077a977 100644 --- a/src/reducerMaps/team.ts +++ b/src/reducers/team.ts @@ -5,6 +5,8 @@ const team: Reducer<"team"> = (state, action) => { case "TEAM_PATCHED": { return action.team; } + default: + break; } return state; }; diff --git a/src/reducers/teams.ts b/src/reducers/teams.ts new file mode 100644 index 000000000..d7873e5c0 --- /dev/null +++ b/src/reducers/teams.ts @@ -0,0 +1,34 @@ +import { createNextState } from "@reduxjs/toolkit"; +import { Reducer } from "../interfaces"; + +const teams: Reducer<"teams"> = (state, action) => + createNextState(state, (draftState) => { + switch (action.type) { + case "POST_TEAM": { + draftState.isFetching = true; + return; + } + case "TEAM_POSTED": { + draftState.isFetching = false; + draftState.items.result.push(action.team.id); + draftState.items.entities.teams = { + ...draftState.items.entities.teams, + [action.team.id]: action.team, + }; + return; + } + case "USER_DELETED": { + if (action.isSelf) { + draftState.items.result.splice( + state.items.result.indexOf(action.team.id), + 1 + ); + } + break; + } + default: + break; + } + }); + +export default teams; diff --git a/src/reducerMaps/tests/restaurants.test.ts b/src/reducers/tests/restaurants.test.ts similarity index 98% rename from src/reducerMaps/tests/restaurants.test.ts rename to src/reducers/tests/restaurants.test.ts index f098ee79a..db6c0240f 100644 --- a/src/reducerMaps/tests/restaurants.test.ts +++ b/src/reducers/tests/restaurants.test.ts @@ -4,7 +4,7 @@ import { expect } from "chai"; import { Decision, Restaurant, State, User } from "../../interfaces"; import restaurants from "../restaurants"; -describe("reducerMaps/restaurants", () => { +describe("reducers/restaurants", () => { let beforeState: State["restaurants"]; let afterState: State["restaurants"]; diff --git a/src/reducerMaps/tests/teams.test.ts b/src/reducers/tests/teams.test.ts similarity index 97% rename from src/reducerMaps/tests/teams.test.ts rename to src/reducers/tests/teams.test.ts index 7e968b881..49dcdc6f5 100644 --- a/src/reducerMaps/tests/teams.test.ts +++ b/src/reducers/tests/teams.test.ts @@ -5,7 +5,7 @@ import { expect } from "chai"; import { State, Team } from "../../interfaces"; import teams from "../teams"; -describe("reducerMaps/teams", () => { +describe("reducers/teams", () => { describe("USER_DELETED", () => { let beforeState: State["teams"]; let afterState: State["teams"]; diff --git a/src/reducerMaps/tests/user.test.ts b/src/reducers/tests/user.test.ts similarity index 99% rename from src/reducerMaps/tests/user.test.ts rename to src/reducers/tests/user.test.ts index 704efae56..362c51492 100644 --- a/src/reducerMaps/tests/user.test.ts +++ b/src/reducers/tests/user.test.ts @@ -5,7 +5,7 @@ import { expect } from "chai"; import { State, Team, User } from "../../interfaces"; import users from "../user"; -describe("reducerMaps/user", () => { +describe("reducers/user", () => { let beforeState: State["user"]; let afterState: State["user"]; diff --git a/src/reducerMaps/tests/users.test.ts b/src/reducers/tests/users.test.ts similarity index 96% rename from src/reducerMaps/tests/users.test.ts rename to src/reducers/tests/users.test.ts index 71085083e..122a6c74b 100644 --- a/src/reducerMaps/tests/users.test.ts +++ b/src/reducers/tests/users.test.ts @@ -5,7 +5,7 @@ import { expect } from "chai"; import { State, Team, User } from "../../interfaces"; import users from "../users"; -describe("reducerMaps/users", () => { +describe("reducers/users", () => { let beforeState: State["users"]; let afterState: State["users"]; diff --git a/src/reducers/user.ts b/src/reducers/user.ts new file mode 100644 index 000000000..85a6f3df6 --- /dev/null +++ b/src/reducers/user.ts @@ -0,0 +1,55 @@ +/* eslint-disable consistent-return */ +import { createNextState } from "@reduxjs/toolkit"; +import { Reducer, User } from "../interfaces"; + +const user: Reducer<"user"> = (state, action) => + createNextState(state, (draftState) => { + switch (action.type) { + case "TEAM_POSTED": { + if (action.team.roles) { + action.team.roles.forEach((role) => { + if (draftState && role.userId === draftState.id) { + draftState.roles.push(role); + } + }); + } + return; + } + case "USER_DELETED": { + if (draftState && action.isSelf) { + draftState.roles.splice( + draftState.roles.map((role) => role.teamId).indexOf(action.team.id), + 1 + ); + } + return; + } + case "USER_PATCHED": { + if (draftState && action.isSelf) { + return { + ...draftState, + ...action.user, + roles: draftState.roles.map((role) => { + if (role.teamId === action.team.id) { + return { + ...role, + type: action.user.type, + }; + } + return role; + }), + type: undefined, + } as User; + } + return; + } + case "CURRENT_USER_PATCHED": { + return action.user; + } + default: { + break; + } + } + }); + +export default user; diff --git a/src/reducers/users.ts b/src/reducers/users.ts new file mode 100644 index 000000000..14a298277 --- /dev/null +++ b/src/reducers/users.ts @@ -0,0 +1,56 @@ +import { normalize } from "normalizr"; +import { createNextState } from "@reduxjs/toolkit"; +import { getUserIds } from "../selectors/users"; +import * as schemas from "../schemas"; +import { Reducer, State } from "../interfaces"; + +const users: Reducer<"users"> = (state, action) => + createNextState(state, (draftState) => { + switch (action.type) { + case "INVALIDATE_USERS": { + draftState.didInvalidate = true; + return; + } + case "REQUEST_USERS": + case "DELETE_USER": + case "POST_USER": + case "PATCH_USER": { + draftState.isFetching = true; + return; + } + case "RECEIVE_USERS": { + draftState.isFetching = false; + draftState.didInvalidate = false; + draftState.items = normalize(action.items, [schemas.user]); + return; + } + case "USER_DELETED": { + draftState.isFetching = false; + draftState.items.result.splice( + getUserIds({ users: draftState } as State).indexOf(action.id), + 1 + ); + return; + } + case "USER_POSTED": { + draftState.items.result.push(action.user.id); + draftState.items.entities.users = { + ...draftState.items.entities.users, + [action.user.id]: action.user, + }; + return; + } + case "USER_PATCHED": { + draftState.isFetching = false; + draftState.items.entities.users[action.id] = { + ...draftState.items.entities.users[action.id], + ...action.user, + }; + break; + } + default: + break; + } + }); + +export default users; diff --git a/src/routes/helpers/redirectToLogin.ts b/src/routes/helpers/redirectToLogin.ts index 60cf5b5a8..3848d19b2 100644 --- a/src/routes/helpers/redirectToLogin.ts +++ b/src/routes/helpers/redirectToLogin.ts @@ -1,8 +1,7 @@ -import qs from "qs"; import { AppContext } from "../../interfaces"; export default (context: AppContext) => { - const stringifiedQuery = qs.stringify(context.query); + const stringifiedQuery = context.query?.toString(); let params = ""; if (context.path !== "/" || stringifiedQuery) { params = `?next=${context.path}`; diff --git a/src/routes/login/Login.tsx b/src/routes/login/Login.tsx index 3d349832a..2c6517507 100644 --- a/src/routes/login/Login.tsx +++ b/src/routes/login/Login.tsx @@ -1,5 +1,4 @@ import React, { ChangeEvent, Component } from "react"; -import qs from "qs"; import withStyles from "isomorphic-style-loader/withStyles"; import Button from "react-bootstrap/Button"; import Col from "react-bootstrap/Col"; @@ -45,8 +44,18 @@ class Login extends Component { const { host, next, team } = this.props; const { email, password } = this.state; - const googleQuery = qs.stringify({ team, next }); - const nextQuery = qs.stringify({ next }); + const googleParams: Record = {}; + const nextParams: Record = {}; + if (team) { + googleParams.team = team; + } + if (next) { + googleParams.next = next; + nextParams.next = next; + } + + const googleQuery = new URLSearchParams(googleParams).toString(); + const nextQuery = new URLSearchParams(nextParams).toString(); return (
diff --git a/src/routes/login/index.tsx b/src/routes/login/index.tsx index 3c27d90ee..851b383f4 100644 --- a/src/routes/login/index.tsx +++ b/src/routes/login/index.tsx @@ -17,7 +17,7 @@ function action(context: AppContext) { const state = context.store.getState(); const subdomain = context.subdomain; - const next = context.query?.next as string | undefined; + const next = context.query?.get("next") as string | undefined; return renderIfLoggedOut(state, () => ({ chunks: ["login"], diff --git a/src/routes/main/invitation/create/index.tsx b/src/routes/main/invitation/create/index.tsx index f1071a406..aa97b221c 100644 --- a/src/routes/main/invitation/create/index.tsx +++ b/src/routes/main/invitation/create/index.tsx @@ -13,8 +13,8 @@ import { AppContext } from "../../../../interfaces"; import Create from "./Create"; export default (context: AppContext) => { - const success = context.query?.success; - const token = context.query?.token; + const success = context.query?.get("success"); + const token = context.query?.get("token"); if (!success && !token) { return { diff --git a/src/routes/main/invitation/new/index.tsx b/src/routes/main/invitation/new/index.tsx index 25134ec98..646b3c428 100644 --- a/src/routes/main/invitation/new/index.tsx +++ b/src/routes/main/invitation/new/index.tsx @@ -13,7 +13,7 @@ import { AppContext } from "../../../../interfaces"; import New from "./New"; export default (context: AppContext) => { - const email = context.query?.email; + const email = context.query?.get("email"); return { component: ( diff --git a/src/routes/main/password/create/Create.tsx b/src/routes/main/password/create/Create.tsx index 8685359e8..6d312dab1 100644 --- a/src/routes/main/password/create/Create.tsx +++ b/src/routes/main/password/create/Create.tsx @@ -28,7 +28,7 @@ class Create extends Component {

Password reset

- Your password has been reset. Go ahead and + Your password has been reset. Go ahead and{" "} log in.

diff --git a/src/routes/main/password/create/index.tsx b/src/routes/main/password/create/index.tsx index 5cecf1946..bfd8fb7b5 100644 --- a/src/routes/main/password/create/index.tsx +++ b/src/routes/main/password/create/index.tsx @@ -16,7 +16,7 @@ import Create from "./Create"; export default (context: AppContext) => { const state = context.store.getState(); - const success = context.query?.success; + const success = context.query?.get("success"); return renderIfLoggedOut(state, () => { if (!success) { diff --git a/src/routes/main/password/edit/index.tsx b/src/routes/main/password/edit/index.tsx index 48a355dbc..d63ea096a 100644 --- a/src/routes/main/password/edit/index.tsx +++ b/src/routes/main/password/edit/index.tsx @@ -16,7 +16,7 @@ import Edit from "./Edit"; export default (context: AppContext) => { const state = context.store.getState(); - const token = context.query?.token as string | undefined; + const token = context.query?.get("token") as string | undefined; return renderIfLoggedOut(state, () => { if (!token) { diff --git a/src/routes/main/password/new/index.tsx b/src/routes/main/password/new/index.tsx index a18c7610b..c28468868 100644 --- a/src/routes/main/password/new/index.tsx +++ b/src/routes/main/password/new/index.tsx @@ -16,7 +16,7 @@ import New from "./New"; export default (context: AppContext) => { const state = context.store.getState(); - const email = context.query?.email; + const email = context.query?.get("email"); return renderIfLoggedOut(state, () => ({ component: ( diff --git a/src/routes/main/teams/Teams.tsx b/src/routes/main/teams/Teams.tsx index fd7d03e89..9efa8901e 100644 --- a/src/routes/main/teams/Teams.tsx +++ b/src/routes/main/teams/Teams.tsx @@ -5,29 +5,30 @@ import ListGroup from "react-bootstrap/ListGroup"; import { FaTimes } from "react-icons/fa"; import Container from "react-bootstrap/Container"; import Link from "../../../components/Link/Link"; -import { ConfirmOpts, Team } from "../../../interfaces"; +import { ConfirmOpts, Team, User } from "../../../interfaces"; import s from "./Teams.scss"; interface TeamsProps { - confirm: (props: ConfirmOpts) => void; + confirm: (props: ConfirmOpts<"removeUser">) => void; host: string; - leaveTeam: (team: Team) => () => void; teams: Team[]; + user: User; } class Teams extends Component { - confirmLeave = (team: Team) => (event: MouseEvent) => { + confirmLeave = (user: User, team: Team) => (event: MouseEvent) => { event.preventDefault(); this.props.confirm({ actionLabel: "Leave", body: `Are you sure you want to leave this team? You will need to be invited back by another member.`, - action: this.props.leaveTeam(team), + action: "removeUser", + actionArgs: [user.id, team], }); }; render() { - const { host, teams } = this.props; + const { host, teams, user } = this.props; return (
@@ -46,7 +47,7 @@ You will need to be invited back by another member.`,
{team.name}