feat: multi-workspace (frontend) (#4232)

* select workspace component

* generateJWT mutation

* workspaces state and hooks

* requested changes

* mutation fix

* requested changes

* user workpsace delete call

* migration to drop and createt user workspace

* revert select props

* add DropdownMenu

* seperate multi-workspace dropdown as component

* Signup button displayed accurately

* update seed data for multi-workspace

* lint fix

* lint fix

* css fix

* lint fix

* state fix

* isDefined check

* refactor

* add default workspace constants for logo and name

* update migration

* lint fix

* isInviteMode check on sign-in/up

* removeWorkspaceMember mutation

* import fixes

* prop name fix

* backfill migration

* handle edge cases

* refactor

* remove migration query

* delete user on no-workspace found condition

* emit workspaceMember.deleted

* Fix event class and unrelated fix linked to a previously missing dependency

* Edit migration (I did it in prod manually)

* Revert changes

* Fix tests

* Fix conflicts

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Aditya Pimpalkar
2024-03-20 13:43:41 +00:00
committed by GitHub
parent 352192a63f
commit da12710fe9
29 changed files with 726 additions and 134 deletions

View File

@ -0,0 +1,124 @@
import { useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { Workspaces } from '@/auth/states/workspaces';
import { IconChevronDown } from '@/ui/display/icon';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItemSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemSelectAvatar';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { MULTI_WORKSPACE_DROPDOWN_ID } from '@/ui/navigation/navigation-drawer/constants/MulitWorkspaceDropdownId';
import { useWorkspaceSwitching } from '@/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching';
import { NavigationDrawerHotKeyScope } from '@/ui/navigation/navigation-drawer/types/NavigationDrawerHotKeyScope';
const StyledLogo = styled.div<{ logo: string }>`
background: url(${({ logo }) => logo});
background-position: center;
background-size: cover;
border-radius: ${({ theme }) => theme.border.radius.xs};
height: 16px;
width: 16px;
`;
const StyledContainer = styled.div`
align-items: center;
cursor: pointer;
color: ${({ theme }) => theme.font.color.primary};
border-radius: ${({ theme }) => theme.border.radius.sm};
border: 1px solid transparent;
display: flex;
justify-content: space-between;
height: ${({ theme }) => theme.spacing(7)};
padding: 0 ${({ theme }) => theme.spacing(2)};
width: 100%;
&:hover {
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
}
`;
const StyledLabel = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledIconChevronDown = styled(IconChevronDown)<{ disabled?: boolean }>`
color: ${({ disabled, theme }) =>
disabled ? theme.font.color.extraLight : theme.font.color.tertiary};
`;
type MultiWorkspaceDropdownButtonProps = {
workspaces: Workspaces[];
};
export const MultiWorkspaceDropdownButton = ({
workspaces,
}: MultiWorkspaceDropdownButtonProps) => {
const theme = useTheme();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const [isMultiWorkspaceDropdownOpen, setToggleMultiWorkspaceDropdown] =
useState(false);
const { switchWorkspace } = useWorkspaceSwitching();
const { closeDropdown } = useDropdown(MULTI_WORKSPACE_DROPDOWN_ID);
const handleChange = async (workspaceId: string) => {
setToggleMultiWorkspaceDropdown(!isMultiWorkspaceDropdownOpen);
closeDropdown();
await switchWorkspace(workspaceId);
};
return (
<Dropdown
dropdownId={MULTI_WORKSPACE_DROPDOWN_ID}
dropdownHotkeyScope={{
scope: NavigationDrawerHotKeyScope.MultiWorkspaceDropdownButton,
}}
clickableComponent={
<StyledContainer>
<StyledLogo
logo={
currentWorkspace?.logo === null
? DEFAULT_WORKSPACE_LOGO
: currentWorkspace?.logo ?? ''
}
/>
<StyledLabel>{currentWorkspace?.displayName ?? ''}</StyledLabel>
<StyledIconChevronDown
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</StyledContainer>
}
dropdownComponents={
<DropdownMenuItemsContainer>
{workspaces.map((workspace) => (
<MenuItemSelectAvatar
key={workspace.id}
text={workspace.displayName!}
avatar={
<StyledLogo
logo={
workspace.logo === null
? DEFAULT_WORKSPACE_LOGO
: workspace.logo ?? ''
}
/>
}
selected={currentWorkspace?.id === workspace.id}
onClick={() => handleChange(workspace.id)}
/>
))}
</DropdownMenuItemsContainer>
}
/>
);
};

View File

@ -1,5 +1,10 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { workspacesState } from '@/auth/states/workspaces';
import { MultiWorkspaceDropdownButton } from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { NavigationDrawerCollapseButton } from './NavigationDrawerCollapseButton';
@ -44,16 +49,24 @@ type NavigationDrawerHeaderProps = {
};
export const NavigationDrawerHeader = ({
name = 'Twenty',
logo = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAACb0lEQVR4nO2VO4taQRTHr3AblbjxEVlwCwVhg7BoqqCIjy/gAyyFWNlYBOxsfH0KuxgQGwXRUkGuL2S7i1barGAgiwbdW93SnGOc4BonPiKahf3DwXFmuP/fPM4ZlvmlTxAhCBdzHnEQWYiv7Mr4C3NeuVYhQYDPzOUUQgDLBQGcLHNhvQK8DACPx8PTxiqVyvISG43GbyaT6Qfpn06n0m63e/tPAPF4vJ1MJu8kEsnWTCkWi1yr1RKGw+GDRqPBOTfr44vFQvD7/Q/lcpmaaVQAr9fLp1IpO22c47hGOBz+MB6PH+Vy+VYDAL8qlUoGtVotzOfzq4MAgsHgE/6KojiQyWR/bKVSqbSszHFM8Pl8z1YK48JsNltCOBwOnrYLO+8AAIjb+nHbycoTiUQfDJ7tFq4YAHiVSmXBxcD41u8flQU8z7fhzO0r83atVns3Go3u9Xr9x0O/RQXo9/tsIBBg6vX606a52Wz+bZ7P5/WwG29gxSJzhKgA6XTaDoFNF+krFAocmC//4yWEcSf2wTm7mCO19xFgSsKOLI16vV7b7XY7mRNoLwA0JymJ5uQIzgIAuX5PzDElT2m+E8BqtQ4ymcx7Yq7T6a6ZE4sKgOadTucaCwkxp1UzlEKh0GDxIXOwDWHAdi6Xe3swQDQa/Q7mywoolUpvsaptymazDWKxmBHTlWXZm405BFZoNpuGgwEmk4mE2SGtVivii4f1AO7J3ZopkQCQj7Ar1FeRChCJRJzVapX6DKNIfSc1Ax+wtQWQ55h6bH8FWDfYV4fO3wlwDr0C/BcADYiTPCxHqIEA2QsCZAkAKnRGkMbKN/sTX5YHPQ1e7SkAAAAASUVORK5CYII=',
name = DEFAULT_WORKSPACE_NAME,
logo = DEFAULT_WORKSPACE_LOGO,
showCollapseButton,
}: NavigationDrawerHeaderProps) => {
const isMobile = useIsMobile();
const workspaces = useRecoilValue(workspacesState);
return (
<StyledContainer>
<StyledLogo logo={logo} />
<StyledName>{name}</StyledName>
{workspaces !== null && workspaces.length > 1 ? (
<MultiWorkspaceDropdownButton workspaces={workspaces} />
) : (
<>
<StyledLogo logo={logo} />
<StyledName>{name}</StyledName>
</>
)}
{!isMobile && (
<StyledNavigationDrawerCollapseButton
direction="left"

View File

@ -0,0 +1,2 @@
export const DEFAULT_WORKSPACE_LOGO =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAACb0lEQVR4nO2VO4taQRTHr3AblbjxEVlwCwVhg7BoqqCIjy/gAyyFWNlYBOxsfH0KuxgQGwXRUkGuL2S7i1barGAgiwbdW93SnGOc4BonPiKahf3DwXFmuP/fPM4ZlvmlTxAhCBdzHnEQWYiv7Mr4C3NeuVYhQYDPzOUUQgDLBQGcLHNhvQK8DACPx8PTxiqVyvISG43GbyaT6Qfpn06n0m63e/tPAPF4vJ1MJu8kEsnWTCkWi1yr1RKGw+GDRqPBOTfr44vFQvD7/Q/lcpmaaVQAr9fLp1IpO22c47hGOBz+MB6PH+Vy+VYDAL8qlUoGtVotzOfzq4MAgsHgE/6KojiQyWR/bKVSqbSszHFM8Pl8z1YK48JsNltCOBwOnrYLO+8AAIjb+nHbycoTiUQfDJ7tFq4YAHiVSmXBxcD41u8flQU8z7fhzO0r83atVns3Go3u9Xr9x0O/RQXo9/tsIBBg6vX606a52Wz+bZ7P5/WwG29gxSJzhKgA6XTaDoFNF+krFAocmC//4yWEcSf2wTm7mCO19xFgSsKOLI16vV7b7XY7mRNoLwA0JymJ5uQIzgIAuX5PzDElT2m+E8BqtQ4ymcx7Yq7T6a6ZE4sKgOadTucaCwkxp1UzlEKh0GDxIXOwDWHAdi6Xe3swQDQa/Q7mywoolUpvsaptymazDWKxmBHTlWXZm405BFZoNpuGgwEmk4mE2SGtVivii4f1AO7J3ZopkQCQj7Ar1FeRChCJRJzVapX6DKNIfSc1Ax+wtQWQ55h6bH8FWDfYV4fO3wlwDr0C/BcADYiTPCxHqIEA2QsCZAkAKnRGkMbKN/sTX5YHPQ1e7SkAAAAASUVORK5CYII=';

View File

@ -0,0 +1 @@
export const DEFAULT_WORKSPACE_NAME = 'Twenty';

View File

@ -0,0 +1 @@
export const MULTI_WORKSPACE_DROPDOWN_ID = 'multi-workspace-dropdown-id';

View File

@ -0,0 +1,38 @@
import { useNavigate } from 'react-router-dom';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { useGenerateJwtMutation } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
export const useWorkspaceSwitching = () => {
const navigate = useNavigate();
const setTokenPair = useSetRecoilState(tokenPairState);
const [generateJWT] = useGenerateJwtMutation();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const switchWorkspace = async (workspaceId: string) => {
if (currentWorkspace?.id === workspaceId) return;
const jwt = await generateJWT({
variables: {
workspaceId,
},
});
if (isDefined(jwt.errors)) {
throw jwt.errors;
}
if (!isDefined(jwt.data?.generateJWT)) {
throw new Error('could not create token');
}
const { tokens } = jwt.data.generateJWT;
setTokenPair(tokens);
navigate(`/objects/companies`);
window.location.reload();
};
return { switchWorkspace };
};

View File

@ -0,0 +1,3 @@
export enum NavigationDrawerHotKeyScope {
MultiWorkspaceDropdownButton = 'multi-workspace-dropdown',
}