Skip to content

Commit

Permalink
Add Rename Nickname (#237)
Browse files Browse the repository at this point in the history
* [feat] add navigation to profile page in profile popover

* [feat] add nickname change page

* [chore] add workspace query to show workspace

* [chore] delete duplicated route

* [fix] delete eslint-disabled

* [fix] delete unused textfieldElement value

* [fix] change profile routes

* [fix] add error message condition

* [fix] profile route

* [chore] fix typo

* [feat] create setting route and related component

* [chore] rename CommonHeader to DrawerAppBar

* [refactor] separate layout constants

* [refactor] refactor the function named with reversed naming convention
  • Loading branch information
devysi0827 authored Jul 22, 2024
1 parent 33264ec commit 94c0b9f
Show file tree
Hide file tree
Showing 12 changed files with 233 additions and 33 deletions.
4 changes: 2 additions & 2 deletions frontend/src/components/drawers/WorkspaceDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import WorkspaceListPopover from "../popovers/WorkspaceListPopover";
import PeopleIcon from "@mui/icons-material/People";
import { selectWorkspace } from "../../store/workspaceSlice";
import { DRAWER_WIDTH, WorkspaceDrawerHeader } from "../layouts/WorkspaceLayout";
import { WorkspaceDrawerHeader } from "../layouts/WorkspaceLayout";
import { useLocation, useNavigate, useParams } from "react-router-dom";

import { DRAWER_WIDTH } from "../../constants/layout";
interface WorkspaceDrawerProps {
open: boolean;
}
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/components/headers/DrawerAppBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { styled } from "@mui/material";
import AppBar, { AppBarProps } from "@mui/material/AppBar";
import { DRAWER_WIDTH } from "../../constants/layout";

interface DrawerAppBarProps extends AppBarProps {
open?: boolean;
}

export const DrawerAppBar = styled(AppBar, {
shouldForwardProp: (prop) => prop !== "open",
})<DrawerAppBarProps>(({ theme, open }) => ({
transition: theme.transitions.create(["margin", "width"], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
...(open && {
width: `calc(100% - ${DRAWER_WIDTH}px)`,
marginLeft: `${DRAWER_WIDTH}px`,
transition: theme.transitions.create(["margin", "width"], {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen,
}),
}),
}));
53 changes: 53 additions & 0 deletions frontend/src/components/headers/SettingHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { MouseEventHandler, useState } from "react";
import { Avatar, IconButton, Stack, Toolbar } from "@mui/material";
import { useSelector } from "react-redux";
import { selectUser } from "../../store/userSlice";
import ProfilePopover from "../popovers/ProfilePopover";
import CodePairIcon from "../icons/CodePairIcon";
import { useNavigate } from "react-router-dom";
import { DrawerAppBar } from "./DrawerAppBar";

function SettingHeader() {
const navigate = useNavigate();
const userStore = useSelector(selectUser);
const [profileAnchorEl, setProfileAnchorEl] = useState<(EventTarget & Element) | null>(null);

const handleOpenProfilePopover: MouseEventHandler = (event) => {
setProfileAnchorEl(event.currentTarget);
};

const handleCloseProfilePopover = () => {
setProfileAnchorEl(null);
};

const handleToWorkspace = () => {
navigate(`/${userStore.data?.lastWorkspaceSlug}`);
};

return (
<DrawerAppBar position="fixed">
<Toolbar>
<Stack
width="100%"
direction="row-reverse"
justifyContent="space-between"
alignItems="center"
>
<IconButton onClick={handleOpenProfilePopover}>
<Avatar>{userStore.data?.nickname?.charAt(0)}</Avatar>
</IconButton>
<IconButton onClick={handleToWorkspace}>
<CodePairIcon />
</IconButton>
</Stack>
</Toolbar>
<ProfilePopover
open={Boolean(profileAnchorEl)}
anchorEl={profileAnchorEl}
onClose={handleCloseProfilePopover}
/>
</DrawerAppBar>
);
}

export default SettingHeader;
30 changes: 4 additions & 26 deletions frontend/src/components/headers/WorkspaceHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { MouseEventHandler, useState } from "react";
import { Avatar, IconButton, Stack, Toolbar, styled, useTheme } from "@mui/material";
import AppBar, { AppBarProps } from "@mui/material/AppBar";
import { DRAWER_WIDTH } from "../layouts/WorkspaceLayout";
import { Avatar, IconButton, Stack, Toolbar, useTheme } from "@mui/material";
import MenuIcon from "@mui/icons-material/Menu";
import { useSelector } from "react-redux";
import { selectUser } from "../../store/userSlice";
Expand All @@ -11,27 +9,7 @@ import KeyboardDoubleArrowRightIcon from "@mui/icons-material/KeyboardDoubleArro
import CodePairIcon from "../icons/CodePairIcon";
import { useNavigate } from "react-router-dom";
import { selectWorkspace } from "../../store/workspaceSlice";

interface WorkspaceAppBarProps extends AppBarProps {
open?: boolean;
}

const WorkspaceAppBar = styled(AppBar, {
shouldForwardProp: (prop) => prop !== "open",
})<WorkspaceAppBarProps>(({ theme, open }) => ({
transition: theme.transitions.create(["margin", "width"], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
...(open && {
width: `calc(100% - ${DRAWER_WIDTH}px)`,
marginLeft: `${DRAWER_WIDTH}px`,
transition: theme.transitions.create(["margin", "width"], {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen,
}),
}),
}));
import { DrawerAppBar } from "./DrawerAppBar";

interface WorkspaceHeaderProps {
open: boolean;
Expand Down Expand Up @@ -59,7 +37,7 @@ function WorkspaceHeader(props: WorkspaceHeaderProps) {
};

return (
<WorkspaceAppBar position="fixed" open={open}>
<DrawerAppBar position="fixed" open={open}>
<Toolbar>
<Stack
width="100%"
Expand Down Expand Up @@ -99,7 +77,7 @@ function WorkspaceHeader(props: WorkspaceHeaderProps) {
anchorEl={profileAnchorEl}
onClose={handleCloseProfilePopover}
/>
</WorkspaceAppBar>
</DrawerAppBar>
);
}

Expand Down
14 changes: 14 additions & 0 deletions frontend/src/components/layouts/SettingLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Box from "@mui/material/Box";
import { Outlet } from "react-router-dom";
import SettingHeader from "../headers/SettingHeader";

function SettingLayout() {
return (
<Box sx={{ display: "flex" }}>
<SettingHeader />
<Outlet />
</Box>
);
}

export default SettingLayout;
3 changes: 1 addition & 2 deletions frontend/src/components/layouts/WorkspaceLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import WorkspaceHeader from "../headers/WorkspaceHeader";
import WorkspaceDrawer from "../drawers/WorkspaceDrawer";
import { useDispatch, useSelector } from "react-redux";
import { selectConfig, setDrawerOpen } from "../../store/configSlice";

export const DRAWER_WIDTH = 282;
import { DRAWER_WIDTH } from "../../constants/layout";

const Main = styled("main", { shouldForwardProp: (prop) => prop !== "open" })<{
open?: boolean;
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/modals/ChangeNicknameModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import { FormContainer, TextFieldElement } from "react-hook-form-mui";
import { useCheckNameConflictQuery } from "../../hooks/api/check";
import { useMemo, useState } from "react";
import { useDebounce } from "react-use";
import { useUpdateUserNicknmaeMutation } from "../../hooks/api/user";
import { useUpdateUserNicknameMutation } from "../../hooks/api/user";

interface ChangeNicknameModalProps extends Omit<ModalProps, "children"> {}

function ChangeNicknameModal(props: ChangeNicknameModalProps) {
const [nickname, setNickname] = useState("");
const [debouncedNickname, setDebouncedNickname] = useState("");
const { data: conflictResult } = useCheckNameConflictQuery(debouncedNickname);
const { mutateAsync: updateUserNickname } = useUpdateUserNicknmaeMutation();
const { mutateAsync: updateUserNickname } = useUpdateUserNicknameMutation();
const errorMessage = useMemo(() => {
if (conflictResult?.conflict) {
return "Already Exists";
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/components/popovers/ProfilePopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,30 @@ import {
PopoverProps,
} from "@mui/material";
import LogoutIcon from "@mui/icons-material/Logout";
import ManageAccountsIcon from "@mui/icons-material/ManageAccounts";
import { useDispatch } from "react-redux";
import { setAccessToken } from "../../store/authSlice";
import { setUserData } from "../../store/userSlice";
import DarkModeIcon from "@mui/icons-material/DarkMode";
import LightModeIcon from "@mui/icons-material/LightMode";
import { useCurrentTheme } from "../../hooks/useCurrentTheme";
import { setTheme } from "../../store/configSlice";
import { useNavigate } from "react-router-dom";

function ProfilePopover(props: PopoverProps) {
const dispatch = useDispatch();
const themeMode = useCurrentTheme();
const navigate = useNavigate();

const handleLogout = () => {
dispatch(setAccessToken(null));
dispatch(setUserData(null));
};

const handleMoveProfilePage = () => {
navigate(`/settings/profile`);
};

const handleChangeTheme = () => {
dispatch(setTheme(themeMode == "light" ? "dark" : "light"));
};
Expand All @@ -47,6 +54,12 @@ function ProfilePopover(props: PopoverProps) {
</ListItemIcon>
<ListItemText>Appearance</ListItemText>
</MenuItem>
<MenuItem onClick={handleMoveProfilePage}>
<ListItemIcon>
<ManageAccountsIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Profile</ListItemText>
</MenuItem>
<MenuItem onClick={handleLogout}>
<ListItemIcon>
<LogoutIcon fontSize="small" />
Expand Down
1 change: 1 addition & 0 deletions frontend/src/constants/layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const DRAWER_WIDTH = 282;
2 changes: 1 addition & 1 deletion frontend/src/hooks/api/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const useGetUserQuery = () => {
return query;
};

export const useUpdateUserNicknmaeMutation = () => {
export const useUpdateUserNicknameMutation = () => {
const authStore = useSelector(selectAuth);
const queryClient = useQueryClient();

Expand Down
105 changes: 105 additions & 0 deletions frontend/src/pages/settings/profile/Index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { Container, Stack, Avatar, Typography, Button, FormControl } from "@mui/material";
import { TextFieldElement, FormContainer } from "react-hook-form-mui";
import { useSelector } from "react-redux";
import { selectUser } from "../../../store/userSlice";
import { useEffect, useMemo, useState } from "react";
import { useDebounce } from "react-use";
import { useUpdateUserNicknameMutation } from "../../../hooks/api/user";
import { useCheckNameConflictQuery } from "../../../hooks/api/check";

const avatarSize = 117;

function ProfileIndex() {
const userStore = useSelector(selectUser);
const [nickname, setNickname] = useState(userStore.data?.nickname || "");
const [debouncedNickname, setDebouncedNickname] = useState("");
const { data: conflictResult } = useCheckNameConflictQuery(debouncedNickname);
const { mutateAsync: updateUserNickname } = useUpdateUserNicknameMutation();
const errorMessage = useMemo(() => {
if (debouncedNickname != userStore.data?.nickname && conflictResult?.conflict) {
return "Already Exists";
}
return null;
}, [conflictResult?.conflict, debouncedNickname, userStore.data?.nickname]);

const isSubmitDisabled = useMemo(() => {
return (
Boolean(errorMessage) || nickname === userStore.data?.nickname || nickname.length === 0
);
}, [errorMessage, nickname, userStore.data?.nickname]);

useDebounce(
() => {
setDebouncedNickname(nickname);
},
500,
[nickname]
);

useEffect(() => {
if (userStore.data?.nickname) {
setNickname(userStore.data?.nickname || "");
}
}, [userStore.data?.nickname]);

const handleNicknameChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setNickname(e.target.value);
};

const handleUpdateUserNickname = async (data: { nickname: string }) => {
await updateUserNickname(data);
};

return (
<Container sx={{ height: "calc(100vh - 88px)", width: "100%" }}>
<Stack alignItems="center" justifyContent="center" gap={6} sx={{ height: 1 }}>
<Avatar
sx={{
width: avatarSize,
height: avatarSize,
fontSize: avatarSize / 2,
}}
>
{userStore.data?.nickname?.charAt(0).toUpperCase()}
</Avatar>
<Stack width={310}>
<FormControl>
<Typography variant="body1">User name</Typography>
{userStore.data?.nickname && (
<FormContainer
defaultValues={{ nickname: userStore.data?.nickname }}
onSuccess={handleUpdateUserNickname}
>
<Stack gap={3}>
<TextFieldElement
variant="standard"
name="nickname"
required
fullWidth
inputProps={{
maxLength: 255,
}}
onChange={handleNicknameChange}
error={Boolean(errorMessage)}
helperText={errorMessage}
/>

<Button
type="submit"
variant="contained"
size="large"
disabled={isSubmitDisabled}
>
Save
</Button>
</Stack>
</FormContainer>
)}
</FormControl>
</Stack>
</Stack>
</Container>
);
}

export default ProfileIndex;
13 changes: 13 additions & 0 deletions frontend/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import Index from "./pages/Index";
import DocumentLayout from "./components/layouts/DocumentLayout";
import DocumentShareIndex from "./pages/workspace/document/share/Index";
import MemberIndex from "./pages/workspace/member/Index";
import ProfileIndex from "./pages/settings/profile/Index";
import SettingLayout from "./components/layouts/SettingLayout";

interface CodePairRoute {
path: string;
Expand Down Expand Up @@ -83,6 +85,17 @@ const codePairRoutes: Array<CodePairRoute> = [
accessType: AccessType.PRIVATE,
element: <JoinIndex />,
},
{
path: "settings/profile",
accessType: AccessType.PRIVATE,
element: <SettingLayout />,
children: [
{
path: "",
element: <ProfileIndex />,
},
],
},
];

const injectProtectedRoute = (routes: Array<CodePairRoute>) => {
Expand Down

0 comments on commit 94c0b9f

Please sign in to comment.