diff --git a/.env b/.env index 7ec61e60b2..279905bee0 100644 --- a/.env +++ b/.env @@ -1,3 +1,5 @@ PUBLIC_URL=/ REACT_APP_PUBLIC_URL=/ -REACT_APP_VERSION=$npm_package_version +REACT_APP_VERSION=$npm_package_version + +REACT_APP_LEGACY_CREATE_BOARD=true diff --git a/.env.development b/.env.development index 7d277b4f98..cd4a1a8759 100644 --- a/.env.development +++ b/.env.development @@ -4,3 +4,5 @@ REACT_APP_VERSION=$npm_package_version SKIP_PREFLIGHT_CHECK=true PUBLIC_URL=http://localhost:3000 REACT_APP_PUBLIC_URL=http://localhost:3000 + +REACT_APP_LEGACY_CREATE_BOARD=false diff --git a/cypress/e2e/landingPageToBoard-spec.cy.ts b/cypress/e2e/landingPageToBoard-spec.cy.ts index d2467293f6..cc4114cce1 100644 --- a/cypress/e2e/landingPageToBoard-spec.cy.ts +++ b/cypress/e2e/landingPageToBoard-spec.cy.ts @@ -1,7 +1,7 @@ /// /// -import { columnTemplates } from "../../src/routes/NewBoard/columnTemplates" +import { legacyColumnTemplates } from "../../src/routes/Boards/Legacy/legacyColumnTemplates" import translationEn from "../../src/i18n/en/translation.json" describe("Navigation from landing page to board", () => { @@ -28,13 +28,13 @@ describe("Navigation from landing page to board", () => { cy.get("button").contains(translationEn.LoginBoard.login).click() // check templates - cy.get("h1").contains(translationEn.NewBoard.basicConfigurationTitle) - Object.values(columnTemplates).forEach(templateName => { + cy.get("h1").contains(translationEn.LegacyNewBoard.basicConfigurationTitle) + Object.values(legacyColumnTemplates).forEach(templateName => { cy.contains(templateName.name) }) cy.get("button") - .contains(translationEn.NewBoard.createNewBoard) + .contains(translationEn.LegacyNewBoard.createNewBoard) .parent() .should("be.disabled") @@ -42,17 +42,17 @@ describe("Navigation from landing page to board", () => { cy.get("input[type='radio']").siblings().contains("Lean Coffee").click() cy.get("button") - .contains(translationEn.NewBoard.createNewBoard) + .contains(translationEn.LegacyNewBoard.createNewBoard) .parent() .should("not.be.disabled") // click CTA cy.get("button") - .contains(translationEn.NewBoard.createNewBoard) + .contains(translationEn.LegacyNewBoard.createNewBoard) .click() // navigates to the board cy.url().should("include", "/board/") cy.get("h2").contains("Lean Coffee") }) - }) \ No newline at end of file + }) diff --git a/src/assets/stan/Stan_Hanging_With_Coffee_Cropped_Dark.png b/src/assets/stan/Stan_Hanging_With_Coffee_Cropped_Dark.png new file mode 100644 index 0000000000..2c16bd4629 Binary files /dev/null and b/src/assets/stan/Stan_Hanging_With_Coffee_Cropped_Dark.png differ diff --git a/src/assets/stan/Stan_Hanging_With_Coffee_Cropped_Light.png b/src/assets/stan/Stan_Hanging_With_Coffee_Cropped_Light.png new file mode 100644 index 0000000000..63df0a3721 Binary files /dev/null and b/src/assets/stan/Stan_Hanging_With_Coffee_Cropped_Light.png differ diff --git a/src/components/SearchBar/SearchBar.scss b/src/components/SearchBar/SearchBar.scss new file mode 100644 index 0000000000..c724f75c64 --- /dev/null +++ b/src/components/SearchBar/SearchBar.scss @@ -0,0 +1,143 @@ +@import "constants/style"; + +$padding--default: $spacing--base; +$padding--mobile: 12.5px $spacing--base; + +.search-bar { + display: flex; + flex-direction: row; + align-items: center; + gap: $spacing--xs; + + width: 100%; + padding: $padding--default; + border-radius: $rounded--full; + + background-color: $gray--000; + + &:not(&--disabled) { + @include default-states($padding: $padding--default); + + // alternatively, this could be split up and put in their respective classes, + // which might be more readable but also requires a little bit more code + &:hover, + &:focus-within { + .search-bar__icon-container--search-icon { + color: $navy--600; + } + + .search-bar__input { + color: $navy--600; + } + } + } + + &--disabled { + background-color: $gray--300; + + .search-bar__button { + &--search-icon, + &--clear-icon { + color: $gray--800; + } + } + } +} + +.search-bar__button { + all: unset; + width: $icon--large; + height: $icon--large; + + &--search-icon { + color: $gray--800; + } + + &--clear-icon { + cursor: pointer; + } +} + +.search-bar__input { + width: 100%; + padding: 0; + margin: 0; + border: none; + + font-size: $text--base; + font-weight: 600; + + color: $gray--800; + background-color: $gray--000; + + &:focus { + outline: none; + + color: $navy--600; + } + + &::placeholder { + opacity: 100%; + } + + &:disabled { + background-color: $gray--300; + } +} + +@media screen and (max-width: $breakpoint--smartphone) { + .search-bar { + padding: $padding--mobile; + + &:not(&--disabled) { + @include default-states($padding: $padding--mobile); + } + } +} + +[theme="dark"] { + .search-bar { + background-color: $navy--400; + + &:not(&--disabled) { + &:hover, + &:focus-within { + .search-bar__icon-container--search-icon { + color: $gray--000; + } + + .search-bar__input { + color: $gray--000; + } + } + } + + &--disabled { + background-color: $navy--700; + + .search-bar__button { + &--search-icon, + &--clear-icon { + color: $navy--300; + } + } + } + } + + .search-bar__input { + color: $blue--50; + background-color: $navy--400; + + &:disabled { + color: $navy--300; + background-color: $navy--700; + } + } + + .search-bar__button { + &--search-icon, + &--clear-icon { + color: $blue--50; + } + } +} diff --git a/src/components/SearchBar/SearchBar.tsx b/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000000..40f8fde57f --- /dev/null +++ b/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,38 @@ +import {Dispatch, FormEvent, SetStateAction} from "react"; +import {useTranslation} from "react-i18next"; +import classNames from "classnames"; +import {ReactComponent as SearchIcon} from "assets/icons/search.svg"; +import {ReactComponent as ClearIcon} from "assets/icons/close.svg"; +import "./SearchBar.scss"; + +type SearchBarProps = { + className?: string; + disabled?: boolean; + value: string; + handleValueChange: Dispatch>; +}; + +/* + * stateless search bar component. + * if the input is not empty, it's clearable using the X button + */ +export const SearchBar = (props: SearchBarProps) => { + const {t} = useTranslation(); + + const updateInput = (e: FormEvent) => props.handleValueChange(e.currentTarget.value); + const clearInput = () => props.handleValueChange(""); + + return ( +
+
+ +
+ + {props.value && ( + + )} +
+ ); +}; diff --git a/src/components/SettingsDialog/Components/AvatarSettings.tsx b/src/components/SettingsDialog/Components/AvatarSettings.tsx index 48c2821e98..29a6936d20 100644 --- a/src/components/SettingsDialog/Components/AvatarSettings.tsx +++ b/src/components/SettingsDialog/Components/AvatarSettings.tsx @@ -4,7 +4,6 @@ import {Avatar, generateRandomProps} from "components/Avatar"; import {Fragment, useEffect, useState} from "react"; import {useTranslation} from "react-i18next"; import {useAppDispatch, useAppSelector} from "store"; -import {isEqual} from "underscore"; import {AVATAR_CONFIG} from "constants/avatar"; import {AvataaarProps, AvatarGroup} from "types/avatar"; import {editSelf} from "store/features"; @@ -19,14 +18,9 @@ export interface AvatarSettingsProps { export const AvatarSettings = (props: AvatarSettingsProps) => { const dispatch = useAppDispatch(); const {t} = useTranslation(); - const state = useAppSelector( - (applicationState) => ({ - participant: applicationState.participants!.self!, - }), - isEqual - ); + const self = useAppSelector((state) => state.auth.user!); - let initialState = state.participant?.user.avatar; + let initialState = self.avatar; if (initialState === null || initialState === undefined) { initialState = generateRandomProps(props.id ?? ""); } @@ -46,7 +40,12 @@ export const AvatarSettings = (props: AvatarSettingsProps) => { }; useEffect(() => { - dispatch(editSelf({...state.participant.user, avatar: properties})); + dispatch( + editSelf({ + auth: {...self, avatar: properties}, + applyOptimistically: true, + }) + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [properties]); diff --git a/src/components/SettingsDialog/ProfileSettings/ProfileSettings.tsx b/src/components/SettingsDialog/ProfileSettings/ProfileSettings.tsx index fc27309154..ba7ad320f9 100644 --- a/src/components/SettingsDialog/ProfileSettings/ProfileSettings.tsx +++ b/src/components/SettingsDialog/ProfileSettings/ProfileSettings.tsx @@ -5,7 +5,6 @@ import {useAppDispatch, useAppSelector} from "store"; import {editSelf, setHotkeyState} from "store/features"; import {Info} from "components/Icon"; import {Toggle} from "components/Toggle"; -import {isEqual} from "underscore"; import {useOutletContext} from "react-router"; import {MenuItemConfig} from "constants/settings"; import {getColorClassName} from "constants/colors"; @@ -20,16 +19,11 @@ export const ProfileSettings = () => { const activeMenuItem: MenuItemConfig = useOutletContext(); - const state = useAppSelector( - (applicationState) => ({ - participant: applicationState.participants.self!, - hotkeysAreActive: applicationState.view.hotkeysAreActive, - }), - isEqual - ); + const self = useAppSelector((state) => state.auth.user!); + const hotkeysAreActive = useAppSelector((state) => state.view.hotkeysAreActive); - const [userName, setUserName] = useState(state.participant?.user.name); - const [id] = useState(state.participant?.user.id); + const [userName, setUserName] = useState(self.name); + const [id] = useState(self.id); return (
@@ -45,7 +39,7 @@ export const ProfileSettings = () => { value={userName} maxLength={64} onChange={(e) => setUserName(e.target.value)} - submit={() => dispatch(editSelf({...state.participant.user, name: userName}))} + submit={() => dispatch(editSelf({auth: {...self, name: userName}, applyOptimistically: true}))} /> @@ -54,10 +48,10 @@ export const ProfileSettings = () => { className="profile-settings__toggle-hotkeys-button" label={t("Hotkeys.hotkeyToggle")} onClick={() => { - dispatch(setHotkeyState(!state.hotkeysAreActive)); + dispatch(setHotkeyState(!hotkeysAreActive)); }} > - +

{t("Hotkeys.cheatSheet")}

diff --git a/src/components/SettingsDialog/SettingsDialog.tsx b/src/components/SettingsDialog/SettingsDialog.tsx index c1fb9de505..b88e544bbb 100644 --- a/src/components/SettingsDialog/SettingsDialog.tsx +++ b/src/components/SettingsDialog/SettingsDialog.tsx @@ -21,7 +21,7 @@ export const SettingsDialog = (props: SettingsDialogProps) => { const {t} = useTranslation(); const location = useLocation(); const navigate = useNavigate(); - const me = useAppSelector((applicationState) => applicationState.participants?.self?.user)!; + const me = useAppSelector((state) => state.auth.user!); const isBoardModerator = useAppSelector((state) => state.participants?.self?.role === "MODERATOR" || state.participants?.self?.role === "OWNER"); const [activeMenuItem, setActiveMenuItem] = useState(); diff --git a/src/components/SettingsDialog/__tests__/__snapshots__/SettingsDialog.test.tsx.snap b/src/components/SettingsDialog/__tests__/__snapshots__/SettingsDialog.test.tsx.snap index 894f54a4a7..e92103a97d 100644 --- a/src/components/SettingsDialog/__tests__/__snapshots__/SettingsDialog.test.tsx.snap +++ b/src/components/SettingsDialog/__tests__/__snapshots__/SettingsDialog.test.tsx.snap @@ -389,12 +389,12 @@ exports[`SettingsDialog should render correctly 1`] = ` /> @@ -407,13 +407,13 @@ exports[`SettingsDialog should render correctly 1`] = ` /> + + - - - - - - - - - - - - @@ -548,22 +510,192 @@ exports[`SettingsDialog should render correctly 1`] = ` - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + id="Ornamentos" + stroke-width="1" + transform="translate(67.000000, 0.000000)" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + @@ -652,7 +971,7 @@ exports[`SettingsDialog should render correctly 1`] = ` class="navigation-item__name" data-clarity-mask="True" > - test-participant-name + test-auth-user-name

+
+ {/* logo - - - profile */} + + + + {/* - - title - - */} +
{boardView === "templates" ? t("Templates.title") : t("Sessions.title")}
+ + {/* switch - - - search */} + + + {/* desktop search bar */} + + + {/* mobile search button + search bar (row below) */} + + {showMobileSearchBar && } + +
+ +
+
+
+ ); +}; diff --git a/src/routes/NewBoard/NewBoard.scss b/src/routes/Boards/Legacy/LegacyNewBoard.scss similarity index 100% rename from src/routes/NewBoard/NewBoard.scss rename to src/routes/Boards/Legacy/LegacyNewBoard.scss diff --git a/src/routes/NewBoard/NewBoard.tsx b/src/routes/Boards/Legacy/LegacyNewBoard.tsx similarity index 89% rename from src/routes/NewBoard/NewBoard.tsx rename to src/routes/Boards/Legacy/LegacyNewBoard.tsx index 78510b613e..aca386fe7a 100644 --- a/src/routes/NewBoard/NewBoard.tsx +++ b/src/routes/Boards/Legacy/LegacyNewBoard.tsx @@ -1,22 +1,21 @@ import {API} from "api"; -import "routes/NewBoard/NewBoard.scss"; import React, {useRef, useState} from "react"; -import {AccessPolicySelection} from "components/AccessPolicySelection"; import {AccessPolicy, BoardImportData} from "store/features/board/types"; import {useTranslation} from "react-i18next"; import {useNavigate} from "react-router"; +import {useAppDispatch} from "store"; +import {importBoard} from "store/features"; +import {Toast} from "utils/Toast"; +import {AccessPolicySelection} from "components/AccessPolicySelection"; import {TextInputLabel} from "components/TextInputLabel"; import {TextInput} from "components/TextInput"; import {Button} from "components/Button"; import {ScrumlrLogo} from "components/ScrumlrLogo"; - import {PassphraseModal} from "components/PassphraseDialog/PassphraseModal/PassphraseModal"; -import {importBoard} from "store/features"; -import {useAppDispatch} from "store"; -import {columnTemplates} from "./columnTemplates"; -import {Toast} from "../../utils/Toast"; +import {legacyColumnTemplates} from "./legacyColumnTemplates"; +import "./LegacyNewBoard.scss"; -export const NewBoard = () => { +export const LegacyNewBoard = () => { const {t} = useTranslation(); const dispatch = useAppDispatch(); const navigate = useNavigate(); @@ -115,7 +114,7 @@ export const NewBoard = () => { type: accessPolicy, ...additionalAccessPolicyOptions, }, - columnTemplates[columnTemplate].columns + legacyColumnTemplates[columnTemplate].columns ); navigate(`/board/${boardId}`); } @@ -133,10 +132,10 @@ export const NewBoard = () => { {!extendedConfiguration && (
-

{t("NewBoard.basicConfigurationTitle")}

+

{t("LegacyNewBoard.basicConfigurationTitle")}

- {Object.keys(columnTemplates).map((key) => ( + {Object.keys(legacyColumnTemplates).map((key) => ( @@ -177,8 +176,8 @@ export const NewBoard = () => { }} >
-
{t("NewBoard.importBoard")}
-
{t("NewBoard.uploadFile")}
+
{t("LegacyNewBoard.importBoard")}
+
{t("LegacyNewBoard.uploadFile")}
@@ -188,9 +187,9 @@ export const NewBoard = () => { {extendedConfiguration && (
-

{t("NewBoard.extendedConfigurationTitle")}

+

{t("LegacyNewBoard.extendedConfigurationTitle")}

- + setBoardName(e.target.value)} /> @@ -202,22 +201,21 @@ export const NewBoard = () => {
{!importFile ? ( ) : ( )} - {!extendedConfiguration && ( )} {extendedConfiguration && ( )}
diff --git a/src/routes/NewBoard/columnTemplates.ts b/src/routes/Boards/Legacy/legacyColumnTemplates.ts similarity index 94% rename from src/routes/NewBoard/columnTemplates.ts rename to src/routes/Boards/Legacy/legacyColumnTemplates.ts index f13ec6c5b3..8c555f6ab9 100644 --- a/src/routes/NewBoard/columnTemplates.ts +++ b/src/routes/Boards/Legacy/legacyColumnTemplates.ts @@ -1,6 +1,6 @@ import {Color} from "constants/colors"; -export const columnTemplates: {[id: string]: {name: string; description?: string; columns: {name: string; hidden: boolean; color: Color}[]}} = { +export const legacyColumnTemplates: {[id: string]: {name: string; description?: string; columns: {name: string; hidden: boolean; color: Color}[]}} = { leanCoffee: { name: "Lean Coffee", columns: [ diff --git a/src/routes/Boards/Sessions/Sessions.tsx b/src/routes/Boards/Sessions/Sessions.tsx new file mode 100644 index 0000000000..ea98876512 --- /dev/null +++ b/src/routes/Boards/Sessions/Sessions.tsx @@ -0,0 +1,8 @@ +import {Outlet} from "react-router"; + +export const Sessions = () => ( + <> + {/* settings */} +
Hello Sessions
+ +); diff --git a/src/routes/Boards/Sessions/index.ts b/src/routes/Boards/Sessions/index.ts new file mode 100644 index 0000000000..c88c501dea --- /dev/null +++ b/src/routes/Boards/Sessions/index.ts @@ -0,0 +1 @@ +export * from "./Sessions"; diff --git a/src/routes/Boards/Templates/Templates.scss b/src/routes/Boards/Templates/Templates.scss new file mode 100644 index 0000000000..377ddfd971 --- /dev/null +++ b/src/routes/Boards/Templates/Templates.scss @@ -0,0 +1,134 @@ +@import "constants/style"; + +$stan-height-factor: 93%; + +.templates { + display: flex; + flex-direction: row; + flex: 1; + // use margin below instead to accommodate for stan, otherwise the --saved container would be missing the space + // gap: $spacing--md; + + position: relative; +} + +.templates__container { + display: flex; + flex-direction: column; + flex: 1; + gap: $spacing--lg; + + padding: $spacing--xl $spacing--xl 0 $spacing--xl; + border-radius: $rounded--default; + + background-color: $blue--50; + + &--saved { + margin-left: $spacing--md; // instead of gap, see above + } +} + +.templates__container-header { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: $spacing--md; +} + +.templates__container-title { + word-break: break-word; // because the word "Recommendations" is too long, TODO find better solution for this + font-size: $text--lg; + font-weight: 700; + line-height: 32px; // TODO use constant + + color: $navy--600; +} + +.templates__container-arrow-button { + all: unset; +} + +.templates__container-arrow { + display: none; + + &--disabled { + color: $gray--800; + } +} + +.templates__stan { + position: absolute; + + width: auto; + // TODO find a solution so stan never gets cut off + height: 100%; + + // align right edge of stan to the left edge of the container + transform: translateX(-100%); + + &--dark { + display: initial; + } + + &--light { + display: none; + } +} + +@media screen and (max-width: $breakpoint--smartphone) { + // instead of the both containers sitting on one screen we want them to be full width each, and make the main container scrollable. + .templates { + overflow-x: scroll; + scroll-snap-type: x mandatory; + } + + .templates__container { + flex: 0 0 100%; // no growing, no shrinking, and take up the whole width + scroll-snap-align: center; + } + + .templates__container-header { + justify-content: center; + } + + .templates__container-title { + font-size: $text--md; + line-height: 24px; // TODO use constant + } + + .templates__container-arrow { + display: inline-block; + width: 36px; + height: 36px; + } +} + +[theme="dark"] { + .templates__container { + background-color: $navy--700; + } + + .templates__container-title { + color: $blue--50; + } + + .templates__stan { + height: $stan-height-factor; + &--dark { + display: none; + } + + &--light { + display: initial; + } + } + + .templates__container-arrow { + color: $blue--50; + + &--disabled { + color: $navy--300; + } + } +} diff --git a/src/routes/Boards/Templates/Templates.tsx b/src/routes/Boards/Templates/Templates.tsx new file mode 100644 index 0000000000..04cdbfc957 --- /dev/null +++ b/src/routes/Boards/Templates/Templates.tsx @@ -0,0 +1,58 @@ +import classNames from "classnames"; +import {Outlet} from "react-router"; +import {useAppSelector} from "store"; +import {useTranslation} from "react-i18next"; +import {useRef} from "react"; +// using a png instead of svg for now. reason being problems with layering +import StanDark from "assets/stan/Stan_Hanging_With_Coffee_Cropped_Dark.png"; +import StanLight from "assets/stan/Stan_Hanging_With_Coffee_Cropped_Light.png"; +import {ReactComponent as ArrowLeft} from "assets/icons/arrow-left.svg"; +import {ReactComponent as ArrowRight} from "assets/icons/arrow-right.svg"; +import "./Templates.scss"; + +type Side = "left" | "right"; + +export const Templates = () => { + const templatesRef = useRef(null); + const {t} = useTranslation(); + const isAnonymous = useAppSelector((state) => state.auth.user?.isAnonymous) ?? true; + + const scrollToSide = (side: Side) => { + const screenWidth = document.documentElement.clientWidth; + const offset = screenWidth * (side === "left" ? -1 : 1); + templatesRef.current?.scroll({left: offset, behavior: "smooth"}); + }; + + const renderContainerHeader = (renderSide: Side, title: string) => + isAnonymous ? ( +
+
{title}
+
+ ) : ( +
+ +
scrollToSide(renderSide === "left" ? "right" : "left")}> + {title} +
+ +
+ ); + + return ( + <> + {/* settings */} +
+
+ Stan just hanging there with a coffee + Stan just hanging there with a coffee +
+
{renderContainerHeader("left", t("Templates.recommendedTemplates"))}
+ {!isAnonymous &&
{renderContainerHeader("right", t("Templates.savedTemplates"))}
} +
+ + ); +}; diff --git a/src/routes/Boards/Templates/index.ts b/src/routes/Boards/Templates/index.ts new file mode 100644 index 0000000000..02f3228a69 --- /dev/null +++ b/src/routes/Boards/Templates/index.ts @@ -0,0 +1 @@ +export * from "./Templates"; diff --git a/src/routes/Boards/index.ts b/src/routes/Boards/index.ts new file mode 100644 index 0000000000..2563c8f19e --- /dev/null +++ b/src/routes/Boards/index.ts @@ -0,0 +1 @@ +export * from "./Boards"; diff --git a/src/routes/NewBoard/index.ts b/src/routes/NewBoard/index.ts deleted file mode 100644 index eff10f9c69..0000000000 --- a/src/routes/NewBoard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./NewBoard"; diff --git a/src/routes/Router.tsx b/src/routes/Router.tsx index 2b2bf2b7b1..8e41c2a231 100644 --- a/src/routes/Router.tsx +++ b/src/routes/Router.tsx @@ -1,6 +1,8 @@ -import {BrowserRouter, Route, Routes} from "react-router"; +import {BrowserRouter, Navigate, Route, Routes} from "react-router"; import {LoginBoard} from "routes/LoginBoard"; -import {NewBoard} from "routes/NewBoard"; +import {Boards} from "routes/Boards"; +import {Templates} from "routes/Boards/Templates"; +import {Sessions} from "routes/Boards/Sessions"; import {BoardGuard} from "routes/Board"; import {NotFound} from "routes/NotFound"; import {RequireAuthentication} from "routes/RequireAuthentication"; @@ -20,8 +22,12 @@ import {Homepage} from "./Homepage"; import {Legal} from "./Legal"; import {StackView} from "./StackView"; import RouteChangeObserver from "./RouteChangeObserver"; +import {LegacyNewBoard} from "./Boards/Legacy/LegacyNewBoard"; + +const renderLegacyRoute = (legacy: boolean) => (legacy ? } /> : } />); const Router = () => { + const legacyCreateBoard = !!useAppSelector((state) => state.view.legacyCreateBoard); const feedbackEnabled = useAppSelector((state) => state.view.feedbackEnabled); return ( @@ -32,14 +38,32 @@ const Router = () => { } /> } /> } /> + {renderLegacyRoute(legacyCreateBoard)} - + } - /> + > + } /> + }> + {/* TODO extract settings routes to avoid repetition */} + }> + } /> + } /> + } /> + + + }> + }> + } /> + } /> + } /> + + + } /> ("auth/signIn"); export const userCheckCompleted = createAction("auth/userCheckCompleted"); + +// allow changes to user auth without being part of a board +export const editUserOptimistically = createAction("auth/editUserOptimistically"); diff --git a/src/store/features/auth/reducer.ts b/src/store/features/auth/reducer.ts index 0ae1e6be98..2204001ba8 100644 --- a/src/store/features/auth/reducer.ts +++ b/src/store/features/auth/reducer.ts @@ -1,6 +1,6 @@ import {createReducer} from "@reduxjs/toolkit"; import {AuthState} from "./types"; -import {signIn, userCheckCompleted} from "./actions"; +import {editUserOptimistically, signIn, userCheckCompleted} from "./actions"; import {signOut} from "./thunks"; const initialState: AuthState = {user: undefined, initializationSucceeded: null}; @@ -18,4 +18,7 @@ export const authReducer = createReducer(initialState, (builder) => .addCase(userCheckCompleted, (state, action) => { state.initializationSucceeded = action.payload; }) + .addCase(editUserOptimistically, (state, action) => { + state.user = action.payload; + }) ); diff --git a/src/store/features/participants/thunks.ts b/src/store/features/participants/thunks.ts index 7fd6dcf7d5..c4b317ee56 100644 --- a/src/store/features/participants/thunks.ts +++ b/src/store/features/participants/thunks.ts @@ -3,11 +3,23 @@ import {API} from "api"; import {ApplicationState, retryable} from "store"; import {Toast} from "utils/Toast"; import i18n from "i18next"; -import {Auth} from "../auth"; +import {Auth, editUserOptimistically} from "store/features"; -export const editSelf = createAsyncThunk("participants/editSelf", async (payload) => { - await API.editUser(payload); - return payload; +export const editSelf = createAsyncThunk< + Auth, + { + auth: Auth; + applyOptimistically?: boolean; + }, + {state: ApplicationState} +>("participants/editSelf", async (payload, {dispatch}) => { + if (payload.applyOptimistically) { + // instantly apply changes (required when not in a board, since no event is retrieved) + dispatch(editUserOptimistically(payload.auth)); + } + + await API.editUser(payload.auth); + return payload.auth; }); export const changePermission = createAsyncThunk( diff --git a/src/store/features/view/reducer.ts b/src/store/features/view/reducer.ts index 197e7b2eff..444bbac9d2 100644 --- a/src/store/features/view/reducer.ts +++ b/src/store/features/view/reducer.ts @@ -34,6 +34,7 @@ const initialState: ViewState = { hotkeyNotificationsEnabled: getFromStorage(HOTKEY_NOTIFICATIONS_ENABLE_STORAGE_KEY) !== "false", showBoardReactions: getFromStorage(BOARD_REACTIONS_ENABLE_STORAGE_KEY) !== "false", theme: (getFromStorage(THEME_STORAGE_KEY) as Theme) ?? "auto", + legacyCreateBoard: process.env.REACT_APP_LEGACY_CREATE_BOARD === "true", snowfallEnabled: getFromStorage(SNOWFALL_STORAGE_KEY) !== "false", snowfallNotificationEnabled: getFromStorage(SNOWFALL_NOTIFICATION_STORAGE_KEY) !== "false", }; diff --git a/src/store/features/view/types.ts b/src/store/features/view/types.ts index 48e336163a..9ef4e6deec 100644 --- a/src/store/features/view/types.ts +++ b/src/store/features/view/types.ts @@ -41,6 +41,8 @@ export interface View { showBoardReactions: boolean; + readonly legacyCreateBoard?: boolean; + snowfallEnabled: boolean; snowfallNotificationEnabled: boolean;