mirror of
https://github.com/aljazceru/landscape-template.git
synced 2026-01-25 09:14:34 +01:00
update: members permissions UIs
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>}
|
||||
</>
|
||||
|
||||
@@ -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 product’s team.</p>
|
||||
@@ -29,6 +27,7 @@ export default function TeamTab(props: Props) {
|
||||
<TeamMembersInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}` : '')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user