update: members permissions UIs

This commit is contained in:
MTG2000
2022-09-25 15:16:42 +03:00
parent 8b3c0df5be
commit 96dac5fbf6
9 changed files with 212 additions and 78 deletions

View File

@@ -3,7 +3,7 @@ import Skeleton from 'react-loading-skeleton'
export default function ProjectCardMiniSkeleton() {
return (
<div className="bg-gray-25 select-none px-16 py-16 flex min-w-[300px] gap-16 border border-gray-200 rounded-10 items-center" >
<div className="bg-gray-25 select-none px-16 py-16 flex flex-[0_0_100%] max-w-[296px] gap-16 border border-gray-200 rounded-10 items-center" >
<Skeleton circle width={64} height={64} containerClassName='flex-shrink-0' />
<div className="justify-around items-start min-w-0">
<p className="text-body4 w-full font-bold overflow-ellipsis overflow-hidden whitespace-nowrap"><Skeleton width="15ch" /></p>

View File

@@ -1,7 +1,7 @@
import { FormProvider, NestedValue, Resolver, SubmitHandler, useForm } from "react-hook-form"
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import { IsValidProjectHashtagDocument, ProjectLaunchStatusEnum, Team_Member_Role, UpdateProjectInput, useProjectDetailsQuery } from "src/graphql";
import { IsValidProjectHashtagDocument, ProjectDetailsQuery, ProjectLaunchStatusEnum, ProjectPermissionEnum, Team_Member_Role, UpdateProjectInput, useProjectDetailsQuery } from "src/graphql";
import { PropsWithChildren } from "react";
import { useSearchParams } from "react-router-dom";
import { usePrompt } from "src/utils/hooks";
@@ -9,6 +9,10 @@ import { imageSchema } from "src/utils/validation";
import { Override } from "src/utils/interfaces";
import LoadingPage from "src/Components/LoadingPage/LoadingPage";
import { apolloClient } from "src/utils/apollo";
import { store } from "src/redux/store";
import UpdateProjectContextProvider from './updateProjectContext'
import { useNavigate } from 'react-router-dom'
import { createRoute } from 'src/utils/routing'
interface Props {
@@ -16,13 +20,7 @@ interface Props {
}
export type IListProjectForm = Override<UpdateProjectInput, {
members: NestedValue<{
id: number,
name: string,
jobTitle: string | null,
avatar: string,
role: Team_Member_Role,
}[]>
members: NestedValue<ProjectMember[]>
capabilities: NestedValue<UpdateProjectInput['capabilities']>
recruit_roles: NestedValue<UpdateProjectInput['recruit_roles']>
tournaments: NestedValue<UpdateProjectInput['tournaments']>
@@ -30,6 +28,14 @@ export type IListProjectForm = Override<UpdateProjectInput, {
thumbnail_image: NestedValue<UpdateProjectInput['thumbnail_image']>
}>
export type ProjectMember = {
id: number,
name: string,
jobTitle: string | null,
avatar: string,
role: Team_Member_Role,
}
const schema: yup.SchemaOf<IListProjectForm> = yup.object({
id: yup.number().optional(),
title: yup.string().trim().required().min(2),
@@ -85,7 +91,7 @@ export default function FormContainer(props: PropsWithChildren<Props>) {
const id = Number(params.get('id'));
const isUpdating = !Number.isNaN(id);
const navigate = useNavigate()
const methods = useForm<IListProjectForm>({
@@ -103,7 +109,7 @@ export default function FormContainer(props: PropsWithChildren<Props>) {
category_id: undefined,
capabilities: [],
screenshots: [],
members: [],
members: prepareMembers([]),
recruit_roles: [],
launch_status: ProjectLaunchStatusEnum.Wip,
tournaments: [],
@@ -120,30 +126,33 @@ export default function FormContainer(props: PropsWithChildren<Props>) {
onCompleted: (res) => {
if (res.getProject) {
const data = res.getProject
methods.reset({
id: data.id,
title: data.title,
cover_image: { url: data.cover_image },
thumbnail_image: { url: data.thumbnail_image },
tagline: data.tagline,
website: data.website,
description: data.description,
hashtag: data.hashtag,
twitter: data.twitter,
discord: data.discord,
slack: data.slack,
telegram: data.telegram,
github: data.github,
category_id: data.category.id,
capabilities: data.capabilities.map(c => c.id),
screenshots: data.screenshots.map(url => ({ url })),
if (!res.getProject.permissions.includes(ProjectPermissionEnum.UpdateInfo))
navigate({ pathname: createRoute({ type: "projects-page" }) })
else
methods.reset({
id: data.id,
title: data.title,
cover_image: { url: data.cover_image },
thumbnail_image: { url: data.thumbnail_image },
tagline: data.tagline,
website: data.website,
description: data.description,
hashtag: data.hashtag,
twitter: data.twitter,
discord: data.discord,
slack: data.slack,
telegram: data.telegram,
github: data.github,
category_id: data.category.id,
capabilities: data.capabilities.map(c => c.id),
screenshots: data.screenshots.map(url => ({ url })),
members: data.members.map(({ role, user }) => ({ role, id: user.id, avatar: user.avatar, name: user.name, jobTitle: user.jobTitle })),
recruit_roles: data.recruit_roles.map(r => r.id),
members: prepareMembers(data.members),
recruit_roles: data.recruit_roles.map(r => r.id),
tournaments: [],
launch_status: data.launch_status,
})
tournaments: [],
launch_status: data.launch_status,
})
}
}
})
@@ -159,9 +168,39 @@ export default function FormContainer(props: PropsWithChildren<Props>) {
return (
<FormProvider {...methods} >
<form onSubmit={methods.handleSubmit(onSubmit)}>
{props.children}
</form>
<UpdateProjectContextProvider permissions={query.data?.getProject.permissions ?? Object.values(ProjectPermissionEnum)}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
{props.children}
</form>
</UpdateProjectContextProvider>
</FormProvider>
)
}
function prepareMembers(members: ProjectDetailsQuery['getProject']['members']): ProjectMember[] {
const me = store.getState().user.me;
if (!me) {
window.location.href = '/login';
return [];
}
if (members.length === 0)
return [{
id: me.id,
avatar: me.avatar,
name: me.name,
jobTitle: me.jobTitle,
role: Team_Member_Role.Owner,
}]
const _members = members.map(({ role, user }) => ({ role, id: user.id, avatar: user.avatar, name: user.name, jobTitle: user.jobTitle }))
const myMember = _members.find(m => m.id === me.id);
if (!myMember) throw new Error("Not a member of the project")
return [myMember, ..._members.filter(m => m.id !== me.id)]
}

View File

@@ -0,0 +1,31 @@
import React, { PropsWithChildren } from 'react'
import { ProjectDetailsQuery } from 'src/graphql'
interface Props {
permissions: ProjectDetailsQuery['getProject']['permissions']
}
interface State {
permissions: ProjectDetailsQuery['getProject']['permissions']
}
const context = React.createContext<State | undefined>(undefined)
const UpdateProjectContextProvider = React.memo(function (props: PropsWithChildren<Props>) {
return (
<context.Provider value={{ permissions: props.permissions }}>
{props.children}
</context.Provider>
)
})
export default UpdateProjectContextProvider;
export const useUpdateProjectContext = () => {
const res = React.useContext(context);
if (!res) throw new Error("No context provider was found for useUpdateProjectContext")
return res;
}

View File

@@ -0,0 +1,56 @@
import { Menu, MenuButton, MenuItem } from '@szhsin/react-menu';
import { ComponentProps } from 'react'
import { NestedValue } from 'react-hook-form'
import { FaChevronDown, FaRegTrashAlt, } from 'react-icons/fa';
import UsersInput from 'src/Components/Inputs/UsersInput/UsersInput'
import Avatar from 'src/features/Profiles/Components/Avatar/Avatar';
import { Team_Member_Role } from 'src/graphql';
import { Value } from './TeamMembersInput'
interface Props {
user: Value[number]
onRemove: () => void;
onUpdateRole: (role: Team_Member_Role) => void
disabled?: boolean;
canUpdateRole?: boolean;
canDelete?: boolean;
}
export default function MemberRow({ user, onRemove, onUpdateRole, disabled, canUpdateRole, canDelete }: Props) {
return (
<div
key={user.id}
className="border-b py-16 last-of-type:border-b-0 flex gap-16 items-center">
<Avatar width={40} src={user.avatar} />
<div className='grow overflow-hidden'>
<p className="font-medium self-center overflow-hidden text-ellipsis whitespace-nowrap">
{user.name}
</p>
<p className="text-body5 text-gray-500 overflow-hidden text-ellipsis whitespace-nowrap">
{user.jobTitle}
</p>
</div>
<div className="ml-auto flex gap-12 md:gap-16 shrink-0">
{canUpdateRole ? <Menu
offsetY={12}
align='end'
menuButton={<MenuButton className='border border-gray-200 p-8 rounded-8 text-gray-500'>{user.role} <FaChevronDown className='ml-4 text-gray-400' /></MenuButton>} transition>
{[Team_Member_Role.Admin, Team_Member_Role.Maker].map(role =>
<MenuItem
onClick={() => onUpdateRole(role)}
key={role}>{role}</MenuItem>
)}
</Menu>
:
<span className="text-gray-500">{user.role}</span>
}
{canDelete && <button onClick={() => onRemove()} className=''>
<FaRegTrashAlt className='text-red-400' />
</button>}
</div>
</div>
)
}

View File

@@ -1,13 +1,13 @@
import { Menu, MenuButton, MenuItem } from '@szhsin/react-menu';
import { ComponentProps } from 'react'
import { NestedValue } from 'react-hook-form'
import { FaChevronDown, FaRegTrashAlt, } from 'react-icons/fa';
import UsersInput from 'src/Components/Inputs/UsersInput/UsersInput'
import Avatar from 'src/features/Profiles/Components/Avatar/Avatar';
import { Team_Member_Role } from 'src/graphql';
import { ProjectPermissionEnum, Team_Member_Role } from 'src/graphql';
import { IListProjectForm } from '../FormContainer/FormContainer';
import { useUpdateProjectContext } from '../FormContainer/updateProjectContext';
import MemberRow from './MemberRow';
type Value = IListProjectForm['members'] extends NestedValue<infer U> ? U : never;
export type Value = IListProjectForm['members'] extends NestedValue<infer U> ? U : never;
type Props = {
value: Value,
@@ -16,6 +16,13 @@ type Props = {
export default function TeamMembersInput({ value, onChange = () => { } }: Props) {
const { permissions } = useUpdateProjectContext()
const canAddNew = permissions.includes(ProjectPermissionEnum.UpdateAdmins)
const canUpdateMembers = permissions.includes(ProjectPermissionEnum.UpdateMembers)
const canUpdateAdmins = permissions.includes(ProjectPermissionEnum.UpdateAdmins)
const addMember: ComponentProps<typeof UsersInput>['onSelect'] = (user) => {
if (value.some(u => u.id === user.id))
return;
@@ -40,45 +47,30 @@ export default function TeamMembersInput({ value, onChange = () => { } }: Props)
}))
}
const removeMemeber = (id: number) => {
const removeMember = (id: number) => {
onChange(value.filter(u => u.id !== id));
}
return (
<>
<UsersInput onSelect={addMember} />
{canAddNew && <UsersInput onSelect={addMember} />}
{value.length > 0 &&
<div className='flex flex-col mt-24'>
{value.map(user => {
return <div
key={user.id}
className="border-b py-16 last-of-type:border-b-0 flex gap-16 items-center">
{value.map(member => {
<Avatar width={40} src={user.avatar} />
<div className='grow overflow-hidden'>
<p className="font-medium self-center overflow-hidden text-ellipsis whitespace-nowrap">
{user.name}
</p>
<p className="text-body5 text-gray-500 overflow-hidden text-ellipsis whitespace-nowrap">
{user.jobTitle}
</p>
</div>
<div className="ml-auto flex gap-12 md:gap-16 shrink-0">
<Menu
offsetY={12}
align='end'
menuButton={<MenuButton className='border border-gray-200 p-8 rounded-8 text-gray-500'>{user.role} <FaChevronDown className='ml-4 text-gray-400' /></MenuButton>} transition>
{Object.values(Team_Member_Role).map(role =>
<MenuItem
onClick={() => setMemberRole(user.id, role)}
key={role}>{role}</MenuItem>
)}
</Menu>
<button onClick={() => removeMemeber(user.id)} className=''>
<FaRegTrashAlt className='text-red-400' />
</button>
</div>
</div>
let canEdit = false;
if (member.role === Team_Member_Role.Admin) canEdit = canUpdateAdmins;
if (member.role === Team_Member_Role.Maker) canEdit = canUpdateMembers;
return <MemberRow
key={member.id}
user={member}
canUpdateRole={canEdit}
canDelete={canEdit}
onRemove={() => removeMember(member.id)}
onUpdateRole={role => setMemberRole(member.id, role)}
/>
})}
</div>}
</>

View File

@@ -8,7 +8,7 @@ import { IListProjectForm } from "../FormContainer/FormContainer";
interface Props {
}
export default function TeamTab(props: Props) {
export default function TeamTab() {
const { formState: { errors, }, control } = useFormContext<IListProjectForm>();
@@ -16,8 +16,6 @@ export default function TeamTab(props: Props) {
return (
<div className="flex flex-col gap-24">
<Card >
<h2 className="text-body2 font-bolder"> Team</h2>
<p className="text-body4 font-light text-gray-600 mt-8">Let us know who is on this products team.</p>
@@ -29,6 +27,7 @@ export default function TeamTab(props: Props) {
<TeamMembersInput
value={value}
onChange={onChange}
/>
)}
/>

View File

@@ -2,12 +2,15 @@ import { useCarousel, useMediaQuery, } from "src/utils/hooks";
import { Helmet } from 'react-helmet'
import { MEDIA_QUERIES } from "src/utils/theme";
import Card from "src/Components/Card/Card";
import LoadingPage from "src/Components/LoadingPage/LoadingPage";
import ProjectDetailsTab from "./Components/ProjectDetailsTab/ProjectDetailsTab";
import TeamTab from "./Components/TeamTab/TeamTab";
import ExtrasTab from "./Components/ExtrasTab/ExtrasTab";
import FormContainer from "./Components/FormContainer/FormContainer";
import { useState } from "react";
import SaveChangesCard from "./Components/SaveChangesCard/SaveChangesCard";
import { useMeQuery } from "src/graphql";
import { Navigate, useLocation } from 'react-router-dom'
export const tabs = {
@@ -38,8 +41,15 @@ export default function ListProjectPage() {
align: 'start', slidesToScroll: 2,
containScroll: "trimSnaps",
})
const location = useLocation()
const meQuery = useMeQuery({
});
if (meQuery.loading) return <LoadingPage />
if (meQuery.error || !meQuery.data?.me) return <Navigate to={'/login'} state={{ from: location.pathname }} />
return (
<>

View File

@@ -146,9 +146,10 @@ export default function ProjectDetailsCard({ direction, projectId, ...props }: P
</div>
<div className="p-24 flex flex-col gap-24">
<div className='flex justify-end'>
<Button color='gray' size='sm' className='ml-auto' onClick={() => props.onClose?.()} href={createRoute({ type: "edit-project", id: project.id })}>Edit Project</Button>
</div>
{project.permissions.includes(ProjectPermissionEnum.UpdateInfo) &&
<div className='flex justify-end'>
<Button color='gray' size='sm' className='ml-auto' onClick={() => props.onClose?.()} href={createRoute({ type: "edit-project", id: project.id })}>Edit Project</Button>
</div>}
{/* Title & Basic Info */}
<div className="flex flex-col mt-[-80px] md:flex-row md:mt-0 gap-24 items-start relative">

View File

@@ -35,6 +35,9 @@ type RouteOptions =
id: string | number,
username?: string,
}
| {
type: "projects-page"
}
| {
type: "edit-project",
id?: number,
@@ -82,6 +85,9 @@ export function createRoute(options: RouteOptions) {
if (options.type === 'tournament')
return `/tournaments/${options.id}` + (options.tab ? `/${options.tab}` : "")
if (options.type === 'projects-page')
return '/projects'
if (options.type === 'edit-project')
return `/projects/list-project` + (options.id ? `?id=${options.id}` : '')