67
common/resources/client/workspace/active-workspace-id-context.tsx
Executable file
67
common/resources/client/workspace/active-workspace-id-context.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
12
common/resources/client/workspace/active-workspace-id.ts
Executable file
12
common/resources/client/workspace/active-workspace-id.ts
Executable 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;
|
||||
}
|
||||
26
common/resources/client/workspace/leave-workspace-confirmation.tsx
Executable file
26
common/resources/client/workspace/leave-workspace-confirmation.tsx
Executable 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" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
60
common/resources/client/workspace/new-workspace-dialog.tsx
Executable file
60
common/resources/client/workspace/new-workspace-dialog.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
65
common/resources/client/workspace/rename-workspace-dialog.tsx
Executable file
65
common/resources/client/workspace/rename-workspace-dialog.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
42
common/resources/client/workspace/requests/change-role.ts
Executable file
42
common/resources/client/workspace/requests/change-role.ts
Executable 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),
|
||||
});
|
||||
}
|
||||
34
common/resources/client/workspace/requests/create-workspace.ts
Executable file
34
common/resources/client/workspace/requests/create-workspace.ts
Executable 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);
|
||||
}
|
||||
36
common/resources/client/workspace/requests/delete-invite.ts
Executable file
36
common/resources/client/workspace/requests/delete-invite.ts
Executable 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
41
common/resources/client/workspace/requests/delete-workspace.ts
Executable file
41
common/resources/client/workspace/requests/delete-workspace.ts
Executable 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),
|
||||
});
|
||||
}
|
||||
34
common/resources/client/workspace/requests/invite-members.ts
Executable file
34
common/resources/client/workspace/requests/invite-members.ts
Executable 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),
|
||||
});
|
||||
}
|
||||
46
common/resources/client/workspace/requests/join-workspace.ts
Executable file
46
common/resources/client/workspace/requests/join-workspace.ts
Executable 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);
|
||||
}
|
||||
50
common/resources/client/workspace/requests/remove-member.ts
Executable file
50
common/resources/client/workspace/requests/remove-member.ts
Executable 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),
|
||||
});
|
||||
}
|
||||
35
common/resources/client/workspace/requests/resend-invite.ts
Executable file
35
common/resources/client/workspace/requests/resend-invite.ts
Executable 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),
|
||||
});
|
||||
}
|
||||
48
common/resources/client/workspace/requests/update-workspace.ts
Executable file
48
common/resources/client/workspace/requests/update-workspace.ts
Executable 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),
|
||||
});
|
||||
}
|
||||
4
common/resources/client/workspace/requests/workspace-query-keys.ts
Executable file
4
common/resources/client/workspace/requests/workspace-query-keys.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export const WorkspaceQueryKeys = {
|
||||
fetchUserWorkspaces: ['user-workspaces'],
|
||||
workspaceWithMembers: (id: number) => ['workspace-with-members', id],
|
||||
};
|
||||
24
common/resources/client/workspace/requests/workspace-with-members.ts
Executable file
24
common/resources/client/workspace/requests/workspace-with-members.ts
Executable 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),
|
||||
});
|
||||
}
|
||||
11
common/resources/client/workspace/types/workspace-invite.ts
Executable file
11
common/resources/client/workspace/types/workspace-invite.ts
Executable 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;
|
||||
}
|
||||
14
common/resources/client/workspace/types/workspace-member.ts
Executable file
14
common/resources/client/workspace/types/workspace-member.ts
Executable 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[];
|
||||
}
|
||||
16
common/resources/client/workspace/types/workspace.ts
Executable file
16
common/resources/client/workspace/types/workspace.ts
Executable 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;
|
||||
}
|
||||
33
common/resources/client/workspace/user-workspaces.ts
Executable file
33
common/resources/client/workspace/user-workspaces.ts
Executable 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,
|
||||
});
|
||||
}
|
||||
476
common/resources/client/workspace/workspace-members-dialog.tsx
Executable file
476
common/resources/client/workspace/workspace-members-dialog.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
419
common/resources/client/workspace/workspace-selector.tsx
Executable file
419
common/resources/client/workspace/workspace-selector.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user