chore: update workspace user pages

This commit is contained in:
Steven 2022-09-25 20:30:40 +08:00
parent c0699f159e
commit a642465f86
14 changed files with 511 additions and 39 deletions

View File

@ -189,10 +189,10 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
); );
}; };
export default function showCreateShortcutDialog(workspaceId: WorkspaceId, shortcutId?: ShortcutId): void { export default function showCreateShortcutDialog(workspaceId: WorkspaceId, shortcutId?: ShortcutId, onDestory?: () => void): void {
generateDialog( generateDialog(
{ {
className: "px-2 sm:px-0", onDestory,
}, },
CreateShortcutDialog, CreateShortcutDialog,
{ {

View File

@ -127,13 +127,7 @@ const CreateWorkspaceDialog: React.FC<Props> = (props: Props) => {
}; };
export default function showCreateWorkspaceDialog(workspaceId?: WorkspaceId): void { export default function showCreateWorkspaceDialog(workspaceId?: WorkspaceId): void {
generateDialog( generateDialog({}, CreateWorkspaceDialog, {
{ workspaceId,
className: "px-2 sm:px-0", });
},
CreateWorkspaceDialog,
{
workspaceId,
}
);
} }

View File

@ -8,6 +8,7 @@ import "../../less/base-dialog.less";
interface DialogConfig { interface DialogConfig {
className?: string; className?: string;
clickSpaceDestroy?: boolean; clickSpaceDestroy?: boolean;
onDestory?: () => void;
} }
interface Props extends DialogConfig, DialogProps { interface Props extends DialogConfig, DialogProps {
@ -38,7 +39,7 @@ const BaseDialog: React.FC<Props> = (props: Props) => {
}; };
return ( return (
<div className={`dialog-wrapper ${className}`} onClick={handleSpaceClicked}> <div className={`dialog-wrapper px-2 sm:px-0 ${className}`} onClick={handleSpaceClicked}>
<div className="dialog-container" onClick={(e) => e.stopPropagation()}> <div className="dialog-container" onClick={(e) => e.stopPropagation()}>
{children} {children}
</div> </div>
@ -67,6 +68,10 @@ export function generateDialog<T extends DialogProps>(
dialog.unmount(); dialog.unmount();
tempDiv.remove(); tempDiv.remove();
}, ANIMATION_DURATION); }, ANIMATION_DURATION);
if (config.onDestory) {
config.onDestory();
}
}, },
}; };

View File

@ -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: Props) => {
const { workspaceId, workspaceUser: currentUser } = props;
const [state, setState] = useState<State>({
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 (
<div className="w-full flex flex-col justify-start items-start">
{loadingState.isLoading ? (
<></>
) : (
state.workspaceUserList.map((workspaceUser) => {
return (
<div key={workspaceUser.userId} className="w-full flex flex-row justify-between items-start border px-6 py-4 mb-3 rounded-lg">
<div className="flex flex-row justify-start items-center mr-4">
<span>{workspaceUser.user.name}</span>
{currentUser.userId === workspaceUser.userId && <span className="ml-2 text-gray-400">(yourself)</span>}
</div>
<div className="flex flex-row justify-end items-center">
{currentUser.role === "ADMIN" ? (
<>
<Dropdown
className="mr-4"
trigger={
<button className="flex flex-row justify-end items-center cursor-pointer">
<span className="font-mono">{workspaceUser.role}</span>
<Icon.ChevronDown className="ml-1 w-5 h-auto text-gray-600" />
</button>
}
actions={
<>
{userRoles.map((role) => {
return (
<button
key={role}
className="w-full px-3 leading-10 flex flex-row justify-between items-center text-left cursor-pointer rounded whitespace-nowrap hover:bg-gray-100"
onClick={() => handleWorkspaceUserRoleChange(workspaceUser, role.toUpperCase() as Role)}
>
<span className="truncate">{role}</span>
{workspaceUser.role === role.toLocaleUpperCase() && <Icon.Check className="w-4 h-auto ml-1 shrink-0" />}
</button>
);
})}
</>
}
actionsClassName="!w-28 !-left-4"
></Dropdown>
<Dropdown
actions={
<>
<button
className="w-full px-3 text-left leading-10 cursor-pointer rounded text-red-600 hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
onClick={() => {
handleDeleteWorkspaceUserButtonClick(workspaceUser);
}}
>
Delete
</button>
</>
}
actionsClassName="!w-24"
></Dropdown>
</>
) : (
<>
<span className="font-mono">{workspaceUser.role}</span>
</>
)}
</div>
</div>
);
})
)}
</div>
);
};
export default MemberListView;

View File

@ -1,9 +1,10 @@
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { shortcutService, workspaceService } from "../services"; import { shortcutService, workspaceService } from "../services";
import { useAppSelector } from "../store"; import { useAppSelector } from "../store";
import { showCommonDialog } from "./Dialog/CommonDialog";
import Dropdown from "./common/Dropdown"; import Dropdown from "./common/Dropdown";
import showCreateShortcutDialog from "./CreateShortcutDialog";
import Icon from "./Icon"; import Icon from "./Icon";
import showCreateShortcutDialog from "./CreateShortcutDialog";
interface Props { interface Props {
workspaceId: WorkspaceId; workspaceId: WorkspaceId;
@ -20,7 +21,14 @@ const ShortcutListView: React.FC<Props> = (props: Props) => {
}; };
const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => { 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 ( return (

View File

@ -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: Props) => {
const { destroy, workspaceId } = props;
const [state, setState] = useState<State>({
workspaceUserUpsert: {
workspaceId: workspaceId,
userId: UNKNOWN_ID,
role: "USER",
},
});
const requestState = useLoading(false);
const handleUserIdInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setState({
workspaceUserUpsert: Object.assign(state.workspaceUserUpsert, {
userId: Number(text),
}),
});
};
const handleUserRoleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<>
<div className="max-w-full w-80 flex flex-row justify-between items-center mb-4">
<p className="text-base">Create Workspace Member</p>
<button className="rounded p-1 hover:bg-gray-100" onClick={destroy}>
<Icon.X className="w-5 h-auto text-gray-600" />
</button>
</div>
<div className="w-full flex flex-col justify-start items-start">
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">User ID</span>
<input
className="w-full rounded border text-sm shadow-inner px-2 py-2"
type="number"
value={state.workspaceUserUpsert.userId}
onChange={handleUserIdInputChange}
/>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">Role</span>
<div>
<input
type="radio"
name="role"
id="role-user"
value="USER"
onChange={handleUserRoleInputChange}
checked={state.workspaceUserUpsert.role === "USER"}
/>
<label htmlFor="role-user" className="ml-1 mr-4">
User
</label>
<input
type="radio"
name="role"
id="role-admin"
value="ADMIN"
onChange={handleUserRoleInputChange}
checked={state.workspaceUserUpsert.role === "ADMIN"}
/>
<label htmlFor="role-admin" className="ml-1">
Admin
</label>
</div>
</div>
<div className="w-full flex flex-row justify-end items-center">
<button
disabled={requestState.isLoading}
className={`rounded px-3 leading-9 shadow bg-green-600 text-white hover:bg-green-700 ${
requestState.isLoading ? "opacity-80" : ""
}`}
onClick={handleSaveBtnClick}
>
Save
</button>
</div>
</div>
</>
);
};
export default function showUpsertWorkspaceUserDialog(workspaceId: WorkspaceId, onDestory?: () => void) {
return generateDialog(
{
onDestory,
},
UpsertWorkspaceUserDialog,
{
workspaceId,
}
);
}

View File

@ -1,5 +1,6 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { workspaceService } from "../services"; import { workspaceService } from "../services";
import { showCommonDialog } from "./Dialog/CommonDialog";
import Dropdown from "./common/Dropdown"; import Dropdown from "./common/Dropdown";
import showCreateWorkspaceDialog from "./CreateWorkspaceDialog"; import showCreateWorkspaceDialog from "./CreateWorkspaceDialog";
@ -11,7 +12,14 @@ const WorkspaceListView: React.FC<Props> = (props: Props) => {
const { workspaceList } = props; const { workspaceList } = props;
const handleDeleteWorkspaceButtonClick = (workspace: Workspace) => { 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 ( return (

View File

@ -76,6 +76,24 @@ export function deleteWorkspaceById(workspaceId: WorkspaceId) {
return axios.delete(`/api/workspace/${workspaceId}`); return axios.delete(`/api/workspace/${workspaceId}`);
} }
export function upsertWorkspaceUser(upsert: WorkspaceUserUpsert) {
return axios.post<ResponseObject<WorkspaceUser>>(`/api/workspace/${upsert.workspaceId}/user`, upsert);
}
export function getWorkspaceUserList(workspaceUserFind?: WorkspaceUserFind) {
return axios.get<ResponseObject<WorkspaceUser[]>>(`/api/workspace/${workspaceUserFind?.workspaceId}/user`);
}
export function getWorkspaceUser(workspaceUserFind?: WorkspaceUserFind) {
return axios.get<ResponseObject<WorkspaceUser>>(
`/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) { export function getShortcutList(shortcutFind?: ShortcutFind) {
const queryList = []; const queryList = [];
if (shortcutFind?.creatorId) { if (shortcutFind?.creatorId) {

View File

@ -1,3 +1,9 @@
import { isNull, isUndefined } from "lodash-es";
export const isNullorUndefined = (value: any) => {
return isNull(value) || isUndefined(value);
};
export function getNowTimeStamp(): number { export function getNowTimeStamp(): number {
return Date.now(); return Date.now();
} }

View File

@ -1,25 +1,34 @@
import { useEffect, useState } from "react"; 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 { shortcutService, userService, workspaceService } from "../services";
import { useAppSelector } from "../store"; import { useAppSelector } from "../store";
import { unknownWorkspace, unknownWorkspaceUser } from "../store/modules/workspace";
import useLoading from "../hooks/useLoading"; import useLoading from "../hooks/useLoading";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import toastHelper from "../components/Toast"; import toastHelper from "../components/Toast";
import Dropdown from "../components/common/Dropdown";
import Header from "../components/Header"; import Header from "../components/Header";
import ShortcutListView from "../components/ShortcutListView"; import ShortcutListView from "../components/ShortcutListView";
import { unknownWorkspace } from "../store/modules/workspace";
import showCreateShortcutDialog from "../components/CreateShortcutDialog"; import showCreateShortcutDialog from "../components/CreateShortcutDialog";
import MemberListView from "../components/MemberListView";
import showUpsertWorkspaceUserDialog from "../components/UpsertWorkspaceUserDialog";
interface State { interface State {
workspace: Workspace; workspace: Workspace;
workspaceUser: WorkspaceUser;
userList: WorkspaceUser[];
} }
const WorkspaceDetail: React.FC = () => { const WorkspaceDetail: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const params = useParams(); const params = useParams();
const location = useLocation();
const user = useAppSelector((state) => state.user.user) as User;
const { shortcutList } = useAppSelector((state) => state.shortcut); const { shortcutList } = useAppSelector((state) => state.shortcut);
const [state, setState] = useState<State>({ const [state, setState] = useState<State>({
workspace: unknownWorkspace, workspace: unknownWorkspace,
workspaceUser: unknownWorkspaceUser,
userList: [],
}); });
const loadingState = useLoading(); const loadingState = useLoading();
@ -35,28 +44,91 @@ const WorkspaceDetail: React.FC = () => {
return; return;
} }
setState({
...state,
workspace,
});
loadingState.setLoading(); loadingState.setLoading();
Promise.all([shortcutService.fetchWorkspaceShortcuts(workspace.id)]).finally(() => { Promise.all([
loadingState.setFinish(); 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]); }, [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 ( return (
<div className="w-full h-full flex flex-col justify-start items-start"> <div className="w-full h-full flex flex-col justify-start items-start">
<Header /> <Header />
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start"> <div className="mx-auto max-w-4xl w-full px-3 pb-6 flex flex-col justify-start items-start">
<div className="w-full flex flex-row justify-between items-center mb-4"> <div className="w-full flex flex-row justify-between items-center mt-4 mb-4">
<span className="font-mono text-gray-400">Shortcut List</span> <div className="flex flex-row justify-start items-center space-x-4">
<button <NavLink to="#shortcuts" className={`${location.hash === "#shortcuts" && "underline"}`}>
className="text-sm flex flex-row justify-start items-center border px-3 leading-10 rounded-lg cursor-pointer hover:shadow" Shortcuts
onClick={() => showCreateShortcutDialog(state.workspace.id)} </NavLink>
> <NavLink to="#members" className={`${location.hash === "#members" && "underline"}`}>
<Icon.Plus className="w-4 h-auto mr-1" /> Create Shortcut Members
</button> </NavLink>
</div>
<div>
<Dropdown
trigger={
<button className="w-32 flex flex-row justify-start items-center border px-3 leading-10 rounded-lg cursor-pointer hover:shadow">
<Icon.Plus className="w-4 h-auto mr-1" /> Add new...
</button>
}
actions={
<>
<button
className="w-full flex flex-row justify-start items-center px-3 leading-10 rounded cursor-pointer hover:bg-gray-100"
onClick={handleCreateShortcutButtonClick}
>
Shortcut
</button>
<button
className="w-full flex flex-row justify-start items-center px-3 leading-10 rounded cursor-pointer hover:bg-gray-100"
onClick={handleUpsertWorkspaceMemberButtonClick}
>
Member
</button>
</>
}
actionsClassName="!w-32"
/>
</div>
</div> </div>
{loadingState.isLoading ? ( {loadingState.isLoading ? (
<div className="py-4 w-full flex flex-row justify-center items-center"> <div className="py-4 w-full flex flex-row justify-center items-center">
@ -64,7 +136,12 @@ const WorkspaceDetail: React.FC = () => {
loading loading
</div> </div>
) : ( ) : (
<ShortcutListView workspaceId={state.workspace.id} shortcutList={shortcutList} /> <>
{location.hash === "#shortcuts" && <ShortcutListView workspaceId={state.workspace.id} shortcutList={shortcutList} />}
{location.hash === "#members" && (
<MemberListView workspaceId={state.workspace.id} workspaceUser={state.workspaceUser} userList={state.userList} />
)}
</>
)} )}
</div> </div>
</div> </div>

View File

@ -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 { userService, workspaceService } from "../services";
import Auth from "../pages/Auth"; import Auth from "../pages/Auth";
import Home from "../pages/Home"; import Home from "../pages/Home";
@ -7,6 +8,10 @@ import WorkspaceDetail from "../pages/WorkspaceDetail";
import ShortcutRedirector from "../pages/ShortcutRedirector"; import ShortcutRedirector from "../pages/ShortcutRedirector";
const router = createBrowserRouter([ const router = createBrowserRouter([
{
path: "/user/auth",
element: <Auth />,
},
{ {
path: "/", path: "/",
element: <Home />, element: <Home />,
@ -16,12 +21,13 @@ const router = createBrowserRouter([
} catch (error) { } catch (error) {
// do nth // do nth
} }
const { user } = userService.getState();
if (isNullorUndefined(user)) {
return redirect("/user/auth");
}
}, },
}, },
{
path: "/user/auth",
element: <Auth />,
},
{ {
path: "/account", path: "/account",
element: <UserDetail />, element: <UserDetail />,
@ -31,6 +37,11 @@ const router = createBrowserRouter([
} catch (error) { } catch (error) {
// do nth // do nth
} }
const { user } = userService.getState();
if (isNullorUndefined(user)) {
return redirect("/user/auth");
}
}, },
}, },
{ {
@ -43,6 +54,11 @@ const router = createBrowserRouter([
} catch (error) { } catch (error) {
// do nth // do nth
} }
const { user } = userService.getState();
if (isNullorUndefined(user)) {
return redirect("/user/auth");
}
}, },
}, },
{ {
@ -55,6 +71,11 @@ const router = createBrowserRouter([
} catch (error) { } catch (error) {
// do nth // do nth
} }
const { user } = userService.getState();
if (isNullorUndefined(user)) {
return redirect("/user/auth");
}
}, },
}, },
]); ]);

View File

@ -60,6 +60,25 @@ const workspaceService = {
await api.deleteWorkspaceById(id); await api.deleteWorkspaceById(id);
store.dispatch(deleteWorkspace(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; export default workspaceService;

View File

@ -5,6 +5,11 @@ export const unknownWorkspace = {
id: UNKNOWN_ID, id: UNKNOWN_ID,
} as Workspace; } as Workspace;
export const unknownWorkspaceUser = {
workspaceId: UNKNOWN_ID,
userId: UNKNOWN_ID,
} as WorkspaceUser;
interface State { interface State {
workspaceList: Workspace[]; workspaceList: Workspace[];
} }

View File

@ -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;
}