diff --git a/web/sdk/react/components/organization/project/add.tsx b/web/sdk/react/components/organization/project/add.tsx deleted file mode 100644 index a93b246dd..000000000 --- a/web/sdk/react/components/organization/project/add.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useEffect } from 'react'; -import { - Button, - toast, - Image, - Text, - Flex, - Dialog, - InputField -} from '@raystack/apsara'; -import * as yup from 'yup'; - -import { yupResolver } from '@hookform/resolvers/yup'; -import { useNavigate } from '@tanstack/react-router'; -import { useForm } from 'react-hook-form'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import { useMutation } from '@connectrpc/connect-query'; -import { - FrontierServiceQueries, - CreateProjectRequestSchema -} from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import cross from '~/react/assets/cross.svg'; -import styles from '../organization.module.css'; -import slugify from 'slugify'; -import { generateHashFromString } from '~/react/utils'; -import { ConnectError, Code } from '@connectrpc/connect'; - -const projectSchema = yup - .object({ - title: yup.string().required(), - org_id: yup.string().required() - }) - .required(); - -type FormData = yup.InferType; - -export const AddProject = () => { - const { - reset, - handleSubmit, - setError, - formState: { errors, isSubmitting }, - register - } = useForm({ - resolver: yupResolver(projectSchema) - }); - const navigate = useNavigate({ from: '/projects/modal' }); - const { activeOrganization: organization } = useFrontier(); - - useEffect(() => { - reset({ org_id: organization?.id }); - }, [organization, reset]); - - const { mutateAsync: createProject } = useMutation( - FrontierServiceQueries.createProject, - { - onSuccess: () => { - toast.success('Project added'); - navigate({ to: '/projects' }); - } - } - ); - - async function onSubmit(data: FormData) { - if (!organization?.id) return; - const slug = slugify(data.title, { lower: true, strict: true }); - const suffix = generateHashFromString(organization.id); - const name = `${slug}-${suffix}`; - try { - await createProject( - create(CreateProjectRequestSchema, { - body: { - title: data.title, - name, - orgId: organization.id - } - }) - ); - } catch (error) { - if (error instanceof ConnectError && error.code === Code.AlreadyExists) { - setError('title', { - message: - 'A project with a similar title already exist. Please tweak the title and try again.' - }); - } else { - toast.error('Something went wrong', { - description: - error instanceof Error ? error.message : 'Failed to create project' - }); - } - } - } - - return ( - - - - - - Add Project - - cross navigate({ to: '/projects' })} - data-test-id="frontier-sdk-new-project-close-btn" - style={{ cursor: 'pointer' }} - /> - - -
- - -
- -
- -
-
- - - - - -
-
-
- ); -}; diff --git a/web/sdk/react/components/organization/project/delete.tsx b/web/sdk/react/components/organization/project/delete.tsx deleted file mode 100644 index 7a71efd7d..000000000 --- a/web/sdk/react/components/organization/project/delete.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { - Button, - Checkbox, - toast, - Skeleton, - Image, - Text, - Flex, - Dialog, - InputField -} from '@raystack/apsara'; - -import { yupResolver } from '@hookform/resolvers/yup'; -import { useNavigate, useParams } from '@tanstack/react-router'; -import { useEffect, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import * as yup from 'yup'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import { useQuery, useMutation } from '@connectrpc/connect-query'; -import { FrontierServiceQueries, GetProjectRequestSchema, DeleteProjectRequestSchema } from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import cross from '~/react/assets/cross.svg'; -import styles from '../organization.module.css'; - -const projectSchema = yup - .object({ - title: yup.string() - }) - .required(); - -export const DeleteProject = () => { - const { - watch, - setError, - handleSubmit, - formState: { errors, isSubmitting }, - register - } = useForm({ - resolver: yupResolver(projectSchema) - }); - let { projectId } = useParams({ from: '/projects/$projectId/delete' }); - const navigate = useNavigate({ from: '/projects/$projectId/delete' }); - const { activeOrganization: organization } = useFrontier(); - const [isAcknowledged, setIsAcknowledged] = useState(false); - - const { - data: project, - isLoading: isProjectQueryLoading, - error: projectError - } = useQuery( - FrontierServiceQueries.getProject, - create(GetProjectRequestSchema, { id: projectId || '' }), - { - enabled: !!projectId, - select: (d) => d?.project - } - ); - - useEffect(() => { - if (projectError) { - toast.error('Something went wrong', { description: projectError.message }); - } - }, [projectError]); - - const { mutateAsync: deleteProject } = useMutation( - FrontierServiceQueries.deleteProject, - { - onSuccess: () => { - toast.success('project deleted'); - navigate({ to: '/projects' }); - }, - onError: (err: Error) => - toast.error('Something went wrong', { description: err.message }) - } - ); - - async function onSubmit(data: { title?: string }) { - if (!organization?.id || !projectId) return; - if (data.title !== project?.title) - return setError('title', { message: 'Project title does not match' }); - await deleteProject(create(DeleteProjectRequestSchema, { id: projectId })); - } - - const title = watch('title', ''); - return ( - - - - - - Verify project deletion - - cross - navigate({ to: '/projects/$projectId', params: { projectId } }) - } - style={{ cursor: 'pointer' }} - data-test-id="frontier-sdk-delete-project-close-btn" - /> - - - -
- - {isProjectQueryLoading ? ( - <> - - - - - - - ) : ( - <> - - This action can not be undone. This will permanently delete - project {project?.title}. - - - - - - setIsAcknowledged(v === true)} - data-test-id="frontier-sdk-delete-project-checkbox" - /> - - I acknowledge and understand that all of the project data will be deleted - and want to proceed. - - - - - )} - -
-
-
-
- ); -}; diff --git a/web/sdk/react/components/organization/project/general/index.tsx b/web/sdk/react/components/organization/project/general/index.tsx deleted file mode 100644 index 2f5a5a54f..000000000 --- a/web/sdk/react/components/organization/project/general/index.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { - Button, - Separator, - toast, - Tooltip, - Skeleton, - Text, - Flex, - InputField -} from '@raystack/apsara'; - -import { yupResolver } from '@hookform/resolvers/yup'; -import { useNavigate, useParams } from '@tanstack/react-router'; -import { useEffect, useMemo } from 'react'; -import { useForm } from 'react-hook-form'; -import * as yup from 'yup'; -import { usePermissions } from '~/react/hooks/usePermissions'; -import { useMutation } from '@connectrpc/connect-query'; -import { FrontierServiceQueries, UpdateProjectRequestSchema, Project, Organization } from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import { PERMISSIONS, shouldShowComponent } from '~/utils'; -import { AuthTooltipMessage } from '~/react/utils'; - -const projectSchema = yup - .object({ - title: yup.string().required(), - name: yup.string().required() - }) - .required(); - -type FormData = yup.InferType; - -interface GeneralProjectProps { - project?: Project; - organization?: Organization; - isLoading?: boolean; -} - -export const General = ({ - organization, - project, - isLoading: isProjectLoading -}: GeneralProjectProps) => { - const { - reset, - handleSubmit, - formState: { errors, isSubmitting }, - register - } = useForm({ - resolver: yupResolver(projectSchema) - }); - let { projectId } = useParams({ from: '/projects/$projectId' }); - const { mutateAsync: updateProject } = useMutation(FrontierServiceQueries.updateProject, { - onSuccess: () => toast.success('Project updated successfully'), - onError: (error: Error) => - toast.error('Something went wrong', { description: error.message }) - }); - - useEffect(() => { - reset(project); - }, [reset, project]); - - const resource = `app/project:${projectId}`; - const listOfPermissionsToCheck = useMemo( - () => [ - { - permission: PERMISSIONS.UpdatePermission, - resource - }, - { - permission: PERMISSIONS.DeletePermission, - resource - } - ], - [resource] - ); - - const { permissions, isFetching: isPermissionsFetching } = usePermissions( - listOfPermissionsToCheck, - !!projectId - ); - - const { canUpdateProject, canDeleteProject } = useMemo(() => { - return { - canUpdateProject: shouldShowComponent( - permissions, - `${PERMISSIONS.UpdatePermission}::${resource}` - ), - canDeleteProject: shouldShowComponent( - permissions, - `${PERMISSIONS.DeletePermission}::${resource}` - ) - }; - }, [permissions, resource]); - - async function onSubmit(data: FormData) { - if (!organization?.id) return; - if (!projectId) return; - - await updateProject( - create(UpdateProjectRequestSchema, { - id: projectId, - body: { - name: data.name, - title: data.title, - orgId: organization.id - } - }) - ); - } - - const isLoading = isPermissionsFetching || isProjectLoading; - - return ( - -
- - {isLoading ? ( -
- - -
- ) : ( - - )} - {isLoading ? ( -
- - -
- ) : ( - - )} - {isLoading ? ( - - ) : ( - - - - )} -
-
- - - -
- ); -}; - -interface GeneralDeleteProjectProps extends GeneralProjectProps { - canDeleteProject?: boolean; -} - -export const GeneralDeleteProject = ({ - canDeleteProject, - isLoading -}: GeneralDeleteProjectProps) => { - let { projectId } = useParams({ from: '/projects/$projectId' }); - const navigate = useNavigate({ from: '/projects/$projectId' }); - - return ( - - {isLoading ? ( - - ) : ( - - If you want to permanently delete this project and all of its data. - - )}{' '} - {isLoading ? ( - - ) : ( - - - - )} - - ); -}; diff --git a/web/sdk/react/components/organization/project/index.tsx b/web/sdk/react/components/organization/project/index.tsx index 4e5c1dfc5..95cdf0a80 100644 --- a/web/sdk/react/components/organization/project/index.tsx +++ b/web/sdk/react/components/organization/project/index.tsx @@ -1,243 +1,16 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { - Tooltip, - EmptyState, - Skeleton, - Flex, - Button, - Select, - DataTable, - toast -} from '@raystack/apsara'; -import { Outlet, useNavigate, useRouterState } from '@tanstack/react-router'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import { useOrganizationProjects } from '~/react/hooks/useOrganizationProjects'; -import { usePermissions } from '~/react/hooks/usePermissions'; -import { AuthTooltipMessage } from '~/react/utils'; -import { PERMISSIONS, shouldShowComponent } from '~/utils'; -import { getColumns } from './projects.columns'; -import { Project } from '@raystack/proton/frontier'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; -import { PageHeader } from '~/react/components/common/page-header'; -import sharedStyles from '../styles.module.css'; -import styles from './project.module.css'; -import { useTerminology } from '~/react/hooks/useTerminology'; - -const projectsSelectOptions = [ - { value: 'my-projects', label: 'My Projects' }, - { value: 'all-projects', label: 'All Projects' } -]; +import { useNavigate } from '@tanstack/react-router'; +import { ProjectsListPage } from '~/react/views/projects'; export default function WorkspaceProjects() { - const [showOrgProjects, setShowOrgProjects] = useState(false); - const { - isFetching: isProjectsLoading, - projects, - userAccessOnProject, - refetch, - error: projectsError - } = useOrganizationProjects({ - allProjects: showOrgProjects, - withMemberCount: true - }); - const { activeOrganization: organization } = useFrontier(); - const t = useTerminology(); - const routerState = useRouterState(); - - const isListRoute = useMemo(() => { - return routerState.location.pathname === '/projects'; - }, [routerState.location.pathname]); - - const resource = `app/organization:${organization?.id}`; - const listOfPermissionsToCheck = useMemo( - () => [ - { - permission: PERMISSIONS.ProjectCreatePermission, - resource - }, - { - permission: PERMISSIONS.UpdatePermission, - resource - } - ], - [resource] - ); - - const { permissions, isFetching: isPermissionsFetching } = usePermissions( - listOfPermissionsToCheck, - !!organization?.id - ); - - const { canCreateProject, canUpdateOrganization } = useMemo(() => { - return { - canCreateProject: shouldShowComponent( - permissions, - `${PERMISSIONS.ProjectCreatePermission}::${resource}` - ), - canUpdateOrganization: shouldShowComponent( - permissions, - `${PERMISSIONS.UpdatePermission}::${resource}` - ) - }; - }, [permissions, resource]); - - const onOrgProjectsFilterChange = useCallback((value: string) => { - if (value === 'all-projects') { - setShowOrgProjects(true); - } else { - setShowOrgProjects(false); - } - }, []); - - useEffect(() => { - if (isListRoute) { - refetch(); - } - }, [isListRoute, refetch, routerState.location.state.key]); - - useEffect(() => { - if (projectsError) { - toast.error('Something went wrong', { - description: projectsError.message - }); - } - }, [projectsError]); - - const isLoading = isPermissionsFetching || isProjectsLoading; - - return ( - - - - - - - - - - - - ); -} - -interface WorkspaceProjectsProps { - projects: Project[]; - isLoading?: boolean; - canCreateProject?: boolean; - userAccessOnProject: Record; - canListOrgProjects?: boolean; - onOrgProjectsFilterChange?: (value: string) => void; -} - -const ProjectsTable = ({ - projects, - isLoading, - canCreateProject, - userAccessOnProject, - canListOrgProjects, - onOrgProjectsFilterChange -}: WorkspaceProjectsProps) => { const navigate = useNavigate({ from: '/projects' }); - const columns = useMemo( - () => getColumns(userAccessOnProject), - [userAccessOnProject] - ); return ( - - - - - {isLoading ? ( - - ) : ( - - )} - {canListOrgProjects ? ( - - ) : null} - - {isLoading ? ( - - ) : ( - - - - )} - - - - + + navigate({ to: '/projects/$projectId', params: { projectId } }) + } + /> ); -}; - -const noDataChildren = ( - } - heading="No projects found" - subHeading="Get started by creating your first project." - /> -); +} diff --git a/web/sdk/react/components/organization/project/members/index.tsx b/web/sdk/react/components/organization/project/members/index.tsx deleted file mode 100644 index d9af1731c..000000000 --- a/web/sdk/react/components/organization/project/members/index.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import { - CardStackPlusIcon, - PlusIcon, - ExclamationTriangleIcon -} from '@radix-ui/react-icons'; -import type React from 'react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { - Button, - EmptyState, - Tooltip, - toast, - Separator, - Avatar, - Skeleton, - Text, - Flex, - DataTable, - Popover, - Search -} from '@raystack/apsara'; -import { useParams } from '@tanstack/react-router'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import { useOrganizationTeams } from '~/react/hooks/useOrganizationTeams'; -import { usePermissions } from '~/react/hooks/usePermissions'; -import { AuthTooltipMessage } from '~/react/utils'; -import { - PERMISSIONS, - filterUsersfromUsers, - getInitials, - shouldShowComponent -} from '~/utils'; -import { getColumns } from './member.columns'; -import { useQuery, useMutation } from '@connectrpc/connect-query'; -import { FrontierServiceQueries, - ListOrganizationUsersRequestSchema, - CreatePolicyForProjectRequestSchema, - type Group, - type User, - type Role, -} from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import styles from './members.module.css'; - -export type MembersProps = { - teams?: Group[]; - members?: User[]; - roles?: Role[]; - memberRoles?: Record; - groupRoles?: Record; - isLoading?: boolean; - refetch: () => void; -}; - -export const Members = ({ - teams = [], - members = [], - roles = [], - memberRoles, - groupRoles, - isLoading: isMemberLoading, - refetch -}: MembersProps) => { - const { projectId } = useParams({ from: '/projects/$projectId' }); - - const resource = `app/project:${projectId}`; - const listOfPermissionsToCheck = useMemo( - () => [ - { - permission: PERMISSIONS.UpdatePermission, - resource - } - ], - [resource] - ); - - const { permissions, isFetching: isPermissionsFetching } = usePermissions( - listOfPermissionsToCheck, - !!projectId - ); - - const { canUpdateProject } = useMemo(() => { - return { - canUpdateProject: shouldShowComponent( - permissions, - `${PERMISSIONS.UpdatePermission}::${resource}` - ) - }; - }, [permissions, resource]); - - const isLoading = isMemberLoading || isPermissionsFetching; - - const columns = useMemo( - () => - getColumns( - memberRoles, - groupRoles, - roles, - canUpdateProject, - projectId, - refetch - ), - [memberRoles, groupRoles, roles, canUpdateProject, projectId, refetch] - ); - - const updatedUsers = useMemo(() => { - const updatedTeams = teams.map(t => ({ ...t, isTeam: true })); - return members?.length || updatedTeams?.length - ? [...updatedTeams, ...members] - : []; - }, [members, teams]); - - return ( - - - - - - - - {isLoading ? ( - - ) : ( - - - - )} - - - - - - ); -}; - -interface AddMemberDropdownProps { - canUpdateProject: boolean; - members?: User[]; - refetch?: () => void; -} - -const AddMemberDropdown = ({ - canUpdateProject, - members, - refetch -}: AddMemberDropdownProps) => { - const { projectId } = useParams({ from: '/projects/$projectId' }); - const [query, setQuery] = useState(''); - const [showTeam, setShowTeam] = useState(false); - - const { activeOrganization: organization } = useFrontier(); - const { isFetching: isTeamsLoading, teams } = useOrganizationTeams({}); - - const toggleShowTeam = (e: React.MouseEvent) => { - e.preventDefault(); - setQuery(''); - setShowTeam(prev => !prev); - }; - - const { data: orgUsersData, isLoading: isOrgUsersLoading, error: orgUsersError } = useQuery( - FrontierServiceQueries.listOrganizationUsers, - create(ListOrganizationUsersRequestSchema, { id: organization?.id || '' }), - { enabled: !!organization?.id && canUpdateProject } - ); - - const orgUsersResp = useMemo(() => orgUsersData?.users ?? [], [orgUsersData]); - - useEffect(() => { - if (orgUsersError) { - toast.error('Something went wrong', { description: orgUsersError.message }); - } - }, [orgUsersError]); - - const invitableUser = useMemo( - () => filterUsersfromUsers(orgUsersResp || [], members) || [], - [orgUsersResp, members] - ); - - const topUsers = useMemo( - () => - invitableUser - .filter(user => - query - ? user.title?.toLowerCase().includes(query.toLowerCase()) || - user.email?.includes(query) - : true - ) - .slice(0, 7), - [invitableUser, query] - ); - - const topTeams = useMemo(() => - teams - .filter(team => - query - ? team.title && team.title.toLowerCase().includes(query.toLowerCase()) - : true - ) - .slice(0, 7), - [query, teams]); - - function onTextChange(e: React.ChangeEvent) { - setQuery(e.target.value); - } - - const { mutate: createPolicyForProject, isPending: isCreatingPolicy } = useMutation( - FrontierServiceQueries.createPolicyForProject, - { - onSuccess: () => { - toast.success('Member added'); - if (refetch) refetch(); - }, - onError: (err: Error) => { - toast.error('Something went wrong', { description: err.message }); - } - } - ); - - const addMember = useCallback( - (userId: string) => { - if (!userId || !organization?.id || !projectId) return; - const principal = `${PERMISSIONS.UserNamespace}:${userId}`; - createPolicyForProject( - create(CreatePolicyForProjectRequestSchema, { - projectId: projectId, - body: { roleId: PERMISSIONS.RoleProjectViewer, principal } - }) - ); - }, - [createPolicyForProject, organization?.id, projectId] - ); - - const addTeam = useCallback( - (teamId: string) => { - if (!teamId || !organization?.id || !projectId) return; - const principal = `${PERMISSIONS.GroupNamespace}:${teamId}`; - createPolicyForProject( - create(CreatePolicyForProjectRequestSchema, { - projectId: projectId, - body: { roleId: PERMISSIONS.RoleProjectViewer, principal } - }) - ); - }, - [createPolicyForProject, organization?.id, projectId] - ); - - return ( - - - - - - setQuery('')} - /> - - - {showTeam ? ( - isTeamsLoading ? ( - - ) : topTeams.length ? ( -
- {topTeams.map((team, i) => { - const initals = getInitials(team?.title || team.name); - return ( - addTeam(team?.id || '')} - className={styles.inviteDropdownItem} - data-test-id={`frontier-sdk-add-team-to-project-dropdown-item-${i}`} - > - - {team?.title || team?.name} - - ); - })} -
- ) : ( - - No Teams found - - ) - ) : isOrgUsersLoading ? ( - - ) : topUsers.length ? ( -
- {topUsers.map((user, i) => { - const initals = getInitials(user?.title || user.email); - return ( - addMember(user?.id || '')} - data-test-id={`frontier-sdk-add-user-to-project-dropdown-item-${i}`} - > - - {user?.title || user?.email} - - ); - })} -
- ) : ( - - No Users found - - )} - - -
- - {showTeam ? ( - <> - {' '} - Add project member - - ) : ( - <> - {' '} - Add team to project - - )} - -
-
-
- ); -}; - -const noDataChildren = ( - } - heading="No members found" - subHeading="Get started by adding your first member." - /> -); diff --git a/web/sdk/react/components/organization/project/members/member.columns.tsx b/web/sdk/react/components/organization/project/members/member.columns.tsx deleted file mode 100644 index c5da07e30..000000000 --- a/web/sdk/react/components/organization/project/members/member.columns.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { useEffect, useMemo } from 'react'; -import { - DotsHorizontalIcon, - TrashIcon, - UpdateIcon -} from '@radix-ui/react-icons'; -import { - Avatar, - Label, - Text, - Flex, - toast, - DropdownMenu, - type DataTableColumnDef, - getAvatarColor -} from '@raystack/apsara'; -import { useNavigate } from '@tanstack/react-router'; -import { useQuery, useMutation } from '@connectrpc/connect-query'; -import { - FrontierServiceQueries, - ListPoliciesRequestSchema, - DeletePolicyRequestSchema, - CreatePolicyRequestSchema, - type Role, - type User, - type Group -} from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import { differenceWith, getInitials, isEqualById } from '~/utils'; - -import teamIcon from '~/react/assets/users.svg'; - -type RowMember = (User & { isTeam?: false }) | (Group & { isTeam: true }); - -export const getColumns = ( - memberRoles: Record = {}, - groupRoles: Record = {}, - roles: Role[] = [], - canUpdateProject: boolean, - projectId: string, - refetch: () => void -): DataTableColumnDef[] => [ - { - header: '', - accessorKey: 'avatar', - enableSorting: false, - styles: { - cell: { - width: 'var(--rs-space-5)' - } - }, - cell: ({ row, getValue }) => { - const avatarSrc = row.original?.isTeam ? teamIcon : getValue(); - const fallback = row.original?.isTeam - ? '' - : getInitials(row.original?.title || row.original?.email); - const color = getAvatarColor(row?.original?.id || ''); - return ( - - ); - } - }, - { - header: 'Title', - accessorKey: 'title', - cell: ({ row, getValue }) => { - const label = row.original?.isTeam - ? row.original.title - : (getValue() as string); - const subLabel = row.original?.isTeam - ? row.original.name - : row.original.email; - - return ( - - - {subLabel} - - ); - } - }, - { - header: 'Roles', - accessorKey: 'email', - cell: ({ row }) => { - return ( - - {row.original?.isTeam - ? // hardcoding roles as we dont have team roles and team are invited as viewer and we dont allow role change - (row.original?.id && - groupRoles[row.original?.id] && - groupRoles[row.original?.id] - .map((r: Role) => r.title || r.name) - .join(', ')) ?? - 'Project Viewer' - : (row.original?.id && - memberRoles[row.original?.id] && - memberRoles[row.original?.id] - .map((r: Role) => r.title || r.name) - .join(', ')) ?? - 'Inherited role'} - - ); - } - }, - { - header: '', - accessorKey: 'id', - enableSorting: false, - cell: ({ row }) => ( - ( - isEqualById, - roles, - row.original.isTeam - ? row.original?.id && groupRoles[row.original?.id] - ? groupRoles[row.original?.id] - : [] - : row.original?.id && memberRoles[row.original?.id] - ? memberRoles[row.original?.id] - : [] - )} - /> - ) - } -]; - -const MembersActions = ({ - projectId, - member, - canUpdateProject, - excludedRoles = [], - refetch = () => null -}: { - projectId: string; - member: RowMember; - canUpdateProject?: boolean; - excludedRoles: Role[]; - refetch: () => void; -}) => { - const navigate = useNavigate({ from: '/projects' }); - - function removeMember() { - navigate({ - to: '/projects/$projectId/$membertype/$memberId/remove', - params: { - projectId: projectId, - membertype: member?.isTeam ? 'team' : 'user', - memberId: member?.id as string - } - }); - } - - const { data: policiesData, refetch: refetchPolicies, error: policiesError } = useQuery( - FrontierServiceQueries.listPolicies, - create(ListPoliciesRequestSchema, { - projectId: projectId, - userId: member.isTeam ? undefined : (member.id as string), - groupId: member.isTeam ? (member.id as string) : undefined - }), - { enabled: !!projectId && !!member?.id } - ); - - const policies = useMemo(() => policiesData?.policies ?? [], [policiesData]); - - const { mutateAsync: deletePolicy } = useMutation(FrontierServiceQueries.deletePolicy, { - onError: (err: Error) => - toast.error('Something went wrong', { description: err.message }) - }); - const { mutateAsync: createPolicy } = useMutation(FrontierServiceQueries.createPolicy, { - onError: (err: Error) => - toast.error('Something went wrong', { description: err.message }) - }); - - useEffect(() => { - if (policiesError) { - toast.error('Something went wrong', { description: (policiesError as Error).message }); - } - }, [policiesError]); - - async function updateRole(role: Role) { - try { - const resource = `app/project:${projectId}`; - const principal = member.isTeam - ? `app/group:${member?.id}` - : `app/user:${member?.id}`; - - await Promise.all( - (policies || []).map(p => - deletePolicy(create(DeletePolicyRequestSchema, { id: p.id || '' })) - ) - ); - - await createPolicy( - create(CreatePolicyRequestSchema, { - body: { - roleId: role.id as string, - title: (role.title || role.name) as string, - resource, - principal - } - }) - ); - await refetchPolicies(); - refetch(); - toast.success('Project member role updated'); - } catch (err) { - const message = (err as Error)?.message || 'Failed to update role'; - toast.error('Something went wrong', { description: message }); - } - } - - return canUpdateProject ? ( - - - - - {/* @ts-ignore */} - - - {excludedRoles.map((role: Role) => ( - updateRole(role)} - data-test-id="frontier-sdk-update-project-member-role-btn" - > - - Make {role.title} - - ))} - removeMember()} - > - - Remove from project - - - - - ) : null; -}; diff --git a/web/sdk/react/components/organization/project/members/members.module.css b/web/sdk/react/components/organization/project/members/members.module.css deleted file mode 100644 index a5ecc9574..000000000 --- a/web/sdk/react/components/organization/project/members/members.module.css +++ /dev/null @@ -1,46 +0,0 @@ -.inviteDropdownItem { - padding: var(--rs-space-3); - user-select: none; - cursor: pointer; - background-color: var(--rs-color-background-base-primary); - color: var(--rs-color-foreground-base-primary); -} - -.inviteDropdownItem:hover { - background-color: var(--rs-color-background-base-primary-hover); -} - -.popoverContent { - padding: 0; - min-width: 300px; - pointer-events: auto; -} - -.tableWrapper { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - overflow: hidden; -} - -.tableRoot { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - min-height: 0; -} - -.tableHeader { - z-index: 1; -} - -.tableSearchWrapper { - max-width: 50%; - flex: 1; -} - -.container { - margin-top: var(--rs-space-5); - height: calc(100% - var(--rs-space-5)); -} diff --git a/web/sdk/react/components/organization/project/members/remove.tsx b/web/sdk/react/components/organization/project/members/remove.tsx deleted file mode 100644 index 1b8acdfa9..000000000 --- a/web/sdk/react/components/organization/project/members/remove.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { Button, Flex, Text, toast, Image, Dialog } from '@raystack/apsara'; -import { useNavigate, useParams } from '@tanstack/react-router'; -import { useEffect, useMemo } from 'react'; -import { useQuery, useMutation, createConnectQueryKey, useTransport } from '@connectrpc/connect-query'; -import { useQueryClient } from '@tanstack/react-query'; -import { FrontierServiceQueries, ListPoliciesRequestSchema, DeletePolicyRequestSchema, ListProjectUsersRequestSchema, ListProjectGroupsRequestSchema } from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import cross from '~/react/assets/cross.svg'; -import styles from '../../organization.module.css'; - -export const RemoveProjectMember = () => { - const navigate = useNavigate({ - from: '/projects/$projectId/$membertype/$memberId/remove' - }); - const queryClient = useQueryClient(); - const transport = useTransport(); - - const { projectId, memberId } = useParams({ - from: '/projects/$projectId/$membertype/$memberId/remove' - }); - - const { data: policiesData, error: policiesError } = useQuery( - FrontierServiceQueries.listPolicies, - create(ListPoliciesRequestSchema, { - projectId: projectId || '', - userId: memberId || '' - }), - { enabled: !!projectId && !!memberId } - ); - - const policies = useMemo(() => policiesData?.policies ?? [], [policiesData]); - - useEffect(() => { - if (policiesError) { - toast.error('Something went wrong', { description: (policiesError as Error).message }); - } - }, [policiesError]); - - const { mutateAsync: deletePolicy, isPending } = useMutation(FrontierServiceQueries.deletePolicy, { - onError: (err: Error) => - toast.error('Something went wrong', { description: err.message }) - }); - - async function onConfirm() { - try { - await Promise.all( - (policies || []).map(p => deletePolicy(create(DeletePolicyRequestSchema, { id: p.id || '' }))) - ); - // Invalidate and refetch project users and groups queries after all policies are deleted - if (projectId) { - await queryClient.invalidateQueries({ - queryKey: createConnectQueryKey({ - schema: FrontierServiceQueries.listProjectUsers, - transport, - input: create(ListProjectUsersRequestSchema, { - id: projectId, - withRoles: true - }), - cardinality: 'finite' - }) - }); - await queryClient.invalidateQueries({ - queryKey: createConnectQueryKey({ - schema: FrontierServiceQueries.listProjectGroups, - transport, - input: create(ListProjectGroupsRequestSchema, { - id: projectId, - withRoles: true - }), - cardinality: 'finite' - }) - }); - } - navigate({ to: '/projects/$projectId', params: { projectId } }); - toast.success('Member removed'); - } catch (error) { - // Error is already handled by mutation's onError callback - // This catch prevents unhandled promise rejection - console.error('Failed to delete policies:', error); - } - } - - return ( - - - - - - Remove project member - - cross - navigate({ - to: '/projects/$projectId', - params: { projectId } - }) - } - style={{ cursor: 'pointer' }} - /> - - - - - - - Are you sure you want to remove this member from the project? - - - - - - - - - - - - - ); -}; diff --git a/web/sdk/react/components/organization/project/project.module.css b/web/sdk/react/components/organization/project/project.module.css deleted file mode 100644 index ad3613f12..000000000 --- a/web/sdk/react/components/organization/project/project.module.css +++ /dev/null @@ -1,27 +0,0 @@ -.tableWrapper { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - overflow: hidden; -} - -.tableRoot { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - min-height: 0; -} - -.tableHeader { - z-index: 1; -} - -.tableSearchWrapper { - max-width: 500px; - flex: 1; -} - -.tabContent { - height: 100%; -} diff --git a/web/sdk/react/components/organization/project/project.tsx b/web/sdk/react/components/organization/project/project.tsx index 20b51c066..74b066ad3 100644 --- a/web/sdk/react/components/organization/project/project.tsx +++ b/web/sdk/react/components/organization/project/project.tsx @@ -1,207 +1,16 @@ -import { Tabs, Image, toast, Flex } from '@raystack/apsara'; -import { - Outlet, - useNavigate, - useParams, - useRouterState -} from '@tanstack/react-router'; -import { useCallback, useEffect, useMemo } from 'react'; -import backIcon from '~/react/assets/chevron-left.svg'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import { useQuery } from '@connectrpc/connect-query'; -import { FrontierServiceQueries, - ListProjectGroupsRequestSchema, - ListProjectUsersRequestSchema, - GetProjectRequestSchema, - ListRolesRequestSchema, - type Role as ProtoRole, - type Organization -} from '@raystack/proton/frontier'; -import { create } from '@bufbuild/protobuf'; -import { PERMISSIONS } from '~/utils'; -import { General } from './general'; -import { Members } from './members'; -import { PageHeader } from '~/react/components/common/page-header'; -import sharedStyles from '../styles.module.css'; -import styles from './project.module.css'; +'use client'; -interface ProjectGroupRolePair { - groupId?: string; - roles: ProtoRole[]; -} - -interface ProjectUserRolePair { - userId?: string; - roles: ProtoRole[]; -} +import { useNavigate, useParams } from '@tanstack/react-router'; +import { ProjectDetailPage } from '~/react/views/projects'; export const ProjectPage = () => { - let { projectId } = useParams({ from: '/projects/$projectId' }); - - const { activeOrganization: organization } = useFrontier(); - let navigate = useNavigate({ from: '/projects/$projectId' }); - const routeState = useRouterState(); - - const isDeleteRoute = useMemo(() => { - return routeState.matches.some( - route => route.routeId === '/projects/$projectId/delete' + const { projectId } = useParams({ from: '/projects/$projectId' }); + const navigate = useNavigate({ from: '/projects/$projectId' }); + + return ( + navigate({ to: '/projects' })} + /> ); - }, [routeState.matches]); - - const { - data: projectGroupsData, - isLoading: isTeamsLoading, - error: projectGroupsError, - refetch: refetchProjectGroups - } = useQuery( - FrontierServiceQueries.listProjectGroups, - create(ListProjectGroupsRequestSchema, { - id: projectId || '', - withRoles: true - }), - { - enabled: !!organization?.id && !!projectId && !isDeleteRoute - } - ); - - const projectGroups = useMemo(() => ({ - groups: projectGroupsData?.groups ?? [], - groupRoles: (projectGroupsData?.rolePairs ?? []).reduce((acc: Record, gr: ProjectGroupRolePair) => { - const key = gr.groupId; - if (key) acc[key] = gr.roles; - return acc; - }, {}) - }), [projectGroupsData]); - - useEffect(() => { - if (projectGroupsError) { - toast.error('Something went wrong', { - description: projectGroupsError.message - }); - } - }, [projectGroupsError]); - - const { - data: projectUsersData, - isLoading: isMembersLoadingQuery, - refetch: refetchProjectUsers - } = useQuery( - FrontierServiceQueries.listProjectUsers, - create(ListProjectUsersRequestSchema, { - id: projectId || '', - withRoles: true - }), - { - enabled: !!organization?.id && !!projectId && !isDeleteRoute - } - ); - - const projectUsers = useMemo(() => ({ - users: projectUsersData?.users ?? [], - memberRoles: (projectUsersData?.rolePairs ?? []).reduce((acc: Record, mr: ProjectUserRolePair) => { - const key = mr.userId; - if (key) acc[key] = mr.roles; - return acc; - }, {}) - }), [projectUsersData]); - - const { - data: project, - isLoading: isProjectLoadingQuery, - error: projectError - } = useQuery( - FrontierServiceQueries.getProject, - create(GetProjectRequestSchema, { id: projectId || '' }), - { - enabled: !!organization?.id && !!projectId && !isDeleteRoute, - select: d => d?.project - } - ); - - useEffect(() => { - if (projectError) { - toast.error('Something went wrong', { description: projectError.message }); - } - }, [projectError]); - - const { - data: rolesData, - isLoading: isProjectRoleLoadingQuery, - error: rolesError - } = useQuery( - FrontierServiceQueries.listRoles, - create(ListRolesRequestSchema, { - state: 'enabled', - scopes: [PERMISSIONS.ProjectNamespace] - }), - { - enabled: !!organization?.id && !!projectId && !isDeleteRoute - } - ); - - const roles = useMemo(() => rolesData?.roles ?? [], [rolesData]); - - useEffect(() => { - if (rolesError) { - toast.error('Something went wrong', { description: rolesError.message }); - } - }, [rolesError]); - - const isLoading = - isProjectLoadingQuery || - isTeamsLoading || - isMembersLoadingQuery || - isProjectRoleLoadingQuery; - - const refetchTeamAndMembers = useCallback(() => { - refetchProjectUsers(); - refetchProjectGroups(); - }, [refetchProjectUsers, refetchProjectGroups]); - - return ( - - - - - back-icon navigate({ to: '/projects' })} - data-test-id="frontier-sdk-projects-page-back-link" - /> - - - - - - General - Members - - - - - - - - - - - - ); }; diff --git a/web/sdk/react/components/organization/project/projects.columns.tsx b/web/sdk/react/components/organization/project/projects.columns.tsx deleted file mode 100644 index e4db0f803..000000000 --- a/web/sdk/react/components/organization/project/projects.columns.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { - DotsHorizontalIcon, - Pencil1Icon, - TrashIcon -} from '@radix-ui/react-icons'; -import { Text, DropdownMenu } from '@raystack/apsara'; -import { Link } from '@tanstack/react-router'; -import type { Project } from '@raystack/proton/frontier'; -import type { DataTableColumnDef } from '@raystack/apsara'; - -export const getColumns: ( - userAccessOnProject: Record -) => DataTableColumnDef[] = userAccessOnProject => [ - { - header: 'Title', - accessorKey: 'title', - cell: ({ row, getValue }) => { - return ( - - {getValue() as string} - - ); - } - }, - // { - // header: 'Privacy', - // accessorKey: 'privacy', - // cell: isLoading - // ? () => - // : info => {info.getValue() ?? 'Public'} - // }, - { - header: 'Members', - accessorKey: 'membersCount', - cell: ({ row, getValue }) => { - const value = getValue() as string; - return value ? {value} members : null; - } - }, - { - header: '', - accessorKey: 'id', - meta: { - style: { - textAlign: 'end' - } - }, - enableSorting: false, - cell: ({ row, getValue }) => ( - - ) - } -]; - -const ProjectActions = ({ - project, - userAccessOnProject -}: { - project: Project; - userAccessOnProject: Record; -}) => { - const canUpdateProject = (userAccessOnProject[project.id!] ?? []).includes( - 'update' - ); - const canDeleteProject = (userAccessOnProject[project.id!] ?? []).includes( - 'delete' - ); - const canDoActions = canUpdateProject || canDeleteProject; - - return canDoActions ? ( - - - - - {/* @ts-ignore */} - - - {canUpdateProject ? ( - - - Rename - - - ) : null} - {canDeleteProject ? ( - - - Delete project - - - ) : null} - - - - ) : null; -}; diff --git a/web/sdk/react/components/organization/routes.tsx b/web/sdk/react/components/organization/routes.tsx index cee0dd637..b4d158d6c 100644 --- a/web/sdk/react/components/organization/routes.tsx +++ b/web/sdk/react/components/organization/routes.tsx @@ -17,10 +17,7 @@ import WorkspaceMembers from './members'; import UserPreferences from './preferences'; import { default as WorkspaceProjects } from './project'; -import { AddProject } from './project/add'; -import { DeleteProject } from './project/delete'; import { ProjectPage } from './project/project'; -import { RemoveProjectMember } from './project/members/remove'; import WorkspaceSecurity from './security'; import { Sidebar } from './sidebar'; @@ -196,30 +193,12 @@ const projectsRoute = createRoute({ component: WorkspaceProjects }); -const addProjectRoute = createRoute({ - getParentRoute: () => projectsRoute, - path: '/modal', - component: AddProject -}); - const projectPageRoute = createRoute({ getParentRoute: () => rootRoute, path: '/projects/$projectId', component: ProjectPage }); -const deleteProjectRoute = createRoute({ - getParentRoute: () => projectPageRoute, - path: '/delete', - component: DeleteProject -}); - -const removeProjectMemberRoute = createRoute({ - getParentRoute: () => projectPageRoute, - path: '/$membertype/$memberId/remove', - component: RemoveProjectMember -}); - const profileRoute = createRoute({ getParentRoute: () => rootRoute, path: '/profile', @@ -336,11 +315,8 @@ export function getRootTree({ customScreens = [] }: getRootTreeOptions) { deleteDomainRoute ]), teamRoute, - projectsRoute.addChildren([addProjectRoute]), - projectPageRoute.addChildren([ - deleteProjectRoute, - removeProjectMemberRoute - ]), + projectsRoute, + projectPageRoute, profileRoute, preferencesRoute, billingRoute.addChildren([switchBillingCycleModalRoute]), diff --git a/web/sdk/react/views/projects/details/delete-project-dialog.tsx b/web/sdk/react/views/projects/details/delete-project-dialog.tsx new file mode 100644 index 000000000..d5c05e3ec --- /dev/null +++ b/web/sdk/react/views/projects/details/delete-project-dialog.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { + Button, + Checkbox, + toast, + Skeleton, + Image, + Text, + Flex, + Dialog, + InputField +} from '@raystack/apsara'; + +import { yupResolver } from '@hookform/resolvers/yup'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import * as yup from 'yup'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { useQuery, useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + GetProjectRequestSchema, + DeleteProjectRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import cross from '~/react/assets/cross.svg'; +import orgStyles from '../../../components/organization/organization.module.css'; + +const projectSchema = yup + .object({ + title: yup.string() + }) + .required(); + +export interface DeleteProjectDialogProps { + open: boolean; + onOpenChange?: (value: boolean) => void; + projectId: string; + onDeleteSuccess?: () => void; +} + +export const DeleteProjectDialog = ({ + open, + onOpenChange, + projectId, + onDeleteSuccess +}: DeleteProjectDialogProps) => { + const { + watch, + setError, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + register + } = useForm({ + resolver: yupResolver(projectSchema) + }); + const { activeOrganization: organization } = useFrontier(); + const [isAcknowledged, setIsAcknowledged] = useState(false); + + const handleOpenChange = (value: boolean) => { + if (!value) { + reset(); + setIsAcknowledged(false); + } + onOpenChange?.(value); + }; + + const { + data: project, + isLoading: isProjectQueryLoading, + error: projectError + } = useQuery( + FrontierServiceQueries.getProject, + create(GetProjectRequestSchema, { id: projectId || '' }), + { + enabled: !!projectId && open, + select: (d) => d?.project + } + ); + + useEffect(() => { + if (projectError) { + toast.error('Something went wrong', { description: projectError.message }); + } + }, [projectError]); + + const { mutateAsync: deleteProject } = useMutation( + FrontierServiceQueries.deleteProject, + { + onSuccess: () => { + toast.success('Project deleted'); + onDeleteSuccess?.(); + handleOpenChange(false); + }, + onError: (err: Error) => + toast.error('Something went wrong', { description: err.message }) + } + ); + + async function onSubmit(data: { title?: string }) { + if (!organization?.id || !projectId) return; + if (data.title !== project?.title) + return setError('title', { message: 'Project title does not match' }); + await deleteProject(create(DeleteProjectRequestSchema, { id: projectId })); + } + + const title = watch('title', ''); + + return ( + + + + + + Verify project deletion + + cross handleOpenChange(false)} + style={{ cursor: 'pointer' }} + data-test-id="frontier-sdk-delete-project-close-btn" + /> + + + +
+ + {isProjectQueryLoading ? ( + <> + + + + + + + ) : ( + <> + + This action can not be undone. This will permanently delete + project {project?.title}. + + + + + + setIsAcknowledged(v === true)} + data-test-id="frontier-sdk-delete-project-checkbox" + /> + + I acknowledge and understand that all of the project data will be deleted + and want to proceed. + + + + + )} + +
+
+
+
+ ); +}; + diff --git a/web/sdk/react/views/projects/details/index.ts b/web/sdk/react/views/projects/details/index.ts new file mode 100644 index 000000000..5700826a8 --- /dev/null +++ b/web/sdk/react/views/projects/details/index.ts @@ -0,0 +1,9 @@ +export { ProjectDetailPage } from './project-detail-page'; +export type { ProjectDetailPageProps } from './project-detail-page'; + +export { DeleteProjectDialog } from './delete-project-dialog'; +export type { DeleteProjectDialogProps } from './delete-project-dialog'; + +export { RemoveProjectMemberDialog } from './remove-project-member-dialog'; +export type { RemoveProjectMemberDialogProps } from './remove-project-member-dialog'; + diff --git a/web/sdk/react/views/projects/details/project-detail-page.tsx b/web/sdk/react/views/projects/details/project-detail-page.tsx new file mode 100644 index 000000000..b5cca8daf --- /dev/null +++ b/web/sdk/react/views/projects/details/project-detail-page.tsx @@ -0,0 +1,264 @@ +'use client'; + +import { Tabs, Image, toast, Flex } from '@raystack/apsara'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import backIcon from '~/react/assets/chevron-left.svg'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { useQuery } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + ListProjectGroupsRequestSchema, + ListProjectUsersRequestSchema, + GetProjectRequestSchema, + ListRolesRequestSchema, + type Role as ProtoRole, + type Organization +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import { PERMISSIONS } from '~/utils'; +import { ProjectGeneral } from './project-general'; +import { ProjectMembers } from './project-members'; +import { DeleteProjectDialog } from './delete-project-dialog'; +import { RemoveProjectMemberDialog } from './remove-project-member-dialog'; +import { PageHeader } from '~/react/components/common/page-header'; +import sharedStyles from '../../../components/organization/styles.module.css'; +import styles from './project-detail.module.css'; + +interface ProjectGroupRolePair { + groupId?: string; + roles: ProtoRole[]; +} + +interface ProjectUserRolePair { + userId?: string; + roles: ProtoRole[]; +} + +export interface ProjectDetailPageProps { + projectId: string; + onBack?: () => void; +} + +export const ProjectDetailPage = ({ + projectId, + onBack +}: ProjectDetailPageProps) => { + const { activeOrganization: organization } = useFrontier(); + + const [deleteProjectState, setDeleteProjectState] = useState({ open: false }); + const [removeMemberState, setRemoveMemberState] = useState({ + open: false, + memberId: '' + }); + + const { + data: projectGroupsData, + isLoading: isTeamsLoading, + error: projectGroupsError, + refetch: refetchProjectGroups + } = useQuery( + FrontierServiceQueries.listProjectGroups, + create(ListProjectGroupsRequestSchema, { + id: projectId || '', + withRoles: true + }), + { + enabled: !!organization?.id && !!projectId + } + ); + + const projectGroups = useMemo( + () => ({ + groups: projectGroupsData?.groups ?? [], + groupRoles: (projectGroupsData?.rolePairs ?? []).reduce( + (acc: Record, gr: ProjectGroupRolePair) => { + const key = gr.groupId; + if (key) acc[key] = gr.roles; + return acc; + }, + {} + ) + }), + [projectGroupsData] + ); + + useEffect(() => { + if (projectGroupsError) { + toast.error('Something went wrong', { + description: projectGroupsError.message + }); + } + }, [projectGroupsError]); + + const { + data: projectUsersData, + isLoading: isMembersLoadingQuery, + refetch: refetchProjectUsers + } = useQuery( + FrontierServiceQueries.listProjectUsers, + create(ListProjectUsersRequestSchema, { + id: projectId || '', + withRoles: true + }), + { + enabled: !!organization?.id && !!projectId + } + ); + + const projectUsers = useMemo( + () => ({ + users: projectUsersData?.users ?? [], + memberRoles: (projectUsersData?.rolePairs ?? []).reduce( + (acc: Record, mr: ProjectUserRolePair) => { + const key = mr.userId; + if (key) acc[key] = mr.roles; + return acc; + }, + {} + ) + }), + [projectUsersData] + ); + + const { + data: project, + isLoading: isProjectLoadingQuery, + error: projectError + } = useQuery( + FrontierServiceQueries.getProject, + create(GetProjectRequestSchema, { id: projectId || '' }), + { + enabled: !!organization?.id && !!projectId, + select: d => d?.project + } + ); + + useEffect(() => { + if (projectError) { + toast.error('Something went wrong', { + description: projectError.message + }); + } + }, [projectError]); + + const { + data: rolesData, + isLoading: isProjectRoleLoadingQuery, + error: rolesError + } = useQuery( + FrontierServiceQueries.listRoles, + create(ListRolesRequestSchema, { + state: 'enabled', + scopes: [PERMISSIONS.ProjectNamespace] + }), + { + enabled: !!organization?.id && !!projectId + } + ); + + const roles = useMemo(() => rolesData?.roles ?? [], [rolesData]); + + useEffect(() => { + if (rolesError) { + toast.error('Something went wrong', { + description: rolesError.message + }); + } + }, [rolesError]); + + const isLoading = + isProjectLoadingQuery || + isTeamsLoading || + isMembersLoadingQuery || + isProjectRoleLoadingQuery; + + const refetchTeamAndMembers = useCallback(() => { + refetchProjectUsers(); + refetchProjectGroups(); + }, [refetchProjectUsers, refetchProjectGroups]); + + const handleDeleteProjectOpenChange = (value: boolean) => { + setDeleteProjectState({ open: value }); + }; + + const handleRemoveMemberOpenChange = (value: boolean) => { + if (!value) { + setRemoveMemberState({ open: false, memberId: '' }); + refetchTeamAndMembers(); + } else { + setRemoveMemberState(prev => ({ ...prev, open: value })); + } + }; + + const onRemoveMember = (memberId: string) => { + setRemoveMemberState({ open: true, memberId }); + }; + + return ( + + + + + back-icon + + + + + + General + Members + + + handleDeleteProjectOpenChange(true)} + /> + + + + + + + + + + ); +}; + diff --git a/web/sdk/react/views/projects/details/project-detail.module.css b/web/sdk/react/views/projects/details/project-detail.module.css new file mode 100644 index 000000000..fcbc7c82e --- /dev/null +++ b/web/sdk/react/views/projects/details/project-detail.module.css @@ -0,0 +1,8 @@ +.tabContent { + height: 100%; +} + +.container { + box-sizing: border-box; +} + diff --git a/web/sdk/react/views/projects/details/project-general.tsx b/web/sdk/react/views/projects/details/project-general.tsx new file mode 100644 index 000000000..83682df3e --- /dev/null +++ b/web/sdk/react/views/projects/details/project-general.tsx @@ -0,0 +1,227 @@ +'use client'; + +import { + Button, + Separator, + toast, + Tooltip, + Skeleton, + Text, + Flex, + InputField +} from '@raystack/apsara'; + +import { yupResolver } from '@hookform/resolvers/yup'; +import { useEffect, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import * as yup from 'yup'; +import { usePermissions } from '~/react/hooks/usePermissions'; +import { useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + UpdateProjectRequestSchema, + Project, + Organization +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import { PERMISSIONS, shouldShowComponent } from '~/utils'; +import { AuthTooltipMessage } from '~/react/utils'; + +const projectSchema = yup + .object({ + title: yup.string().required(), + name: yup.string().required() + }) + .required(); + +type FormData = yup.InferType; + +interface ProjectGeneralProps { + projectId: string; + project?: Project; + organization?: Organization; + isLoading?: boolean; + onDeleteClick?: () => void; +} + +export const ProjectGeneral = ({ + projectId, + organization, + project, + isLoading: isProjectLoading, + onDeleteClick +}: ProjectGeneralProps) => { + const { + reset, + handleSubmit, + formState: { errors, isSubmitting }, + register + } = useForm({ + resolver: yupResolver(projectSchema) + }); + + const { mutateAsync: updateProject } = useMutation( + FrontierServiceQueries.updateProject, + { + onSuccess: () => toast.success('Project updated successfully'), + onError: (error: Error) => + toast.error('Something went wrong', { description: error.message }) + } + ); + + useEffect(() => { + reset(project); + }, [reset, project]); + + const resource = `app/project:${projectId}`; + const listOfPermissionsToCheck = useMemo( + () => [ + { + permission: PERMISSIONS.UpdatePermission, + resource + }, + { + permission: PERMISSIONS.DeletePermission, + resource + } + ], + [resource] + ); + + const { permissions, isFetching: isPermissionsFetching } = usePermissions( + listOfPermissionsToCheck, + !!projectId + ); + + const { canUpdateProject, canDeleteProject } = useMemo(() => { + return { + canUpdateProject: shouldShowComponent( + permissions, + `${PERMISSIONS.UpdatePermission}::${resource}` + ), + canDeleteProject: shouldShowComponent( + permissions, + `${PERMISSIONS.DeletePermission}::${resource}` + ) + }; + }, [permissions, resource]); + + async function onSubmit(data: FormData) { + if (!organization?.id) return; + if (!projectId) return; + + await updateProject( + create(UpdateProjectRequestSchema, { + id: projectId, + body: { + name: data.name, + title: data.title, + orgId: organization.id + } + }) + ); + } + + const isLoading = isPermissionsFetching || isProjectLoading; + + return ( + +
+ + {isLoading ? ( +
+ + +
+ ) : ( + + )} + {isLoading ? ( +
+ + +
+ ) : ( + + )} + {isLoading ? ( + + ) : ( + + + + )} +
+
+ + + +
+ ); +}; + +interface GeneralDeleteProjectProps { + canDeleteProject?: boolean; + isLoading?: boolean; + onDeleteClick?: () => void; +} + +const GeneralDeleteProject = ({ + canDeleteProject, + isLoading, + onDeleteClick +}: GeneralDeleteProjectProps) => { + return ( + + {isLoading ? ( + + ) : ( + + If you want to permanently delete this project and all of its data. + + )}{' '} + {isLoading ? ( + + ) : ( + + + + )} + + ); +}; + diff --git a/web/sdk/react/views/projects/details/project-member-columns.tsx b/web/sdk/react/views/projects/details/project-member-columns.tsx new file mode 100644 index 000000000..8d77a4a06 --- /dev/null +++ b/web/sdk/react/views/projects/details/project-member-columns.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import { + DotsHorizontalIcon, + TrashIcon, + UpdateIcon +} from '@radix-ui/react-icons'; +import { + Avatar, + Label, + Text, + Flex, + toast, + DropdownMenu, + type DataTableColumnDef, + getAvatarColor +} from '@raystack/apsara'; +import { useQuery, useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + ListPoliciesRequestSchema, + DeletePolicyRequestSchema, + CreatePolicyRequestSchema, + type Role, + type User, + type Group +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import { differenceWith, getInitials, isEqualById } from '~/utils'; + +import teamIcon from '~/react/assets/users.svg'; + +type RowMember = (User & { isTeam?: false }) | (Group & { isTeam: true }); + +export const getColumns = ( + memberRoles: Record = {}, + groupRoles: Record = {}, + roles: Role[] = [], + canUpdateProject: boolean, + projectId: string, + refetch: () => void, + onRemoveMember?: (memberId: string) => void +): DataTableColumnDef[] => [ + { + header: '', + accessorKey: 'avatar', + enableSorting: false, + styles: { + cell: { + width: 'var(--rs-space-5)' + } + }, + cell: ({ row, getValue }) => { + const avatarSrc = row.original?.isTeam ? teamIcon : getValue(); + const fallback = row.original?.isTeam + ? '' + : getInitials(row.original?.title || row.original?.email); + const color = getAvatarColor(row?.original?.id || ''); + return ( + + ); + } + }, + { + header: 'Title', + accessorKey: 'title', + cell: ({ row, getValue }) => { + const label = row.original?.isTeam + ? row.original.title + : (getValue() as string); + const subLabel = row.original?.isTeam + ? row.original.name + : row.original.email; + + return ( + + + {subLabel} + + ); + } + }, + { + header: 'Roles', + accessorKey: 'email', + cell: ({ row }) => { + return ( + + {row.original?.isTeam + ? (row.original?.id && + groupRoles[row.original?.id] && + groupRoles[row.original?.id] + .map((r: Role) => r.title || r.name) + .join(', ')) ?? + 'Project Viewer' + : (row.original?.id && + memberRoles[row.original?.id] && + memberRoles[row.original?.id] + .map((r: Role) => r.title || r.name) + .join(', ')) ?? + 'Inherited role'} + + ); + } + }, + { + header: '', + accessorKey: 'id', + enableSorting: false, + cell: ({ row }) => ( + ( + isEqualById, + roles, + row.original.isTeam + ? row.original?.id && groupRoles[row.original?.id] + ? groupRoles[row.original?.id] + : [] + : row.original?.id && memberRoles[row.original?.id] + ? memberRoles[row.original?.id] + : [] + )} + onRemoveMember={onRemoveMember} + /> + ) + } +]; + +const MembersActions = ({ + projectId, + member, + canUpdateProject, + excludedRoles = [], + refetch = () => null, + onRemoveMember +}: { + projectId: string; + member: RowMember; + canUpdateProject?: boolean; + excludedRoles: Role[]; + refetch: () => void; + onRemoveMember?: (memberId: string) => void; +}) => { + function removeMember() { + onRemoveMember?.(member?.id as string); + } + + const { data: policiesData, refetch: refetchPolicies, error: policiesError } = useQuery( + FrontierServiceQueries.listPolicies, + create(ListPoliciesRequestSchema, { + projectId: projectId, + userId: member.isTeam ? undefined : (member.id as string), + groupId: member.isTeam ? (member.id as string) : undefined + }), + { enabled: !!projectId && !!member?.id } + ); + + useEffect(() => { + if (policiesError) { + toast.error('Something went wrong', { description: (policiesError as Error).message }); + } + }, [policiesError]); + + const policies = useMemo(() => policiesData?.policies ?? [], [policiesData]); + + const { mutateAsync: deletePolicy } = useMutation( + FrontierServiceQueries.deletePolicy, + { + onError: (err: Error) => + toast.error('Something went wrong', { description: err.message }) + } + ); + const { mutateAsync: createPolicy } = useMutation( + FrontierServiceQueries.createPolicy, + { + onError: (err: Error) => + toast.error('Something went wrong', { description: err.message }) + } + ); + + async function updateRole(role: Role) { + try { + const resource = `app/project:${projectId}`; + const principal = member.isTeam + ? `app/group:${member?.id}` + : `app/user:${member?.id}`; + + await Promise.all( + (policies || []).map(p => + deletePolicy(create(DeletePolicyRequestSchema, { id: p.id || '' })) + ) + ); + + await createPolicy( + create(CreatePolicyRequestSchema, { + body: { + roleId: role.id as string, + title: (role.title || role.name) as string, + resource, + principal + } + }) + ); + await refetchPolicies(); + refetch(); + toast.success('Project member role updated'); + } catch (err) { + const message = (err as Error)?.message || 'Failed to update role'; + toast.error('Something went wrong', { description: message }); + } + } + + return canUpdateProject ? ( + + + + + {/* @ts-ignore */} + + + {excludedRoles.map((role: Role) => ( + updateRole(role)} + data-test-id="frontier-sdk-update-project-member-role-btn" + > + + Make {role.title} + + ))} + removeMember()} + > + + Remove from project + + + + + ) : null; +}; + diff --git a/web/sdk/react/views/projects/details/project-members.module.css b/web/sdk/react/views/projects/details/project-members.module.css new file mode 100644 index 000000000..abe7d3c2f --- /dev/null +++ b/web/sdk/react/views/projects/details/project-members.module.css @@ -0,0 +1,47 @@ +.inviteDropdownItem { + padding: var(--rs-space-3); + user-select: none; + cursor: pointer; + background-color: var(--rs-color-background-base-primary); + color: var(--rs-color-foreground-base-primary); +} + +.inviteDropdownItem:hover { + background-color: var(--rs-color-background-base-primary-hover); +} + +.popoverContent { + padding: 0; + min-width: 300px; + pointer-events: auto; +} + +.tableWrapper { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.tableRoot { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; +} + +.tableHeader { + z-index: 1; +} + +.tableSearchWrapper { + max-width: 50%; + flex: 1; +} + +.container { + margin-top: var(--rs-space-5); + height: calc(100% - var(--rs-space-5)); +} + diff --git a/web/sdk/react/views/projects/details/project-members.tsx b/web/sdk/react/views/projects/details/project-members.tsx new file mode 100644 index 000000000..a3c273f8b --- /dev/null +++ b/web/sdk/react/views/projects/details/project-members.tsx @@ -0,0 +1,404 @@ +'use client'; + +import { + CardStackPlusIcon, + PlusIcon, + ExclamationTriangleIcon +} from '@radix-ui/react-icons'; +import type React from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Button, + EmptyState, + Tooltip, + toast, + Separator, + Avatar, + Skeleton, + Text, + Flex, + DataTable, + Popover, + Search +} from '@raystack/apsara'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { useOrganizationTeams } from '~/react/hooks/useOrganizationTeams'; +import { usePermissions } from '~/react/hooks/usePermissions'; +import { AuthTooltipMessage } from '~/react/utils'; +import { + PERMISSIONS, + filterUsersfromUsers, + getInitials, + shouldShowComponent +} from '~/utils'; +import { getColumns } from './project-member-columns'; +import { useQuery, useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + ListOrganizationUsersRequestSchema, + CreatePolicyForProjectRequestSchema, + type Group, + type User, + type Role +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import styles from './project-members.module.css'; + +export type ProjectMembersProps = { + projectId: string; + teams?: Group[]; + members?: User[]; + roles?: Role[]; + memberRoles?: Record; + groupRoles?: Record; + isLoading?: boolean; + refetch: () => void; + onRemoveMember?: (memberId: string) => void; +}; + +export const ProjectMembers = ({ + projectId, + teams = [], + members = [], + roles = [], + memberRoles, + groupRoles, + isLoading: isMemberLoading, + refetch, + onRemoveMember +}: ProjectMembersProps) => { + const resource = `app/project:${projectId}`; + const listOfPermissionsToCheck = useMemo( + () => [ + { + permission: PERMISSIONS.UpdatePermission, + resource + } + ], + [resource] + ); + + const { permissions, isFetching: isPermissionsFetching } = usePermissions( + listOfPermissionsToCheck, + !!projectId + ); + + const { canUpdateProject } = useMemo(() => { + return { + canUpdateProject: shouldShowComponent( + permissions, + `${PERMISSIONS.UpdatePermission}::${resource}` + ) + }; + }, [permissions, resource]); + + const isLoading = isMemberLoading || isPermissionsFetching; + + const columns = useMemo( + () => + getColumns( + memberRoles, + groupRoles, + roles, + canUpdateProject, + projectId, + refetch, + onRemoveMember + ), + [memberRoles, groupRoles, roles, canUpdateProject, projectId, refetch, onRemoveMember] + ); + + const updatedUsers = useMemo(() => { + const updatedTeams = teams.map(t => ({ ...t, isTeam: true })); + return members?.length || updatedTeams?.length + ? [...updatedTeams, ...members] + : []; + }, [members, teams]); + + return ( + + + + + + + + {isLoading ? ( + + ) : ( + + + + )} + + + + + + ); +}; + +interface AddMemberDropdownProps { + projectId: string; + canUpdateProject: boolean; + members?: User[]; + refetch?: () => void; +} + +const AddMemberDropdown = ({ + projectId, + canUpdateProject, + members, + refetch +}: AddMemberDropdownProps) => { + const [query, setQuery] = useState(''); + const [showTeam, setShowTeam] = useState(false); + + const { activeOrganization: organization } = useFrontier(); + const { isFetching: isTeamsLoading, teams } = useOrganizationTeams({}); + + const toggleShowTeam = (e: React.MouseEvent) => { + e.preventDefault(); + setQuery(''); + setShowTeam(prev => !prev); + }; + + const { data: orgUsersData, isLoading: isOrgUsersLoading, error: orgUsersError } = useQuery( + FrontierServiceQueries.listOrganizationUsers, + create(ListOrganizationUsersRequestSchema, { id: organization?.id || '' }), + { enabled: !!organization?.id && canUpdateProject } + ); + + const orgUsersResp = useMemo(() => orgUsersData?.users ?? [], [orgUsersData]); + + useEffect(() => { + if (orgUsersError) { + toast.error('Something went wrong', { description: orgUsersError.message }); + } + }, [orgUsersError]); + + const invitableUser = useMemo( + () => filterUsersfromUsers(orgUsersResp || [], members) || [], + [orgUsersResp, members] + ); + + const topUsers = useMemo( + () => + invitableUser + .filter(user => + query + ? user.title?.toLowerCase().includes(query.toLowerCase()) || + user.email?.includes(query) + : true + ) + .slice(0, 7), + [invitableUser, query] + ); + + const topTeams = useMemo(() => + teams + .filter(team => + query + ? team.title && team.title.toLowerCase().includes(query.toLowerCase()) + : true + ) + .slice(0, 7), + [query, teams]); + + function onTextChange(e: React.ChangeEvent) { + setQuery(e.target.value); + } + + const { mutate: createPolicyForProject, isPending: isCreatingPolicy } = useMutation( + FrontierServiceQueries.createPolicyForProject, + { + onSuccess: () => { + toast.success('Member added'); + if (refetch) refetch(); + }, + onError: (err: Error) => { + toast.error('Something went wrong', { description: err.message }); + } + } + ); + + const addMember = useCallback( + (userId: string) => { + if (!userId || !organization?.id || !projectId) return; + const principal = `${PERMISSIONS.UserNamespace}:${userId}`; + createPolicyForProject( + create(CreatePolicyForProjectRequestSchema, { + projectId: projectId, + body: { roleId: PERMISSIONS.RoleProjectViewer, principal } + }) + ); + }, + [createPolicyForProject, organization?.id, projectId] + ); + + const addTeam = useCallback( + (teamId: string) => { + if (!teamId || !organization?.id || !projectId) return; + const principal = `${PERMISSIONS.GroupNamespace}:${teamId}`; + createPolicyForProject( + create(CreatePolicyForProjectRequestSchema, { + projectId: projectId, + body: { roleId: PERMISSIONS.RoleProjectViewer, principal } + }) + ); + }, + [createPolicyForProject, organization?.id, projectId] + ); + + return ( + + + + + + setQuery('')} + /> + + + {showTeam ? ( + isTeamsLoading ? ( + + ) : topTeams.length ? ( +
+ {topTeams.map((team, i) => { + const initals = getInitials(team?.title || team.name); + return ( + addTeam(team?.id || '')} + className={styles.inviteDropdownItem} + data-test-id={`frontier-sdk-add-team-to-project-dropdown-item-${i}`} + > + + {team?.title || team?.name} + + ); + })} +
+ ) : ( + + No Teams found + + ) + ) : isOrgUsersLoading ? ( + + ) : topUsers.length ? ( +
+ {topUsers.map((user, i) => { + const initals = getInitials(user?.title || user.email); + return ( + addMember(user?.id || '')} + data-test-id={`frontier-sdk-add-user-to-project-dropdown-item-${i}`} + > + + {user?.title || user?.email} + + ); + })} +
+ ) : ( + + No Users found + + )} + + +
+ + {showTeam ? ( + <> + {' '} + Add project member + + ) : ( + <> + {' '} + Add team to project + + )} + +
+
+
+ ); +}; + +const noDataChildren = ( + } + heading="No members found" + subHeading="Get started by adding your first member." + /> +); + diff --git a/web/sdk/react/views/projects/details/remove-project-member-dialog.tsx b/web/sdk/react/views/projects/details/remove-project-member-dialog.tsx new file mode 100644 index 000000000..6e6685f38 --- /dev/null +++ b/web/sdk/react/views/projects/details/remove-project-member-dialog.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { Button, Flex, Text, toast, Image, Dialog } from '@raystack/apsara'; +import { useEffect, useMemo } from 'react'; +import { + useQuery, + useMutation, + createConnectQueryKey, + useTransport +} from '@connectrpc/connect-query'; +import { useQueryClient } from '@tanstack/react-query'; +import { + FrontierServiceQueries, + ListPoliciesRequestSchema, + DeletePolicyRequestSchema, + ListProjectUsersRequestSchema, + ListProjectGroupsRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import cross from '~/react/assets/cross.svg'; +import orgStyles from '../../../components/organization/organization.module.css'; + +export interface RemoveProjectMemberDialogProps { + open: boolean; + onOpenChange?: (value: boolean) => void; + projectId: string; + memberId: string; +} + +export const RemoveProjectMemberDialog = ({ + open, + onOpenChange, + projectId, + memberId +}: RemoveProjectMemberDialogProps) => { + const queryClient = useQueryClient(); + const transport = useTransport(); + + const handleOpenChange = (value: boolean) => { + onOpenChange?.(value); + }; + + const { data: policiesData, error: policiesError } = useQuery( + FrontierServiceQueries.listPolicies, + create(ListPoliciesRequestSchema, { + projectId: projectId || '', + userId: memberId || '' + }), + { enabled: !!projectId && !!memberId && open } + ); + + useEffect(() => { + if (policiesError) { + toast.error('Something went wrong', { description: (policiesError as Error).message }); + } + }, [policiesError]); + + const policies = useMemo(() => policiesData?.policies ?? [], [policiesData]); + + const { mutateAsync: deletePolicy, isPending } = useMutation( + FrontierServiceQueries.deletePolicy, + { + onError: (err: Error) => + toast.error('Something went wrong', { description: err.message }) + } + ); + + async function onConfirm() { + try { + await Promise.all( + (policies || []).map(p => + deletePolicy(create(DeletePolicyRequestSchema, { id: p.id || '' })) + ) + ); + if (projectId) { + await queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: FrontierServiceQueries.listProjectUsers, + transport, + input: create(ListProjectUsersRequestSchema, { + id: projectId, + withRoles: true + }), + cardinality: 'finite' + }) + }); + await queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: FrontierServiceQueries.listProjectGroups, + transport, + input: create(ListProjectGroupsRequestSchema, { + id: projectId, + withRoles: true + }), + cardinality: 'finite' + }) + }); + } + handleOpenChange(false); + toast.success('Member removed'); + } catch (error) { + console.error('Failed to delete policies:', error); + } + } + + return ( + + + + + + Remove project member + + cross handleOpenChange(false)} + style={{ cursor: 'pointer' }} + /> + + + + + + + Are you sure you want to remove this member from the project? + + + + + + + + + + + + + ); +}; + diff --git a/web/sdk/react/views/projects/index.ts b/web/sdk/react/views/projects/index.ts new file mode 100644 index 000000000..b4408c794 --- /dev/null +++ b/web/sdk/react/views/projects/index.ts @@ -0,0 +1,15 @@ +export { ProjectsListPage } from './list/projects-list-page'; +export type { ProjectsListPageProps } from './list/projects-list-page'; + +export { ProjectDetailPage } from './details/project-detail-page'; +export type { ProjectDetailPageProps } from './details/project-detail-page'; + +export { AddProjectDialog } from './list/add-project-dialog'; +export type { AddProjectDialogProps } from './list/add-project-dialog'; + +export { DeleteProjectDialog } from './details/delete-project-dialog'; +export type { DeleteProjectDialogProps } from './details/delete-project-dialog'; + +export { RemoveProjectMemberDialog } from './details/remove-project-member-dialog'; +export type { RemoveProjectMemberDialogProps } from './details/remove-project-member-dialog'; + diff --git a/web/sdk/react/views/projects/list/add-project-dialog.tsx b/web/sdk/react/views/projects/list/add-project-dialog.tsx new file mode 100644 index 000000000..c0dab4574 --- /dev/null +++ b/web/sdk/react/views/projects/list/add-project-dialog.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useEffect } from 'react'; +import { + Button, + toast, + Image, + Text, + Flex, + Dialog, + InputField +} from '@raystack/apsara'; +import * as yup from 'yup'; + +import { yupResolver } from '@hookform/resolvers/yup'; +import { useForm } from 'react-hook-form'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + CreateProjectRequestSchema +} from '@raystack/proton/frontier'; +import { create } from '@bufbuild/protobuf'; +import cross from '~/react/assets/cross.svg'; +import orgStyles from '../../../components/organization/organization.module.css'; +import slugify from 'slugify'; +import { generateHashFromString } from '~/react/utils'; +import { ConnectError, Code } from '@connectrpc/connect'; + +const projectSchema = yup + .object({ + title: yup.string().required(), + org_id: yup.string().required() + }) + .required(); + +type FormData = yup.InferType; + +export interface AddProjectDialogProps { + open: boolean; + onOpenChange: (value: boolean) => void; +} + +export const AddProjectDialog = ({ + open, + onOpenChange +}: AddProjectDialogProps) => { + const { + reset, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + register + } = useForm({ + resolver: yupResolver(projectSchema) + }); + const { activeOrganization: organization } = useFrontier(); + + const handleOpenChange = (value: boolean) => { + if (!value) { + reset(); + } + onOpenChange?.(value); + }; + + useEffect(() => { + if (open) { + reset({ org_id: organization?.id }); + } + }, [organization, reset, open]); + + const { mutateAsync: createProject } = useMutation( + FrontierServiceQueries.createProject, + { + onSuccess: () => { + toast.success('Project added'); + handleOpenChange(false); + } + } + ); + + async function onSubmit(data: FormData) { + if (!organization?.id) return; + const slug = slugify(data.title, { lower: true, strict: true }); + const suffix = generateHashFromString(organization.id); + const name = `${slug}-${suffix}`; + try { + await createProject( + create(CreateProjectRequestSchema, { + body: { + title: data.title, + name, + orgId: organization.id + } + }) + ); + } catch (error) { + if (error instanceof ConnectError && error.code === Code.AlreadyExists) { + setError('title', { + message: + 'A project with a similar title already exist. Please tweak the title and try again.' + }); + } else { + toast.error('Something went wrong', { + description: + error instanceof Error ? error.message : 'Failed to create project' + }); + } + } + } + + return ( + + + + + + Add Project + + cross handleOpenChange(false)} + data-test-id="frontier-sdk-new-project-close-btn" + style={{ cursor: 'pointer' }} + /> + + +
+ + +
+ +
+ +
+
+ + + + + +
+
+
+ ); +}; + diff --git a/web/sdk/react/views/projects/list/index.ts b/web/sdk/react/views/projects/list/index.ts new file mode 100644 index 000000000..76c03f6ae --- /dev/null +++ b/web/sdk/react/views/projects/list/index.ts @@ -0,0 +1,6 @@ +export { ProjectsListPage } from './projects-list-page'; +export type { ProjectsListPageProps } from './projects-list-page'; + +export { AddProjectDialog } from './add-project-dialog'; +export type { AddProjectDialogProps } from './add-project-dialog'; + diff --git a/web/sdk/react/views/projects/list/projects-columns.tsx b/web/sdk/react/views/projects/list/projects-columns.tsx new file mode 100644 index 000000000..b3ce0cf60 --- /dev/null +++ b/web/sdk/react/views/projects/list/projects-columns.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { + DotsHorizontalIcon, + Pencil1Icon, + TrashIcon +} from '@radix-ui/react-icons'; +import { Text, DropdownMenu } from '@raystack/apsara'; +import type { Project } from '@raystack/proton/frontier'; +import type { DataTableColumnDef } from '@raystack/apsara'; +import orgStyles from '../../../components/organization/organization.module.css'; + +export const getColumns = ( + userAccessOnProject: Record, + onProjectClick?: (projectId: string) => void, + onDeleteProjectClick?: (projectId: string) => void +): DataTableColumnDef[] => [ + { + header: 'Title', + accessorKey: 'title', + cell: ({ row, getValue }) => { + return ( + onProjectClick?.(row.original.id || '')} + style={{ + textDecoration: 'none', + color: 'var(--rs-color-foreground-base-primary)', + fontSize: 'var(--rs-font-size-small)', + cursor: 'pointer' + }} + > + {getValue() as string} + + ); + } + }, + { + header: 'Members', + accessorKey: 'membersCount', + cell: ({ row, getValue }) => { + const value = getValue() as string; + return value ? {value} members : null; + } + }, + { + header: '', + accessorKey: 'id', + meta: { + style: { + textAlign: 'end' + } + }, + enableSorting: false, + cell: ({ row, getValue }) => ( + + ) + } +]; + +const ProjectActions = ({ + project, + userAccessOnProject, + onProjectClick, + onDeleteProjectClick +}: { + project: Project; + userAccessOnProject: Record; + onProjectClick?: (projectId: string) => void; + onDeleteProjectClick?: (projectId: string) => void; +}) => { + const canUpdateProject = (userAccessOnProject[project.id!] ?? []).includes( + 'update' + ); + const canDeleteProject = (userAccessOnProject[project.id!] ?? []).includes( + 'delete' + ); + const canDoActions = canUpdateProject || canDeleteProject; + + return canDoActions ? ( + + + + + {/* @ts-ignore */} + + + {canUpdateProject ? ( + onProjectClick?.(project.id || '')} + className={orgStyles.dropdownActionItem} + > + Rename + + ) : null} + {canDeleteProject ? ( + onDeleteProjectClick?.(project.id || '')} + className={orgStyles.dropdownActionItem} + > + Delete project + + ) : null} + + + + ) : null; +}; + diff --git a/web/sdk/react/views/projects/list/projects-list-page.tsx b/web/sdk/react/views/projects/list/projects-list-page.tsx new file mode 100644 index 000000000..554da501a --- /dev/null +++ b/web/sdk/react/views/projects/list/projects-list-page.tsx @@ -0,0 +1,283 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Tooltip, + EmptyState, + Skeleton, + Flex, + Button, + Select, + DataTable, + toast +} from '@raystack/apsara'; +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { useOrganizationProjects } from '~/react/hooks/useOrganizationProjects'; +import { usePermissions } from '~/react/hooks/usePermissions'; +import { AuthTooltipMessage } from '~/react/utils'; +import { PERMISSIONS, shouldShowComponent } from '~/utils'; +import { getColumns } from './projects-columns'; +import { Project } from '@raystack/proton/frontier'; +import { PageHeader } from '~/react/components/common/page-header'; +import { AddProjectDialog } from './add-project-dialog'; +import { DeleteProjectDialog } from '../details/delete-project-dialog'; +import sharedStyles from '../../../components/organization/styles.module.css'; +import styles from './projects.module.css'; +import { useTerminology } from '~/react/hooks/useTerminology'; + +const projectsSelectOptions = [ + { value: 'my-projects', label: 'My Projects' }, + { value: 'all-projects', label: 'All Projects' } +]; + +interface ProjectsTableProps { + projects: Project[]; + isLoading?: boolean; + canCreateProject?: boolean; + userAccessOnProject: Record; + canListOrgProjects?: boolean; + onOrgProjectsFilterChange?: (value: string) => void; + onProjectClick?: (projectId: string) => void; + onDeleteProjectClick?: (projectId: string) => void; + onAddProjectClick?: () => void; +} + +export interface ProjectsListPageProps { + title?: string; + description?: string; + onProjectClick?: (projectId: string) => void; +} + +export function ProjectsListPage({ + title, + description, + onProjectClick +}: ProjectsListPageProps = {}) { + const [showOrgProjects, setShowOrgProjects] = useState(false); + const t = useTerminology(); + + const { + isFetching: isProjectsLoading, + projects, + userAccessOnProject, + refetch, + error: projectsError + } = useOrganizationProjects({ + allProjects: showOrgProjects, + withMemberCount: true + }); + const { activeOrganization: organization } = useFrontier(); + + const resource = `app/organization:${organization?.id}`; + const listOfPermissionsToCheck = useMemo( + () => [ + { + permission: PERMISSIONS.ProjectCreatePermission, + resource + }, + { + permission: PERMISSIONS.UpdatePermission, + resource + } + ], + [resource] + ); + + const { permissions, isFetching: isPermissionsFetching } = usePermissions( + listOfPermissionsToCheck, + !!organization?.id + ); + + const { canCreateProject, canUpdateOrganization } = useMemo(() => { + return { + canCreateProject: shouldShowComponent( + permissions, + `${PERMISSIONS.ProjectCreatePermission}::${resource}` + ), + canUpdateOrganization: shouldShowComponent( + permissions, + `${PERMISSIONS.UpdatePermission}::${resource}` + ) + }; + }, [permissions, resource]); + + const onOrgProjectsFilterChange = useCallback((value: string) => { + if (value === 'all-projects') { + setShowOrgProjects(true); + } else { + setShowOrgProjects(false); + } + }, []); + + useEffect(() => { + if (projectsError) { + toast.error('Something went wrong', { + description: projectsError.message + }); + } + }, [projectsError]); + + const isLoading = isPermissionsFetching || isProjectsLoading; + + const [showAddProjectDialog, setShowAddProjectDialog] = useState(false); + const [deleteProjectState, setDeleteProjectState] = useState({ + open: false, + projectId: '' + }); + + const handleAddProjectOpenChange = (value: boolean) => { + setShowAddProjectDialog(value); + refetch(); + }; + + const handleDeleteProjectOpenChange = (value: boolean) => { + if (!value) { + setDeleteProjectState({ open: false, projectId: '' }); + refetch(); + } else { + setDeleteProjectState(prev => ({ ...prev, open: value })); + } + }; + + return ( + + + + + + + + setDeleteProjectState({ open: true, projectId }) + } + onAddProjectClick={() => setShowAddProjectDialog(true)} + /> + + + + + + ); +} + +const ProjectsTable = ({ + projects, + isLoading, + canCreateProject, + userAccessOnProject, + canListOrgProjects, + onOrgProjectsFilterChange, + onProjectClick, + onDeleteProjectClick, + onAddProjectClick +}: ProjectsTableProps) => { + const columns = useMemo( + () => getColumns(userAccessOnProject, onProjectClick, onDeleteProjectClick), + [userAccessOnProject, onProjectClick, onDeleteProjectClick] + ); + + return ( + + + + + {isLoading ? ( + + ) : ( + + )} + {canListOrgProjects ? ( + + ) : null} + + {isLoading ? ( + + ) : ( + + + + )} + + + + + ); +}; + +const noDataChildren = ( + } + heading="No projects found" + subHeading="Get started by creating your first project." + /> +); + diff --git a/web/sdk/react/views/projects/list/projects.module.css b/web/sdk/react/views/projects/list/projects.module.css new file mode 100644 index 000000000..0e9e35f40 --- /dev/null +++ b/web/sdk/react/views/projects/list/projects.module.css @@ -0,0 +1,24 @@ +.tableWrapper { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.tableRoot { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; +} + +.tableHeader { + z-index: 1; +} + +.tableSearchWrapper { + max-width: 500px; + flex: 1; +} +