first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
import React, {useContext, useEffect, useMemo} from 'react';
import {Workspace} from './types/workspace';
import {PersonalWorkspace, useUserWorkspaces} from './user-workspaces';
import {setActiveWorkspaceId} from './active-workspace-id';
import {useCookie} from '@common/utils/hooks/use-cookie';
export interface ActiveWorkspaceIdContextValue {
workspaceId: number | null;
setWorkspaceId: (id: number) => void;
}
// add default context value so it does not error out, if there's no context provider
export const ActiveWorkspaceIdContext =
React.createContext<ActiveWorkspaceIdContextValue>({
// set default as null, so it's not sent via query params in admin and
// other places if component is not wrapped in workspace context explicitly
workspaceId: null,
setWorkspaceId: () => {},
});
export function useActiveWorkspaceId(): ActiveWorkspaceIdContextValue {
return useContext(ActiveWorkspaceIdContext);
}
export function useActiveWorkspace(): Workspace | null | undefined {
const {workspaceId} = useActiveWorkspaceId();
const query = useUserWorkspaces();
if (query.data) {
return query.data.find(workspace => workspace.id === workspaceId);
}
return null;
}
interface ActiveWorkspaceProviderProps {
children: any;
}
export function ActiveWorkspaceProvider({
children,
}: ActiveWorkspaceProviderProps) {
const [workspaceId, setCookieId] = useCookie(
'activeWorkspaceId',
`${PersonalWorkspace.id}`
);
useEffect(() => {
setActiveWorkspaceId(parseInt(workspaceId));
// clear workspace id when unmounting workspace provider
return () => {
setActiveWorkspaceId(0);
};
}, [workspaceId]);
const contextValue: ActiveWorkspaceIdContextValue = useMemo(() => {
return {
workspaceId: parseInt(workspaceId),
setWorkspaceId: (id: number) => {
setCookieId(`${id}`);
},
};
}, [workspaceId, setCookieId]);
return (
<ActiveWorkspaceIdContext.Provider value={contextValue}>
{children}
</ActiveWorkspaceIdContext.Provider>
);
}

View File

@@ -0,0 +1,12 @@
// store this in a separate file, to avoid importing query client and axios in pixie
let activeWorkspaceId = 0;
// for access outside react
export function getActiveWorkspaceId() {
return activeWorkspaceId;
}
export function setActiveWorkspaceId(id: number) {
activeWorkspaceId = id;
}

View File

@@ -0,0 +1,26 @@
import {ConfirmationDialog} from '../ui/overlays/dialog/confirmation-dialog';
import {Trans} from '../i18n/trans';
interface Props {
onConfirm?: () => void;
isLoading?: boolean;
}
export function LeaveWorkspaceConfirmation({onConfirm, isLoading}: Props) {
return (
<ConfirmationDialog
isDanger
title={<Trans message="Leave workspace" />}
isLoading={isLoading}
onConfirm={onConfirm}
body={
<div>
<Trans message="Are you sure you want to leave this workspace?" />
<div className="mt-8 font-semibold">
<Trans message="All resources you've created in the workspace will be transferred to workspace owner." />
</div>
</div>
}
confirm={<Trans message="Leave" />}
/>
);
}

View File

@@ -0,0 +1,60 @@
import {useForm} from 'react-hook-form';
import {FormTextField} from '../ui/forms/input-field/text-field/text-field';
import {Form} from '../ui/forms/form';
import {Button} from '../ui/buttons/button';
import {useCreateWorkspace} from './requests/create-workspace';
import {DialogFooter} from '../ui/overlays/dialog/dialog-footer';
import {useDialogContext} from '../ui/overlays/dialog/dialog-context';
import {Dialog} from '../ui/overlays/dialog/dialog';
import {DialogHeader} from '../ui/overlays/dialog/dialog-header';
import {DialogBody} from '../ui/overlays/dialog/dialog-body';
import {Trans} from '../i18n/trans';
export function NewWorkspaceDialog() {
const form = useForm<{name: string}>();
const {formId, close} = useDialogContext();
const createWorkspace = useCreateWorkspace(form);
return (
<Dialog>
<DialogHeader>
<Trans message="Create workspace" />
</DialogHeader>
<DialogBody>
<Form
form={form}
id={formId}
onSubmit={() => {
createWorkspace.mutate(form.getValues(), {
onSuccess: response => {
close(response.workspace.id);
},
});
}}
>
<FormTextField
name="name"
autoFocus
label={<Trans message="Workspace name" />}
minLength={3}
required
/>
</Form>
</DialogBody>
<DialogFooter>
<Button variant="text" onClick={close}>
<Trans message="Cancel" />
</Button>
<Button
variant="flat"
color="primary"
type="submit"
form={formId}
disabled={createWorkspace.isPending}
>
<Trans message="Create" />
</Button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,44 @@
import {
NotificationListItem,
NotificationListItemProps,
} from '../../notifications/notification-list';
import {
DatabaseNotification,
DatabaseNotificationData,
} from '../../notifications/database-notification';
import {useJoinWorkspace} from '../requests/join-workspace';
import {useDeleteInvite} from '../requests/delete-invite';
import {useDialogContext} from '../../ui/overlays/dialog/dialog-context';
export interface WorkspaceInviteNotification extends DatabaseNotification {
data: DatabaseNotificationData & {inviteId: string};
}
export function WorkspaceInviteNotificationRenderer(
props: NotificationListItemProps
) {
const {notification} = props;
const joinWorkspace = useJoinWorkspace();
const deleteInvite = useDeleteInvite();
const dialogContextValue = useDialogContext();
return (
<NotificationListItem
{...props}
onActionButtonClick={(e, {action}) => {
const data = (notification as WorkspaceInviteNotification).data;
if (action === 'join') {
joinWorkspace.mutate({
inviteId: data.inviteId,
});
}
if (action === 'decline') {
deleteInvite.mutate({
inviteId: data.inviteId,
});
}
dialogContextValue?.close();
}}
/>
);
}

View File

@@ -0,0 +1,65 @@
import {useForm} from 'react-hook-form';
import {FormTextField} from '../ui/forms/input-field/text-field/text-field';
import {Form} from '../ui/forms/form';
import {Button} from '../ui/buttons/button';
import {DialogFooter} from '../ui/overlays/dialog/dialog-footer';
import {useDialogContext} from '../ui/overlays/dialog/dialog-context';
import {Dialog} from '../ui/overlays/dialog/dialog';
import {DialogHeader} from '../ui/overlays/dialog/dialog-header';
import {DialogBody} from '../ui/overlays/dialog/dialog-body';
import {
UpdateWorkspacePayload,
useUpdateWorkspace,
} from './requests/update-workspace';
import {Workspace} from './types/workspace';
import {Trans} from '../i18n/trans';
interface Props {
workspace: Workspace;
}
export function RenameWorkspaceDialog({workspace}: Props) {
const form = useForm<UpdateWorkspacePayload>({
defaultValues: {id: workspace.id, name: workspace.name},
});
const {formId, close} = useDialogContext();
const updateWorkspace = useUpdateWorkspace(form);
return (
<Dialog>
<DialogHeader>
<Trans message="Rename workspace" />
</DialogHeader>
<DialogBody>
<Form
form={form}
id={formId}
onSubmit={() => {
updateWorkspace.mutate(form.getValues());
}}
>
<FormTextField
name="name"
autoFocus
label={<Trans message="Name" />}
minLength={3}
required
/>
</Form>
</DialogBody>
<DialogFooter>
<Button variant="text" onClick={close}>
<Trans message="Cancel" />
</Button>
<Button
variant="flat"
color="primary"
type="submit"
form={formId}
disabled={updateWorkspace.isPending}
>
<Trans message="Rename" />
</Button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,42 @@
import {useMutation} from '@tanstack/react-query';
import {BackendResponse} from '../../http/backend-response/backend-response';
import {toast} from '../../ui/toast/toast';
import {apiClient, queryClient} from '../../http/query-client';
import {WorkspaceQueryKeys} from './workspace-query-keys';
import {WorkspaceMember} from '../types/workspace-member';
import {WorkspaceInvite} from '../types/workspace-invite';
import {message} from '../../i18n/message';
import {showHttpErrorToast} from '../../utils/http/show-http-error-toast';
interface Response extends BackendResponse {}
interface Props {
workspaceId: number;
member: WorkspaceMember | WorkspaceInvite;
roleId: number;
}
function ChangeRole({workspaceId, member, ...other}: Props): Promise<Response> {
const modelType = member.model_type;
const memberId =
member.model_type === 'invite' ? member.id : member.member_id;
return apiClient
.post(
`workspace/${workspaceId}/${modelType}/${memberId}/change-role`,
other,
)
.then(r => r.data);
}
export function useChangeRole() {
return useMutation({
mutationFn: (props: Props) => ChangeRole(props),
onSuccess: (response, props) => {
toast(message('Role changed'));
queryClient.invalidateQueries({
queryKey: WorkspaceQueryKeys.workspaceWithMembers(props.workspaceId),
});
},
onError: err => showHttpErrorToast(err),
});
}

View File

@@ -0,0 +1,34 @@
import {useMutation} from '@tanstack/react-query';
import {UseFormReturn} from 'react-hook-form';
import {BackendResponse} from '../../http/backend-response/backend-response';
import {toast} from '../../ui/toast/toast';
import {apiClient, queryClient} from '../../http/query-client';
import {WorkspaceQueryKeys} from './workspace-query-keys';
import {Workspace} from '../types/workspace';
import {onFormQueryError} from '../../errors/on-form-query-error';
import {message} from '../../i18n/message';
interface Response extends BackendResponse {
workspace: Workspace;
}
interface Props {
name: string;
}
export function useCreateWorkspace(form: UseFormReturn<Props>) {
return useMutation({
mutationFn: (props: Props) => createWorkspace(props),
onSuccess: () => {
toast(message('Created workspace'));
queryClient.invalidateQueries({
queryKey: WorkspaceQueryKeys.fetchUserWorkspaces,
});
},
onError: r => onFormQueryError(r, form),
});
}
function createWorkspace(props: Props): Promise<Response> {
return apiClient.post('workspace', props).then(r => r.data);
}

View File

@@ -0,0 +1,36 @@
import axios from 'axios';
import {useMutation} from '@tanstack/react-query';
import {BackendResponse} from '../../http/backend-response/backend-response';
import {toast} from '../../ui/toast/toast';
import {apiClient, queryClient} from '../../http/query-client';
import {useUserNotifications} from '../../notifications/dialog/requests/user-notifications';
import {message} from '../../i18n/message';
import {showHttpErrorToast} from '../../utils/http/show-http-error-toast';
interface Response extends BackendResponse {}
interface Props {
inviteId: string;
}
function deleteInvite({inviteId}: Props): Promise<Response> {
return apiClient.delete(`workspace/invite/${inviteId}`).then(r => r.data);
}
export function useDeleteInvite() {
return useMutation({
mutationFn: (props: Props) => deleteInvite(props),
onSuccess: () => {
queryClient.invalidateQueries({queryKey: useUserNotifications.key});
toast(message('Declined workspace invitation'));
},
onError: e => {
if (axios.isAxiosError(e) && e.response && e.response.status === 404) {
queryClient.invalidateQueries({queryKey: useUserNotifications.key});
toast.danger(message('This invite is no longer valid'));
} else {
showHttpErrorToast(e);
}
},
});
}

View File

@@ -0,0 +1,41 @@
import {useMutation} from '@tanstack/react-query';
import {BackendResponse} from '../../http/backend-response/backend-response';
import {toast} from '../../ui/toast/toast';
import {apiClient, queryClient} from '../../http/query-client';
import {WorkspaceQueryKeys} from './workspace-query-keys';
import {useActiveWorkspaceId} from '../active-workspace-id-context';
import {PersonalWorkspace} from '../user-workspaces';
import {message} from '../../i18n/message';
import {showHttpErrorToast} from '../../utils/http/show-http-error-toast';
interface Response extends BackendResponse {}
export interface DeleteWorkspacePayload {
id: number;
}
function deleteWorkspace({id}: DeleteWorkspacePayload): Promise<Response> {
return apiClient.delete(`workspace/${id}`).then(r => r.data);
}
export function useDeleteWorkspace() {
const {workspaceId, setWorkspaceId} = useActiveWorkspaceId();
return useMutation({
mutationFn: (props: DeleteWorkspacePayload) => deleteWorkspace(props),
onSuccess: (r, payload) => {
toast(message('Deleted workspace'));
queryClient.invalidateQueries({
queryKey: WorkspaceQueryKeys.fetchUserWorkspaces,
});
queryClient.invalidateQueries({
queryKey: WorkspaceQueryKeys.workspaceWithMembers(payload.id),
});
// if user deleted workspace that is currently active, switch to personal workspace
if (workspaceId === payload.id) {
setWorkspaceId(PersonalWorkspace.id);
}
},
onError: err => showHttpErrorToast(err),
});
}

View File

@@ -0,0 +1,34 @@
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '../../http/query-client';
import {BackendResponse} from '../../http/backend-response/backend-response';
import {WorkspaceInvite} from '../types/workspace-invite';
import {WorkspaceQueryKeys} from './workspace-query-keys';
import {showHttpErrorToast} from '../../utils/http/show-http-error-toast';
interface Response extends BackendResponse {
invites: WorkspaceInvite[];
}
interface Props {
workspaceId: number;
emails: string[];
roleId: number;
}
function InviteMembers({workspaceId, ...other}: Props): Promise<Response> {
return apiClient
.post(`workspace/${workspaceId}/invite`, other)
.then(r => r.data);
}
export function useInviteMembers() {
return useMutation({
mutationFn: (props: Props) => InviteMembers(props),
onSuccess: (response, props) => {
queryClient.invalidateQueries({
queryKey: WorkspaceQueryKeys.workspaceWithMembers(props.workspaceId),
});
},
onError: err => showHttpErrorToast(err),
});
}

View File

@@ -0,0 +1,46 @@
import axios from 'axios';
import {useMutation} from '@tanstack/react-query';
import {BackendResponse} from '../../http/backend-response/backend-response';
import {toast} from '../../ui/toast/toast';
import {apiClient, queryClient} from '../../http/query-client';
import {Workspace} from '../types/workspace';
import {WorkspaceQueryKeys} from './workspace-query-keys';
import {useActiveWorkspaceId} from '../active-workspace-id-context';
import {useUserNotifications} from '../../notifications/dialog/requests/user-notifications';
import {message} from '../../i18n/message';
import {showHttpErrorToast} from '../../utils/http/show-http-error-toast';
interface Response extends BackendResponse {
workspace: Workspace;
}
interface Props {
inviteId: string;
}
export function useJoinWorkspace() {
const {setWorkspaceId} = useActiveWorkspaceId() || {};
return useMutation({
mutationFn: (props: Props) => joinWorkspace(props),
onSuccess: response => {
toast(message('Joined workspace'));
setWorkspaceId(response.workspace.id);
queryClient.invalidateQueries({
queryKey: WorkspaceQueryKeys.fetchUserWorkspaces,
});
queryClient.invalidateQueries({queryKey: useUserNotifications.key});
},
onError: e => {
if (axios.isAxiosError(e) && e.response && e.response.status === 404) {
queryClient.invalidateQueries({queryKey: useUserNotifications.key});
toast.danger(message('This invite is no longer valid'));
} else {
showHttpErrorToast(e);
}
},
});
}
function joinWorkspace({inviteId}: Props): Promise<Response> {
return apiClient.get(`workspace/join/${inviteId}`).then(r => r.data);
}

View File

@@ -0,0 +1,50 @@
import {useMutation} from '@tanstack/react-query';
import {BackendResponse} from '../../http/backend-response/backend-response';
import {apiClient, queryClient} from '../../http/query-client';
import {WorkspaceQueryKeys} from './workspace-query-keys';
import {useAuth} from '../../auth/use-auth';
import {useActiveWorkspaceId} from '../active-workspace-id-context';
import {PersonalWorkspace} from '../user-workspaces';
import {showHttpErrorToast} from '../../utils/http/show-http-error-toast';
interface Response extends BackendResponse {}
interface Props {
workspaceId: number;
memberId: number | string;
memberType: 'invite' | 'member';
}
function removeMember({
workspaceId,
memberId,
memberType,
}: Props): Promise<Response> {
const endpoint =
memberType === 'invite'
? `workspace/invite/${memberId}`
: `workspace/${workspaceId}/member/${memberId}`;
return apiClient.delete(endpoint).then(r => r.data);
}
export function useRemoveMember() {
const {workspaceId, setWorkspaceId} = useActiveWorkspaceId();
const {user} = useAuth();
return useMutation({
mutationFn: (props: Props) => removeMember(props),
onSuccess: (response, props) => {
queryClient.invalidateQueries({
queryKey: WorkspaceQueryKeys.fetchUserWorkspaces,
});
queryClient.invalidateQueries({
queryKey: WorkspaceQueryKeys.workspaceWithMembers(props.workspaceId),
});
// if user left workspace that is currently active, switch to personal workspace
if (props.memberId === user?.id && workspaceId === props.workspaceId) {
setWorkspaceId(PersonalWorkspace.id);
}
},
onError: err => showHttpErrorToast(err),
});
}

View File

@@ -0,0 +1,35 @@
import {useMutation} from '@tanstack/react-query';
import {BackendResponse} from '../../http/backend-response/backend-response';
import {WorkspaceInvite} from '../types/workspace-invite';
import {toast} from '../../ui/toast/toast';
import {apiClient} from '../../http/query-client';
import {showHttpErrorToast} from '../../utils/http/show-http-error-toast';
interface Response extends BackendResponse {
invite: WorkspaceInvite;
}
interface Props {
workspaceId: number;
inviteId: string;
}
function ResendInvite({
workspaceId,
inviteId,
...other
}: Props): Promise<Response> {
return apiClient
.post(`workspace/${workspaceId}/${inviteId}/resend`, other)
.then(r => r.data);
}
export function useResendInvite() {
return useMutation({
mutationFn: (props: Props) => ResendInvite(props),
onSuccess: () => {
toast('Invite sent');
},
onError: err => showHttpErrorToast(err),
});
}

View File

@@ -0,0 +1,48 @@
import {useMutation} from '@tanstack/react-query';
import {UseFormReturn} from 'react-hook-form';
import {BackendResponse} from '../../http/backend-response/backend-response';
import {toast} from '../../ui/toast/toast';
import {apiClient, queryClient} from '../../http/query-client';
import {WorkspaceQueryKeys} from './workspace-query-keys';
import {Workspace} from '../types/workspace';
import {onFormQueryError} from '../../errors/on-form-query-error';
import {useDialogContext} from '../../ui/overlays/dialog/dialog-context';
import {message} from '../../i18n/message';
interface Response extends BackendResponse {
workspace: Workspace;
}
export interface UpdateWorkspacePayload {
id: number;
name: string;
}
function updateWorkspace({
id,
...props
}: UpdateWorkspacePayload): Promise<Response> {
return apiClient.put(`workspace/${id}`, props).then(r => r.data);
}
export function useUpdateWorkspace(
form: UseFormReturn<UpdateWorkspacePayload>,
) {
const {close} = useDialogContext();
return useMutation({
mutationFn: (props: UpdateWorkspacePayload) => updateWorkspace(props),
onSuccess: response => {
close();
toast(message('Updated workspace'));
queryClient.invalidateQueries({
queryKey: WorkspaceQueryKeys.fetchUserWorkspaces,
});
queryClient.invalidateQueries({
queryKey: WorkspaceQueryKeys.workspaceWithMembers(
response.workspace.id,
),
});
},
onError: r => onFormQueryError(r, form),
});
}

View File

@@ -0,0 +1,4 @@
export const WorkspaceQueryKeys = {
fetchUserWorkspaces: ['user-workspaces'],
workspaceWithMembers: (id: number) => ['workspace-with-members', id],
};

View File

@@ -0,0 +1,24 @@
import {useQuery} from '@tanstack/react-query';
import {WorkspaceQueryKeys} from './workspace-query-keys';
import {Workspace} from '../types/workspace';
import {BackendResponse} from '../../http/backend-response/backend-response';
import {apiClient} from '../../http/query-client';
export interface FetchWorkspaceWithMembersResponse extends BackendResponse {
workspace: Workspace;
}
function fetchWorkspaceWithMembers(
workspaceId: number,
): Promise<FetchWorkspaceWithMembersResponse> {
return apiClient
.get(`workspace/${workspaceId}`)
.then(response => response.data);
}
export function useWorkspaceWithMembers(workspaceId: number) {
return useQuery({
queryKey: WorkspaceQueryKeys.workspaceWithMembers(workspaceId),
queryFn: () => fetchWorkspaceWithMembers(workspaceId),
});
}

View File

@@ -0,0 +1,11 @@
export interface WorkspaceInvite {
id: string;
email: string;
image: string;
created_at: string;
model_type: 'invite';
role_id: number;
role_name: string;
display_name: string;
avatar: string;
}

View File

@@ -0,0 +1,14 @@
import {Permission} from '../../auth/permission';
export interface WorkspaceMember {
id: number;
member_id: number;
email: string;
role_name: string;
role_id: number;
avatar: string;
display_name: string;
model_type: 'member';
is_owner: boolean;
permissions?: Permission[];
}

View File

@@ -0,0 +1,16 @@
import {WorkspaceInvite} from './workspace-invite';
import {WorkspaceMember} from './workspace-member';
export interface Workspace {
id: number;
name: string;
invites?: WorkspaceInvite[];
members?: WorkspaceMember[];
members_count?: number;
owner?: WorkspaceMember;
owner_id?: number;
currentUser?: WorkspaceMember;
default?: boolean;
created_at?: string;
updated_at?: string;
}

View File

@@ -0,0 +1,33 @@
import {useQuery} from '@tanstack/react-query';
import {WorkspaceQueryKeys} from './requests/workspace-query-keys';
import {Workspace} from './types/workspace';
import {BackendResponse} from '../http/backend-response/backend-response';
import {apiClient} from '../http/query-client';
export interface FetchUserWorkspacesResponse extends BackendResponse {
workspaces: Workspace[];
}
export const PersonalWorkspace: Workspace = {
name: 'Default',
default: true,
id: 0,
members_count: 1,
};
function fetchUserWorkspaces(): Promise<FetchUserWorkspacesResponse> {
return apiClient.get(`me/workspaces`).then(response => response.data);
}
function addPersonalWorkspaceToResponse(response: FetchUserWorkspacesResponse) {
return [PersonalWorkspace, ...response.workspaces];
}
export function useUserWorkspaces() {
return useQuery({
queryKey: WorkspaceQueryKeys.fetchUserWorkspaces,
queryFn: fetchUserWorkspaces,
placeholderData: {workspaces: []},
select: addPersonalWorkspaceToResponse,
});
}

View File

@@ -0,0 +1,476 @@
import {useEffect, useState} from 'react';
import {AnimatePresence, m} from 'framer-motion';
import {useWorkspaceWithMembers} from './requests/workspace-with-members';
import {ProgressCircle} from '../ui/progress/progress-circle';
import {Workspace} from './types/workspace';
import {GroupIcon} from '../icons/material/Group';
import {WorkspaceMember} from './types/workspace-member';
import {useAuth} from '../auth/use-auth';
import {
ChipField,
ChipValue,
} from '../ui/forms/input-field/chip-field/chip-field';
import {useValueLists} from '../http/value-lists';
import {Button} from '../ui/buttons/button';
import {ArrowDropDownIcon} from '../icons/material/ArrowDropDown';
import {useInviteMembers} from './requests/invite-members';
import {WorkspaceInvite} from './types/workspace-invite';
import {ConfirmationDialog} from '../ui/overlays/dialog/confirmation-dialog';
import {useResendInvite} from './requests/resend-invite';
import {isEmail} from '../utils/string/is-email';
import {ButtonSize} from '../ui/buttons/button-size';
import {useChangeRole} from './requests/change-role';
import {IconButton} from '../ui/buttons/icon-button';
import {useRemoveMember} from './requests/remove-member';
import {CloseIcon} from '../icons/material/Close';
import {ExitToAppIcon} from '../icons/material/ExitToApp';
import {toast} from '../ui/toast/toast';
import {DialogTrigger} from '../ui/overlays/dialog/dialog-trigger';
import {Menu, MenuItem, MenuTrigger} from '../ui/navigation/menu/menu-trigger';
import {useDialogContext} from '../ui/overlays/dialog/dialog-context';
import {Dialog} from '../ui/overlays/dialog/dialog';
import {DialogHeader} from '../ui/overlays/dialog/dialog-header';
import {DialogBody} from '../ui/overlays/dialog/dialog-body';
import {Trans} from '../i18n/trans';
import {useTrans} from '../i18n/use-trans';
import {message} from '../i18n/message';
import {LeaveWorkspaceConfirmation} from './leave-workspace-confirmation';
interface WorkspaceMembersDialogProps {
workspace: Workspace;
}
export function WorkspaceMembersDialog({
workspace,
}: WorkspaceMembersDialogProps) {
const {data, isLoading} = useWorkspaceWithMembers(workspace.id);
return (
<Dialog size="lg">
<DialogHeader>
<Trans message="Manage workspace members" />
</DialogHeader>
<DialogBody>
{isLoading ? (
<div className="flex min-h-[238px] items-center justify-center">
<ProgressCircle isIndeterminate aria-label="Loading workspace..." />
</div>
) : (
<Manager workspace={data!.workspace} />
)}
</DialogBody>
</Dialog>
);
}
interface ManagerProps {
workspace: Workspace;
}
function Manager({workspace}: ManagerProps) {
const {user} = useAuth();
const can = usePermissions(workspace);
const members: (WorkspaceMember | WorkspaceInvite)[] = [
...(workspace.members || []),
...(workspace.invites || []),
];
const shouldHideOtherMembers = !can.update && !can.delete;
return (
<div>
{can.invite && <InviteChipField workspace={workspace} />}
<div className="mb-14 flex items-center gap-10 text-base">
<GroupIcon className="icon-sm" />
<Trans
message="Members of `:workspace`"
values={{workspace: workspace.name}}
/>
</div>
<AnimatePresence initial={false}>
{members.map(member => {
if (shouldHideOtherMembers && member.id !== user?.id) {
return null;
}
return (
<MemberListItem
key={`${member.model_type}.${member.id}`}
workspace={workspace}
member={member}
/>
);
})}
{shouldHideOtherMembers && (
<div className="text-muted">
<Trans
message="And [one one other member|:count other members]"
values={{count: members.length}}
/>
</div>
)}
</AnimatePresence>
</div>
);
}
interface MemberListItemProps {
member: WorkspaceMember | WorkspaceInvite;
workspace: Workspace;
}
function MemberListItem({workspace, member}: MemberListItemProps) {
return (
<m.div
initial={{x: '-100%', opacity: 0}}
animate={{x: 0, opacity: 1}}
exit={{x: '100%', opacity: 0}}
transition={{type: 'tween', duration: 0.125}}
className="mb-20 flex items-start gap-14 text-sm"
key={`${member.model_type}.${member.id}`}
>
<img
className="h-36 w-36 flex-shrink-0 rounded"
src={member.avatar}
alt=""
/>
<div className="min-w-0 flex-auto items-center justify-between gap-14 md:flex">
<div className="mb-10 overflow-hidden md:mb-0 md:mr-10">
<div className="flex items-center justify-start gap-6">
<div className="overflow-hidden text-ellipsis whitespace-nowrap">
{member.display_name}
</div>
<MemberDisplayNameAppend workspace={workspace} member={member} />
</div>
<div className="text-muted">{member.email}</div>
</div>
<MemberActions workspace={workspace} member={member} />
</div>
</m.div>
);
}
function usePermissions(workspace: Workspace) {
const {user: authUser} = useAuth();
const response = {update: false, invite: false, delete: false};
const permissions = ['update', 'invite', 'delete'] as const;
const authMember = workspace.members?.find(mb => mb.id === authUser?.id);
if (authMember) {
permissions.forEach(permission => {
response[permission] =
authMember.is_owner ||
!!authMember.permissions?.find(
p => p.name === `workspace_members.${permission}`,
);
});
}
return response;
}
interface MemberActionsProps {
workspace: Workspace;
member: WorkspaceMember | WorkspaceInvite;
}
function MemberActions({workspace, member}: MemberActionsProps) {
const [selectedRole, setSelectedRole] = useState<number>(member.role_id);
const changeRole = useChangeRole();
const {user} = useAuth();
const can = usePermissions(workspace);
const isOwner = member.model_type === 'member' && member.is_owner;
const isCurrentUser =
member.model_type === 'member' && user?.id === member.id;
const roleSelector =
!can.update || isOwner || isCurrentUser ? (
<div className="ml-auto text-muted first:capitalize">
<Trans message={member.role_name} />
</div>
) : (
<RoleMenuTrigger
className="ml-auto flex-shrink-0"
size="xs"
value={selectedRole}
isDisabled={changeRole.isPending}
onChange={roleId => {
setSelectedRole(roleId);
changeRole.mutate({
roleId,
workspaceId: workspace.id,
member,
});
}}
/>
);
return (
<>
{roleSelector}
{!isOwner && (isCurrentUser || can.delete) && (
<RemoveMemberButton
type={isCurrentUser ? 'leave' : 'remove'}
member={member}
workspace={workspace}
/>
)}
</>
);
}
interface InviteChipFieldProps {
workspace: Workspace;
}
function InviteChipField({workspace}: InviteChipFieldProps) {
const {trans} = useTrans();
const [chips, setChips] = useState<ChipValue[]>([]);
const allEmailsValid = chips.every(chip => !chip.invalid);
const displayWith = (chip: ChipValue) => chip.description || chip.name;
const [selectedRole, setSelectedRole] = useState<number>();
const inviteMembers = useInviteMembers();
const {data} = useValueLists(['workspaceRoles']);
useEffect(() => {
if (!selectedRole && data?.workspaceRoles?.length) {
setSelectedRole(data.workspaceRoles[0].id);
}
}, [data, selectedRole]);
return (
<div className="mb-30">
<ChipField
value={chips}
onChange={setChips}
displayWith={displayWith}
validateWith={chip => {
const invalid = !isEmail(chip.description);
return {
...chip,
invalid,
errorMessage: invalid
? trans({message: 'Not a valid email'})
: undefined,
};
}}
placeholder={trans({message: 'Enter email addresses'})}
label={<Trans message="Invite people" />}
/>
<div className="mt-14 flex items-center justify-between gap-14">
<RoleMenuTrigger onChange={setSelectedRole} value={selectedRole} />
{chips.length && selectedRole ? (
<Button
variant="flat"
color="primary"
size="sm"
disabled={inviteMembers.isPending || !allEmailsValid}
onClick={() => {
inviteMembers.mutate(
{
emails: chips.map(c => displayWith(c)),
roleId: selectedRole,
workspaceId: workspace.id,
},
{
onSuccess: () => {
setChips([]);
},
},
);
}}
>
<Trans message="Invite" />
</Button>
) : null}
</div>
</div>
);
}
interface RemoveMemberButtonProps {
member: WorkspaceMember | WorkspaceInvite;
workspace: Workspace;
type: 'leave' | 'remove';
}
function RemoveMemberButton({
member,
workspace,
type,
}: RemoveMemberButtonProps) {
const removeMember = useRemoveMember();
const {close} = useDialogContext();
return (
<DialogTrigger
type="modal"
onClose={isConfirmed => {
if (isConfirmed) {
removeMember.mutate({
workspaceId: workspace.id,
memberId: member.id,
memberType: member.model_type,
});
if (type === 'leave') {
close();
toast(message('Left workspace'));
}
}
}}
>
<IconButton
size="md"
className="flex-shrink-0 text-muted"
disabled={removeMember.isPending}
>
{type === 'leave' ? <ExitToAppIcon /> : <CloseIcon />}
</IconButton>
{type === 'leave' ? (
<LeaveWorkspaceConfirmation />
) : (
<RemoveMemberConfirmation member={member} />
)}
</DialogTrigger>
);
}
interface RemoveMemberConfirmationProps {
member: WorkspaceMember | WorkspaceInvite;
}
function RemoveMemberConfirmation({member}: RemoveMemberConfirmationProps) {
return (
<ConfirmationDialog
isDanger
title={<Trans message="Remove member" />}
body={
<div>
<Trans
message="Are you sure you want to remove `:name`?"
values={{name: member.display_name}}
/>
<div className="mt-8 font-semibold">
<Trans
message="All workspace resources created by `:name` will be transferred to workspace owner."
values={{
name: member.display_name,
}}
/>
</div>
</div>
}
confirm={<Trans message="Remove" />}
/>
);
}
interface RoleMenuTriggerProps {
onChange: (value: number) => void;
value?: number;
size?: ButtonSize;
className?: string;
isDisabled?: boolean;
}
function RoleMenuTrigger({
value,
onChange,
size = 'xs',
className,
isDisabled,
}: RoleMenuTriggerProps) {
const {data} = useValueLists(['workspaceRoles']);
const role = data?.workspaceRoles?.find(r => r.id === value);
if (!value || !role || !data?.workspaceRoles) return null;
return (
<MenuTrigger
selectionMode="single"
selectedValue={value}
onSelectionChange={newValue => {
onChange(newValue as number);
}}
>
<Button
className={className}
size={size}
variant="flat"
color="chip"
disabled={isDisabled}
endIcon={<ArrowDropDownIcon />}
>
<Trans message={role.name} />
</Button>
<Menu>
{data.workspaceRoles.map(r => (
<MenuItem value={r.id} key={r.id} description={r.description}>
<Trans message={r.name} />
</MenuItem>
))}
</Menu>
</MenuTrigger>
);
}
interface MemberDisplayNameAppendProps {
member: WorkspaceMember | WorkspaceInvite;
workspace: Workspace;
}
function MemberDisplayNameAppend({
member,
workspace,
}: MemberDisplayNameAppendProps) {
const {user} = useAuth();
const can = usePermissions(workspace);
if (user?.id === member.id) {
return (
<div className="font-medium">
(<Trans message="You" />)
</div>
);
}
if (member.model_type === 'invite') {
return (
<div className="flex items-center gap-4">
<div>·</div>
<div className="font-medium">
<Trans message="Invited" />
</div>
{can.invite ? (
<>
<div>·</div>
<ResendInviteDialogTrigger member={member} workspace={workspace} />
</>
) : null}
</div>
);
}
return null;
}
function ResendInviteDialogTrigger({
member,
workspace,
}: MemberDisplayNameAppendProps) {
const resendInvite = useResendInvite();
return (
<DialogTrigger
type="modal"
onClose={isConfirmed => {
if (isConfirmed) {
resendInvite.mutate({
workspaceId: workspace.id,
inviteId: member.id as string,
});
}
}}
>
<Button
variant="link"
size="sm"
color="primary"
disabled={resendInvite.isPending}
>
<Trans message="Resend invite" />
</Button>
<ConfirmationDialog
title={<Trans message="Resend invite" />}
body={
<Trans message="Are you sure you want to send this invite again?" />
}
confirm={<Trans message="Send" />}
/>
</DialogTrigger>
);
}

View File

@@ -0,0 +1,419 @@
import clsx from 'clsx';
import {
cloneElement,
forwardRef,
Fragment,
ReactElement,
useEffect,
useState,
} from 'react';
import {ButtonBase} from '../ui/buttons/button-base';
import {PersonalWorkspace, useUserWorkspaces} from './user-workspaces';
import {UnfoldMoreIcon} from '../icons/material/UnfoldMore';
import {AddIcon} from '../icons/material/Add';
import {NewWorkspaceDialog} from './new-workspace-dialog';
import {WorkspaceMembersDialog} from './workspace-members-dialog';
import {
useActiveWorkspace,
useActiveWorkspaceId,
} from './active-workspace-id-context';
import {DialogTrigger} from '../ui/overlays/dialog/dialog-trigger';
import {Workspace} from './types/workspace';
import {Dialog} from '../ui/overlays/dialog/dialog';
import {DialogBody} from '../ui/overlays/dialog/dialog-body';
import {Button, ButtonProps} from '../ui/buttons/button';
import {CheckIcon} from '../icons/material/Check';
import {Menu, MenuItem, MenuTrigger} from '../ui/navigation/menu/menu-trigger';
import {KeyboardArrowDownIcon} from '../icons/material/KeyboardArrowDown';
import {PersonAddIcon} from '../icons/material/PersonAdd';
import {DeleteIcon} from '../icons/material/Delete';
import {ExitToAppIcon} from '../icons/material/ExitToApp';
import {EditIcon} from '../icons/material/Edit';
import {RenameWorkspaceDialog} from './rename-workspace-dialog';
import {ConfirmationDialog} from '../ui/overlays/dialog/confirmation-dialog';
import {useDeleteWorkspace} from './requests/delete-workspace';
import {useRemoveMember} from './requests/remove-member';
import {useAuth} from '../auth/use-auth';
import {Trans} from '../i18n/trans';
import {LeaveWorkspaceConfirmation} from './leave-workspace-confirmation';
import {openDialog} from '@common/ui/overlays/store/dialog-store';
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
import {PolicyFailMessage} from '@common/billing/upgrade/policy-fail-message';
interface WorkspaceSelectorProps {
className?: string;
onChange?: (id: number) => void;
trigger?: ReactElement<ButtonProps>;
placement?: 'top' | 'bottom';
}
export function WorkspaceSelector({
onChange,
className,
trigger,
placement = 'top',
}: WorkspaceSelectorProps) {
const {data: workspaces, isFetched, isFetching} = useUserWorkspaces();
const {setWorkspaceId} = useActiveWorkspaceId();
const activeWorkspace = useActiveWorkspace();
const [selectorIsOpen, setSelectorIsOpen] = useState(false);
const {hasPermission} = useAuth();
// if user no longer has access to previously selected workspace, select personal one
useEffect(() => {
// make sure we don't unset active workspace while user workspaces are being re-fetched
if (isFetched && !isFetching && !activeWorkspace) {
setWorkspaceId(PersonalWorkspace.id);
}
}, [activeWorkspace, workspaces, setWorkspaceId, isFetched, isFetching]);
if (
// if we have a custom trigger, leave rendering up to the customer trigger
!trigger &&
(!activeWorkspace ||
(!hasPermission('workspaces.create') && workspaces?.length === 1))
) {
return null;
}
return (
<Fragment>
<DialogTrigger
type="popover"
placement={placement}
isOpen={selectorIsOpen}
onClose={() => {
setSelectorIsOpen(false);
}}
>
{trigger ? (
cloneElement(trigger, {
onClick: () => setSelectorIsOpen(!selectorIsOpen),
})
) : (
<DefaultTrigger
onClick={() => setSelectorIsOpen(!selectorIsOpen)}
workspace={activeWorkspace!}
className={className}
/>
)}
<Dialog size="min-w-320">
<DialogBody padding="p-10">
<div className="mb-16 border-b pb-10">
{workspaces?.map(workspace => (
<WorkspaceItem
key={workspace.id}
workspace={workspace}
setSelectorIsOpen={setSelectorIsOpen}
onChange={onChange}
/>
))}
</div>
<div className="mb-4 px-4 text-center">
<CreateWorkspaceButton
onClick={() => setSelectorIsOpen(false)}
onCreated={id => onChange?.(id)}
workspaceCount={workspaces ? workspaces.length - 1 : 0}
/>
</div>
</DialogBody>
</Dialog>
</DialogTrigger>
</Fragment>
);
}
interface CreateWorkspaceButtonProps {
onClick: () => void;
onCreated?: (id: number) => void;
workspaceCount: number;
}
function CreateWorkspaceButton({
onClick,
onCreated,
workspaceCount,
}: CreateWorkspaceButtonProps) {
const {setWorkspaceId} = useActiveWorkspaceId();
const {checkOverQuotaOrNoPermission} = useAuth();
const {overQuotaOrNoPermission} = checkOverQuotaOrNoPermission(
'workspaces.create',
'count',
workspaceCount,
);
return (
<Fragment>
<Button
disabled={overQuotaOrNoPermission}
onClick={async e => {
e.preventDefault();
e.stopPropagation();
onClick();
const workspaceId = await openDialog(NewWorkspaceDialog);
if (workspaceId) {
setWorkspaceId(workspaceId);
onCreated?.(workspaceId);
}
}}
variant="outline"
startIcon={<AddIcon />}
color="primary"
className="h-40 w-full"
>
<Trans message="Create new workspace" />
</Button>
{overQuotaOrNoPermission && (
<PolicyFailMessage
size="sm"
className="mt-12 max-w-288"
resourceName={<Trans message="worksapces" />}
/>
)}
</Fragment>
);
}
interface DefaultTriggerProps {
onClick: () => void;
workspace: Workspace;
className?: string;
}
const DefaultTrigger = forwardRef<HTMLButtonElement, DefaultTriggerProps>(
({workspace, className, onClick, ...other}, ref) => {
return (
<ButtonBase
ref={ref}
onClick={onClick}
className={clsx(
'flex items-center gap-10 rounded ring-inset hover:bg-hover focus-visible:ring-2',
className,
)}
{...other}
>
<span className="mr-auto block flex-auto overflow-hidden text-left">
<span className="block overflow-hidden overflow-ellipsis text-sm font-medium text-main">
{workspace.default ? (
<Trans message={workspace.name} />
) : (
workspace.name
)}
</span>
<span className="block text-xs text-muted">
{workspace.default ? (
<Trans message="Personal workspace" />
) : (
<Trans
message=":count members"
values={{count: workspace.members_count}}
/>
)}
</span>
</span>
<UnfoldMoreIcon className="shrink-0 icon-md" />
</ButtonBase>
);
},
);
interface WorkspaceItemProps {
workspace: Workspace;
onChange: WorkspaceSelectorProps['onChange'];
setSelectorIsOpen: (value: boolean) => void;
}
function WorkspaceItem({
workspace,
onChange,
setSelectorIsOpen,
}: WorkspaceItemProps) {
const {workspaceId, setWorkspaceId} = useActiveWorkspaceId();
const isActive = workspaceId === workspace.id;
return (
<div
onClick={() => {
setWorkspaceId(workspace.id);
onChange?.(workspace.id);
setSelectorIsOpen(false);
}}
className={clsx(
'mb-4 flex cursor-pointer items-center gap-12 rounded-lg p-10 text-left',
isActive && 'bg-primary/5',
!isActive && 'hover:bg-hover',
)}
>
<CheckIcon
size="sm"
className={clsx('flex-shrink-0 text-primary', !isActive && 'invisible')}
/>
<div className="flex-auto">
<div className={clsx('text-sm', isActive && 'font-semibold')}>
{workspace.name}
</div>
<div className="text-sm text-muted">
{workspace.default ? (
<Trans message="Personal workspace" />
) : (
<Trans
message=":count members"
values={{count: workspace.members_count}}
/>
)}
</div>
</div>
{workspace.id !== 0 && (
<ManageButton
setSelectorIsOpen={setSelectorIsOpen}
workspace={workspace}
onChange={onChange}
/>
)}
</div>
);
}
interface LeaveWorkspaceDialogProps {
workspace: Workspace;
onChange?: (id: number) => void;
}
function LeaveWorkspaceDialog({
workspace,
onChange,
}: LeaveWorkspaceDialogProps) {
const removeMember = useRemoveMember();
const {user} = useAuth();
const {close} = useDialogContext();
return (
<LeaveWorkspaceConfirmation
isLoading={removeMember.isPending}
onConfirm={() => {
removeMember.mutate(
{
workspaceId: workspace.id,
memberId: user!.id,
memberType: 'member',
},
{
onSuccess: () => {
close();
onChange?.(PersonalWorkspace.id);
},
},
);
}}
/>
);
}
interface DeleteWorkspaceConfirmationProps {
workspace: Workspace;
onChange?: (id: number) => void;
}
function DeleteWorkspaceConfirmation({
workspace,
onChange,
}: DeleteWorkspaceConfirmationProps) {
const deleteWorkspace = useDeleteWorkspace();
const {close} = useDialogContext();
return (
<ConfirmationDialog
isDanger
title={<Trans message="Delete workspace" />}
body={
<Trans
message="Are you sure you want to delete “:name“?"
values={{name: workspace.name}}
/>
}
confirm={<Trans message="Delete" />}
isLoading={deleteWorkspace.isPending}
onConfirm={() => {
deleteWorkspace.mutate(
{id: workspace.id},
{
onSuccess: () => {
close();
onChange?.(PersonalWorkspace.id);
},
},
);
}}
/>
);
}
interface ManageButtonProps {
setSelectorIsOpen: (value: boolean) => void;
workspace: Workspace;
onChange?: (id: number) => void;
}
function ManageButton({
setSelectorIsOpen,
workspace,
onChange,
}: ManageButtonProps) {
const {user} = useAuth();
return (
<MenuTrigger onItemSelected={() => setSelectorIsOpen(false)}>
<Button
onClick={e => {
e.preventDefault();
e.stopPropagation();
}}
color="primary"
size="xs"
variant="outline"
endIcon={<KeyboardArrowDownIcon />}
>
<Trans message="Manage" />
</Button>
<Menu>
<MenuItem
onClick={e => {
e.stopPropagation();
openDialog(WorkspaceMembersDialog, {workspace});
}}
value="workspaceMembers"
startIcon={<PersonAddIcon />}
>
<Trans message="Members" />
</MenuItem>
{workspace.owner_id === user?.id && (
<MenuItem
onClick={e => {
e.stopPropagation();
openDialog(RenameWorkspaceDialog, {workspace});
}}
value="updateWorkspace"
startIcon={<EditIcon />}
>
<Trans message="Rename" />
</MenuItem>
)}
{workspace.owner_id !== user?.id && (
<MenuItem
onClick={e => {
e.stopPropagation();
openDialog(LeaveWorkspaceDialog, {workspace, onChange});
}}
value="leaveWorkspace"
startIcon={<ExitToAppIcon />}
>
<Trans message="Leave" />
</MenuItem>
)}
{workspace.owner_id === user?.id && (
<MenuItem
onClick={e => {
e.stopPropagation();
openDialog(DeleteWorkspaceConfirmation, {workspace, onChange});
}}
value="deleteWorkspace"
startIcon={<DeleteIcon />}
>
<Trans message="Delete" />
</MenuItem>
)}
</Menu>
</MenuTrigger>
);
}