diff --git a/web/src/components/CreateShortcutDialog.tsx b/web/src/components/CreateShortcutDialog.tsx index 26dc81a..49a79c1 100644 --- a/web/src/components/CreateShortcutDialog.tsx +++ b/web/src/components/CreateShortcutDialog.tsx @@ -189,10 +189,10 @@ const CreateShortcutDialog: React.FC = (props: Props) => { ); }; -export default function showCreateShortcutDialog(workspaceId: WorkspaceId, shortcutId?: ShortcutId): void { +export default function showCreateShortcutDialog(workspaceId: WorkspaceId, shortcutId?: ShortcutId, onDestory?: () => void): void { generateDialog( { - className: "px-2 sm:px-0", + onDestory, }, CreateShortcutDialog, { diff --git a/web/src/components/CreateWorkspaceDialog.tsx b/web/src/components/CreateWorkspaceDialog.tsx index 2044cf3..dd0def7 100644 --- a/web/src/components/CreateWorkspaceDialog.tsx +++ b/web/src/components/CreateWorkspaceDialog.tsx @@ -127,13 +127,7 @@ const CreateWorkspaceDialog: React.FC = (props: Props) => { }; export default function showCreateWorkspaceDialog(workspaceId?: WorkspaceId): void { - generateDialog( - { - className: "px-2 sm:px-0", - }, - CreateWorkspaceDialog, - { - workspaceId, - } - ); + generateDialog({}, CreateWorkspaceDialog, { + workspaceId, + }); } diff --git a/web/src/components/Dialog/BaseDialog.tsx b/web/src/components/Dialog/BaseDialog.tsx index a62b620..9f44eb1 100644 --- a/web/src/components/Dialog/BaseDialog.tsx +++ b/web/src/components/Dialog/BaseDialog.tsx @@ -8,6 +8,7 @@ import "../../less/base-dialog.less"; interface DialogConfig { className?: string; clickSpaceDestroy?: boolean; + onDestory?: () => void; } interface Props extends DialogConfig, DialogProps { @@ -38,7 +39,7 @@ const BaseDialog: React.FC = (props: Props) => { }; return ( -
+
e.stopPropagation()}> {children}
@@ -67,6 +68,10 @@ export function generateDialog( dialog.unmount(); tempDiv.remove(); }, ANIMATION_DURATION); + + if (config.onDestory) { + config.onDestory(); + } }, }; diff --git a/web/src/components/MemberListView.tsx b/web/src/components/MemberListView.tsx new file mode 100644 index 0000000..9b3e7ac --- /dev/null +++ b/web/src/components/MemberListView.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from "react"; +import { deleteWorkspaceUser, upsertWorkspaceUser } from "../helpers/api"; +import useLoading from "../hooks/useLoading"; +import { workspaceService } from "../services"; +import toastHelper from "./Toast"; +import Dropdown from "./common/Dropdown"; +import { showCommonDialog } from "./Dialog/CommonDialog"; +import Icon from "./Icon"; + +const userRoles = ["Admin", "User"]; + +interface Props { + workspaceId: WorkspaceId; + workspaceUser: WorkspaceUser; + userList: WorkspaceUser[]; +} + +interface State { + workspaceUserList: WorkspaceUser[]; +} + +const MemberListView: React.FC = (props: Props) => { + const { workspaceId, workspaceUser: currentUser } = props; + const [state, setState] = useState({ + workspaceUserList: [], + }); + const loadingState = useLoading(); + + const fetchWorkspaceUserList = () => { + loadingState.setLoading(); + return Promise.all([workspaceService.getWorkspaceUserList(workspaceId)]) + .then(([workspaceUserList]) => { + setState({ + workspaceUserList: workspaceUserList, + }); + }) + .finally(() => { + loadingState.setFinish(); + }); + }; + + useEffect(() => { + fetchWorkspaceUserList(); + }, [props]); + + const handleWorkspaceUserRoleChange = async (workspaceUser: WorkspaceUser, role: Role) => { + if (workspaceUser.userId === currentUser.userId) { + toastHelper.error("Cannot change yourself."); + return; + } + + await upsertWorkspaceUser({ + workspaceId: workspaceId, + userId: workspaceUser.userId, + role, + }); + await fetchWorkspaceUserList(); + }; + + const handleDeleteWorkspaceUserButtonClick = (workspaceUser: WorkspaceUser) => { + showCommonDialog({ + title: "Delete Workspace Member", + content: `Are you sure to delete member \`${workspaceUser.user.name}\` in this workspace?`, + style: "warning", + onConfirm: async () => { + await deleteWorkspaceUser({ + workspaceId: workspaceId, + userId: workspaceUser.userId, + }); + await fetchWorkspaceUserList(); + }, + }); + }; + + return ( +
+ {loadingState.isLoading ? ( + <> + ) : ( + state.workspaceUserList.map((workspaceUser) => { + return ( +
+
+ {workspaceUser.user.name} + {currentUser.userId === workspaceUser.userId && (yourself)} +
+
+ {currentUser.role === "ADMIN" ? ( + <> + + {workspaceUser.role} + + + } + actions={ + <> + {userRoles.map((role) => { + return ( + + ); + })} + + } + actionsClassName="!w-28 !-left-4" + > + + + + } + actionsClassName="!w-24" + > + + ) : ( + <> + {workspaceUser.role} + + )} +
+
+ ); + }) + )} +
+ ); +}; + +export default MemberListView; diff --git a/web/src/components/ShortcutListView.tsx b/web/src/components/ShortcutListView.tsx index 7130e5d..ed55d0c 100644 --- a/web/src/components/ShortcutListView.tsx +++ b/web/src/components/ShortcutListView.tsx @@ -1,9 +1,10 @@ import copy from "copy-to-clipboard"; import { shortcutService, workspaceService } from "../services"; import { useAppSelector } from "../store"; +import { showCommonDialog } from "./Dialog/CommonDialog"; import Dropdown from "./common/Dropdown"; -import showCreateShortcutDialog from "./CreateShortcutDialog"; import Icon from "./Icon"; +import showCreateShortcutDialog from "./CreateShortcutDialog"; interface Props { workspaceId: WorkspaceId; @@ -20,7 +21,14 @@ const ShortcutListView: React.FC = (props: Props) => { }; const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => { - shortcutService.deleteShortcutById(shortcut.id); + showCommonDialog({ + title: "Delete Shortcut", + content: `Are you sure to delete shortcut \`${shortcut.name}\` in this workspace?`, + style: "warning", + onConfirm: async () => { + await shortcutService.deleteShortcutById(shortcut.id); + }, + }); }; return ( diff --git a/web/src/components/UpsertWorkspaceUserDialog.tsx b/web/src/components/UpsertWorkspaceUserDialog.tsx new file mode 100644 index 0000000..fb6b096 --- /dev/null +++ b/web/src/components/UpsertWorkspaceUserDialog.tsx @@ -0,0 +1,138 @@ +import { useState } from "react"; +import { UNKNOWN_ID } from "../helpers/consts"; +import { upsertWorkspaceUser } from "../helpers/api"; +import useLoading from "../hooks/useLoading"; +import Icon from "./Icon"; +import { generateDialog } from "./Dialog"; +import toastHelper from "./Toast"; + +interface Props extends DialogProps { + workspaceId: WorkspaceId; +} + +interface State { + workspaceUserUpsert: WorkspaceUserUpsert; +} + +const UpsertWorkspaceUserDialog: React.FC = (props: Props) => { + const { destroy, workspaceId } = props; + const [state, setState] = useState({ + workspaceUserUpsert: { + workspaceId: workspaceId, + userId: UNKNOWN_ID, + role: "USER", + }, + }); + const requestState = useLoading(false); + + const handleUserIdInputChange = (e: React.ChangeEvent) => { + const text = e.target.value as string; + + setState({ + workspaceUserUpsert: Object.assign(state.workspaceUserUpsert, { + userId: Number(text), + }), + }); + }; + + const handleUserRoleInputChange = (e: React.ChangeEvent) => { + const text = e.target.value as string; + + setState({ + workspaceUserUpsert: Object.assign(state.workspaceUserUpsert, { + role: text, + }), + }); + }; + + const handleSaveBtnClick = async () => { + if (!state.workspaceUserUpsert.userId) { + toastHelper.error("User ID is required"); + return; + } + + requestState.setLoading(); + try { + await upsertWorkspaceUser({ + ...state.workspaceUserUpsert, + }); + destroy(); + } catch (error: any) { + console.error(error); + toastHelper.error(error.response.data.error || error.response.data.message); + } + requestState.setFinish(); + }; + + return ( + <> +
+

Create Workspace Member

+ +
+
+
+ User ID + +
+
+ Role +
+ + + + +
+
+
+ +
+
+ + ); +}; + +export default function showUpsertWorkspaceUserDialog(workspaceId: WorkspaceId, onDestory?: () => void) { + return generateDialog( + { + onDestory, + }, + UpsertWorkspaceUserDialog, + { + workspaceId, + } + ); +} diff --git a/web/src/components/WorkspaceListView.tsx b/web/src/components/WorkspaceListView.tsx index 39dd85e..7f83df3 100644 --- a/web/src/components/WorkspaceListView.tsx +++ b/web/src/components/WorkspaceListView.tsx @@ -1,5 +1,6 @@ import { Link } from "react-router-dom"; import { workspaceService } from "../services"; +import { showCommonDialog } from "./Dialog/CommonDialog"; import Dropdown from "./common/Dropdown"; import showCreateWorkspaceDialog from "./CreateWorkspaceDialog"; @@ -11,7 +12,14 @@ const WorkspaceListView: React.FC = (props: Props) => { const { workspaceList } = props; const handleDeleteWorkspaceButtonClick = (workspace: Workspace) => { - workspaceService.deleteWorkspaceById(workspace.id); + showCommonDialog({ + title: "Delete Workspace", + content: `Are you sure to delete workspace \`${workspace.name}\`?`, + style: "warning", + onConfirm: async () => { + await workspaceService.deleteWorkspaceById(workspace.id); + }, + }); }; return ( diff --git a/web/src/helpers/api.ts b/web/src/helpers/api.ts index 1ad9484..d81782f 100644 --- a/web/src/helpers/api.ts +++ b/web/src/helpers/api.ts @@ -76,6 +76,24 @@ export function deleteWorkspaceById(workspaceId: WorkspaceId) { return axios.delete(`/api/workspace/${workspaceId}`); } +export function upsertWorkspaceUser(upsert: WorkspaceUserUpsert) { + return axios.post>(`/api/workspace/${upsert.workspaceId}/user`, upsert); +} + +export function getWorkspaceUserList(workspaceUserFind?: WorkspaceUserFind) { + return axios.get>(`/api/workspace/${workspaceUserFind?.workspaceId}/user`); +} + +export function getWorkspaceUser(workspaceUserFind?: WorkspaceUserFind) { + return axios.get>( + `/api/workspace/${workspaceUserFind?.workspaceId}/user/${workspaceUserFind?.userId ?? ""}` + ); +} + +export function deleteWorkspaceUser(workspaceUserDelete: WorkspaceUserDelete) { + return axios.delete(`/api/workspace/${workspaceUserDelete.workspaceId}/user/${workspaceUserDelete.userId}`); +} + export function getShortcutList(shortcutFind?: ShortcutFind) { const queryList = []; if (shortcutFind?.creatorId) { diff --git a/web/src/helpers/utils.ts b/web/src/helpers/utils.ts index 2752928..c49ff38 100644 --- a/web/src/helpers/utils.ts +++ b/web/src/helpers/utils.ts @@ -1,3 +1,9 @@ +import { isNull, isUndefined } from "lodash-es"; + +export const isNullorUndefined = (value: any) => { + return isNull(value) || isUndefined(value); +}; + export function getNowTimeStamp(): number { return Date.now(); } diff --git a/web/src/pages/WorkspaceDetail.tsx b/web/src/pages/WorkspaceDetail.tsx index 79c13bc..bfd508e 100644 --- a/web/src/pages/WorkspaceDetail.tsx +++ b/web/src/pages/WorkspaceDetail.tsx @@ -1,25 +1,34 @@ import { useEffect, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { NavLink, useLocation, useNavigate, useParams } from "react-router-dom"; import { shortcutService, userService, workspaceService } from "../services"; import { useAppSelector } from "../store"; +import { unknownWorkspace, unknownWorkspaceUser } from "../store/modules/workspace"; import useLoading from "../hooks/useLoading"; import Icon from "../components/Icon"; import toastHelper from "../components/Toast"; +import Dropdown from "../components/common/Dropdown"; import Header from "../components/Header"; import ShortcutListView from "../components/ShortcutListView"; -import { unknownWorkspace } from "../store/modules/workspace"; import showCreateShortcutDialog from "../components/CreateShortcutDialog"; +import MemberListView from "../components/MemberListView"; +import showUpsertWorkspaceUserDialog from "../components/UpsertWorkspaceUserDialog"; interface State { workspace: Workspace; + workspaceUser: WorkspaceUser; + userList: WorkspaceUser[]; } const WorkspaceDetail: React.FC = () => { const navigate = useNavigate(); const params = useParams(); + const location = useLocation(); + const user = useAppSelector((state) => state.user.user) as User; const { shortcutList } = useAppSelector((state) => state.shortcut); const [state, setState] = useState({ workspace: unknownWorkspace, + workspaceUser: unknownWorkspaceUser, + userList: [], }); const loadingState = useLoading(); @@ -35,28 +44,91 @@ const WorkspaceDetail: React.FC = () => { return; } - setState({ - ...state, - workspace, - }); loadingState.setLoading(); - Promise.all([shortcutService.fetchWorkspaceShortcuts(workspace.id)]).finally(() => { - loadingState.setFinish(); - }); + Promise.all([ + shortcutService.fetchWorkspaceShortcuts(workspace.id), + workspaceService.getWorkspaceUser(workspace.id, user.id), + workspaceService.getWorkspaceUserList(workspace.id), + ]) + .then(([, workspaceUser, workspaceUserList]) => { + setState({ + workspace, + workspaceUser, + userList: workspaceUserList, + }); + }) + .finally(() => { + loadingState.setFinish(); + }); }, [params.workspaceName]); + useEffect(() => { + if (location.hash !== "#shortcuts" && location.hash !== "#members") { + navigate("#shortcuts"); + } + }, [location.hash]); + + const handleCreateShortcutButtonClick = () => { + showCreateShortcutDialog(state.workspace.id, undefined, async () => { + if (location.hash !== "#shortcuts") { + navigate("#shortcuts"); + } + }); + }; + + const handleUpsertWorkspaceMemberButtonClick = () => { + showUpsertWorkspaceUserDialog(state.workspace.id, async () => { + const workspaceUserList = await workspaceService.getWorkspaceUserList(state.workspace.id); + setState({ + ...state, + userList: workspaceUserList, + }); + + if (location.hash !== "#members") { + navigate("#members"); + } + }); + }; + return (
-
-
- Shortcut List - +
+
+
+ + Shortcuts + + + Members + +
+
+ + Add new... + + } + actions={ + <> + + + + } + actionsClassName="!w-32" + /> +
{loadingState.isLoading ? (
@@ -64,7 +136,12 @@ const WorkspaceDetail: React.FC = () => { loading
) : ( - + <> + {location.hash === "#shortcuts" && } + {location.hash === "#members" && ( + + )} + )}
diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 68fbd82..238290c 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -1,4 +1,5 @@ -import { createBrowserRouter } from "react-router-dom"; +import { createBrowserRouter, redirect } from "react-router-dom"; +import { isNullorUndefined } from "../helpers/utils"; import { userService, workspaceService } from "../services"; import Auth from "../pages/Auth"; import Home from "../pages/Home"; @@ -7,6 +8,10 @@ import WorkspaceDetail from "../pages/WorkspaceDetail"; import ShortcutRedirector from "../pages/ShortcutRedirector"; const router = createBrowserRouter([ + { + path: "/user/auth", + element: , + }, { path: "/", element: , @@ -16,12 +21,13 @@ const router = createBrowserRouter([ } catch (error) { // do nth } + + const { user } = userService.getState(); + if (isNullorUndefined(user)) { + return redirect("/user/auth"); + } }, }, - { - path: "/user/auth", - element: , - }, { path: "/account", element: , @@ -31,6 +37,11 @@ const router = createBrowserRouter([ } catch (error) { // do nth } + + const { user } = userService.getState(); + if (isNullorUndefined(user)) { + return redirect("/user/auth"); + } }, }, { @@ -43,6 +54,11 @@ const router = createBrowserRouter([ } catch (error) { // do nth } + + const { user } = userService.getState(); + if (isNullorUndefined(user)) { + return redirect("/user/auth"); + } }, }, { @@ -55,6 +71,11 @@ const router = createBrowserRouter([ } catch (error) { // do nth } + + const { user } = userService.getState(); + if (isNullorUndefined(user)) { + return redirect("/user/auth"); + } }, }, ]); diff --git a/web/src/services/workspaceService.ts b/web/src/services/workspaceService.ts index 6b8c207..c65fc50 100644 --- a/web/src/services/workspaceService.ts +++ b/web/src/services/workspaceService.ts @@ -60,6 +60,25 @@ const workspaceService = { await api.deleteWorkspaceById(id); store.dispatch(deleteWorkspace(id)); }, + + getWorkspaceUserList: async (id: WorkspaceId) => { + const { data } = ( + await api.getWorkspaceUserList({ + workspaceId: id, + }) + ).data; + return data; + }, + + getWorkspaceUser: async (workspaceId: WorkspaceId, userId: UserId) => { + const { data } = ( + await api.getWorkspaceUser({ + workspaceId: workspaceId, + userId: userId, + }) + ).data; + return data; + }, }; export default workspaceService; diff --git a/web/src/store/modules/workspace.ts b/web/src/store/modules/workspace.ts index 879b3f4..2b1d0d0 100644 --- a/web/src/store/modules/workspace.ts +++ b/web/src/store/modules/workspace.ts @@ -5,6 +5,11 @@ export const unknownWorkspace = { id: UNKNOWN_ID, } as Workspace; +export const unknownWorkspaceUser = { + workspaceId: UNKNOWN_ID, + userId: UNKNOWN_ID, +} as WorkspaceUser; + interface State { workspaceList: Workspace[]; } diff --git a/web/src/types/modules/WorkspaceUser.d.ts b/web/src/types/modules/WorkspaceUser.d.ts new file mode 100644 index 0000000..3e01593 --- /dev/null +++ b/web/src/types/modules/WorkspaceUser.d.ts @@ -0,0 +1,27 @@ +type Role = "ADMIN" | "USER"; + +interface WorkspaceUser { + workspaceId: WorkspaceId; + userId: UserId; + user: User; + role: Role; + createdTs: TimeStamp; + updatedTs: TimeStamp; +} + +interface WorkspaceUserUpsert { + workspaceId: WorkspaceId; + userId: UserId; + role: Role; + updatedTs?: TimeStamp; +} + +interface WorkspaceUserFind { + workspaceId: WorkspaceId; + userId?: UserId; +} + +interface WorkspaceUserDelete { + workspaceId: WorkspaceId; + userId: UserId; +}