add stories to roles components (#10503)

## Context
Adding stories for roles components. Also moving modules components to
the proper "modules" folder, "pages" folder being only for entry points.

## Test
Run storybook

<img width="1145" alt="Screenshot 2025-02-26 at 13 40 40"
src="https://github.com/user-attachments/assets/bc184ab0-c590-4362-8c5a-1bf5ef176e6c"
/>
<img width="1149" alt="Screenshot 2025-02-26 at 13 40 32"
src="https://github.com/user-attachments/assets/699cd205-31db-45e9-b9c1-caff1832bd47"
/>
<img width="1153" alt="Screenshot 2025-02-26 at 13 40 11"
src="https://github.com/user-attachments/assets/72e45a67-ea89-4999-8b16-6f7d027d07f6"
/>
<img width="471" alt="Screenshot 2025-02-26 at 13 38 16"
src="https://github.com/user-attachments/assets/62676943-9935-42b5-b769-5544f7eed85f"
/>
<img width="472" alt="Screenshot 2025-02-26 at 13 38 12"
src="https://github.com/user-attachments/assets/946baab9-1be4-439e-bf99-0ebeab0995f7"
/>
This commit is contained in:
Weiko
2025-02-26 18:16:05 +01:00
committed by GitHub
parent d40a5ed74f
commit 431da37cdf
34 changed files with 195 additions and 23 deletions

View File

@ -47,6 +47,7 @@ describe('getDisplayNameFromParticipant', () => {
updatedAt: '',
userEmail: '',
userId: '',
colorScheme: 'Light',
},
};

View File

@ -28,6 +28,7 @@ const mockWorkspaceMember = {
firstName: 'John',
lastName: 'Doe',
},
colorScheme: 'Light' as const,
};
const createMockOptions = (): Options<any> => ({
@ -168,6 +169,7 @@ describe('ApolloFactory', () => {
firstName: 'John',
lastName: 'Doe',
},
colorScheme: 'Light' as const,
};
apolloFactory.updateWorkspaceMember(newWorkspaceMember);

View File

@ -42,6 +42,7 @@ describe('useFindManyRecords', () => {
id: '32219445-f587-4c40-b2b1-6d3205ed96da',
name: { firstName: 'John', lastName: 'Connor' },
locale: 'en',
colorScheme: 'Light',
});
const setMetadataItems = useSetRecoilState(objectMetadataItemsState);

View File

@ -73,6 +73,7 @@ describe('useFilteredSearchRecordQuery', () => {
id: '32219445-f587-4c40-b2b1-6d3205ed96da',
name: { firstName: 'John', lastName: 'Connor' },
locale: 'en',
colorScheme: 'Light',
});
const setMetadataItems = useSetRecoilState(objectMetadataItemsState);

View File

@ -0,0 +1,46 @@
import { Table } from '@/ui/layout/table/components/Table';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { RolesTableHeader } from '@/settings/roles/components/RolesTableHeader';
import { RolesTableRow } from '@/settings/roles/components/RolesTableRow';
import { Button, H2Title, IconPlus, Section } from 'twenty-ui';
import { Role } from '~/generated-metadata/graphql';
const StyledTable = styled(Table)`
margin-top: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledBottomSection = styled(Section)`
border-top: 1px solid ${({ theme }) => theme.border.color.light};
margin-top: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(4)};
display: flex;
justify-content: flex-end;
`;
export const Roles = ({ roles }: { roles: Role[] }) => {
return (
<Section>
<H2Title
title={t`All roles`}
description={t`Assign roles to specify each member's access permissions`}
/>
<StyledTable>
<RolesTableHeader />
{roles.map((role) => (
<RolesTableRow key={role.id} role={role} />
))}
</StyledTable>
<StyledBottomSection>
<Button
Icon={IconPlus}
title={t`Create Role`}
variant="secondary"
size="small"
soon
/>
</StyledBottomSection>
</Section>
);
};

View File

@ -0,0 +1,80 @@
import {
CurrentWorkspace,
currentWorkspaceState,
} from '@/auth/states/currentWorkspaceState';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { Select } from '@/ui/input/components/Select';
import { t } from '@lingui/core/macro';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
import { Card, H2Title, IconUserPin, Section } from 'twenty-ui';
import {
Role,
UpdateWorkspaceMutation,
useUpdateWorkspaceMutation,
} from '~/generated/graphql';
export const RolesDefaultRole = ({ roles }: { roles: Role[] }) => {
const [updateWorkspace] = useUpdateWorkspaceMutation();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const defaultRole = currentWorkspace?.defaultRole;
const updateDefaultRole = (
defaultRoleId: string | null,
currentWorkspace: CurrentWorkspace,
) => {
updateWorkspace({
variables: {
input: {
defaultRoleId: isDefined(defaultRoleId) ? defaultRoleId : null,
},
},
onCompleted: (data: UpdateWorkspaceMutation) => {
const defaultRole = data.updateWorkspace.defaultRole;
setCurrentWorkspace({
...currentWorkspace,
defaultRole: isDefined(defaultRole) ? defaultRole : null,
});
},
});
};
if (!currentWorkspace || !defaultRole) {
return null;
}
return (
<Section>
<H2Title
title={t`Options`}
description={t`Adjust the role-related settings`}
/>
<Card rounded>
<SettingsOptionCardContentSelect
Icon={IconUserPin}
title="Default Role"
description={t`Set a default role for this workspace`}
>
<Select
selectSizeVariant="small"
withSearchInput
dropdownId="default-role-select"
options={roles.map((role) => ({
label: role.label,
value: role.id,
}))}
value={defaultRole?.id ?? ''}
onChange={(value) =>
updateDefaultRole(value as string, currentWorkspace)
}
/>
</SettingsOptionCardContentSelect>
</Card>
</Section>
);
};

View File

@ -0,0 +1,25 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { Trans } from '@lingui/react/macro';
const StyledTableHeaderRow = styled(Table)`
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
export const RolesTableHeader = () => {
return (
<StyledTableHeaderRow>
<TableRow>
<TableHeader>
<Trans>Name</Trans>
</TableHeader>
<TableHeader align={'right'}>
<Trans>Assigned to</Trans>
</TableHeader>
<TableHeader align={'right'}></TableHeader>
</TableRow>
</StyledTableHeaderRow>
);
};

View File

@ -0,0 +1,117 @@
import { SettingsPath } from '@/types/SettingsPath';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import React from 'react';
import {
AppTooltip,
Avatar,
IconChevronRight,
IconLock,
IconUser,
TooltipDelay,
} from 'twenty-ui';
import { Role } from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const StyledIconChevronRight = styled(IconChevronRight)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
const StyledAvatarContainer = styled.div`
border: 0px;
`;
const StyledAssignedText = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
`;
const StyledNameCell = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledAssignedCell = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledAvatarGroup = styled.div`
align-items: center;
display: flex;
margin-right: ${({ theme }) => theme.spacing(1)};
> * {
margin-left: -5px;
&:first-of-type {
margin-left: 0;
}
}
`;
const StyledTableRow = styled(TableRow)`
&:hover {
background: ${({ theme }) => theme.background.transparent.light};
cursor: pointer;
}
`;
export const RolesTableRow = ({ role }: { role: Role }) => {
const theme = useTheme();
const navigateSettings = useNavigateSettings();
const handleRoleClick = (roleId: string) => {
navigateSettings(SettingsPath.RoleDetail, { roleId });
};
return (
<StyledTableRow key={role.id} onClick={() => handleRoleClick(role.id)}>
<TableCell>
<StyledNameCell>
<IconUser size={theme.icon.size.md} />
{role.label}
{!role.isEditable && <IconLock size={theme.icon.size.sm} />}
</StyledNameCell>
</TableCell>
<TableCell align={'right'}>
<StyledAssignedCell>
<StyledAvatarGroup>
{role.workspaceMembers.slice(0, 5).map((workspaceMember) => (
<React.Fragment key={workspaceMember.id}>
<StyledAvatarContainer id={`avatar-${workspaceMember.id}`}>
<Avatar
avatarUrl={workspaceMember.avatarUrl}
placeholderColorSeed={workspaceMember.id}
placeholder={workspaceMember.name.firstName ?? ''}
type="rounded"
size="md"
/>
</StyledAvatarContainer>
<AppTooltip
anchorSelect={`#avatar-${workspaceMember.id}`}
content={`${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`}
noArrow
place="top"
positionStrategy="fixed"
delay={TooltipDelay.shortDelay}
/>
</React.Fragment>
))}
</StyledAvatarGroup>
<StyledAssignedText>
{role.workspaceMembers.length}
</StyledAssignedText>
</StyledAssignedCell>
</TableCell>
<TableCell align={'right'}>
<StyledIconChevronRight size={theme.icon.size.md} />
</TableCell>
</StyledTableRow>
);
};

View File

@ -0,0 +1,24 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui';
import { Roles } from '@/settings/roles/components/Roles';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { getRolesMock } from '~/testing/mock-data/roles';
const meta: Meta<typeof Roles> = {
title: 'Modules/Settings/Roles/Roles',
component: Roles,
decorators: [ComponentDecorator, I18nFrontDecorator, RouterDecorator],
parameters: {
maxWidth: 800,
},
};
export default meta;
type Story = StoryObj<typeof Roles>;
export const Default: Story = {
args: {
roles: getRolesMock(),
},
};

View File

@ -0,0 +1,43 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { RolesDefaultRole } from '@/settings/roles/components/RolesDefaultRole';
import { Meta, StoryObj } from '@storybook/react';
import { RecoilRoot } from 'recoil';
import { ComponentDecorator } from 'twenty-ui';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { getRolesMock } from '~/testing/mock-data/roles';
import { mockCurrentWorkspace } from '~/testing/mock-data/users';
const rolesMock = getRolesMock();
const RolesDefaultRoleWrapper = () => {
return (
<RecoilRoot
initializeState={(snapshot) => {
snapshot.set(currentWorkspaceState, {
...mockCurrentWorkspace,
defaultRole: rolesMock[1],
});
}}
>
<RolesDefaultRole roles={rolesMock} />
</RecoilRoot>
);
};
const meta: Meta<typeof RolesDefaultRoleWrapper> = {
title: 'Modules/Settings/Roles/RolesDefaultRole',
component: RolesDefaultRoleWrapper,
decorators: [ComponentDecorator, I18nFrontDecorator],
parameters: {
maxWidth: 800,
},
};
export default meta;
type Story = StoryObj<typeof RolesDefaultRoleWrapper>;
export const Default: Story = {
args: {
roles: rolesMock,
},
};

View File

@ -0,0 +1,237 @@
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { RoleAssignmentTableHeader } from '@/settings/roles/role-assignment/components/RoleAssignmentTableHeader';
import { RoleAssignmentWorkspaceMemberPickerDropdown } from '@/settings/roles/role-assignment/components/RoleAssignmentWorkspaceMemberPickerDropdown';
import { RoleAssignmentConfirmationModalSelectedWorkspaceMember } from '@/settings/roles/role-assignment/types/RoleAssignmentConfirmationModalSelectedWorkspaceMember';
import { SettingsPath } from '@/types/SettingsPath';
import { TextInput } from '@/ui/input/components/TextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { Table } from '@/ui/layout/table/components/Table';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import {
AppTooltip,
Button,
H2Title,
IconPlus,
IconSearch,
Section,
TooltipDelay,
} from 'twenty-ui';
import { Role, WorkspaceMember } from '~/generated-metadata/graphql';
import {
GetRolesDocument,
useGetRolesQuery,
useUpdateWorkspaceMemberRoleMutation,
} from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { RoleAssignmentConfirmationModal } from './RoleAssignmentConfirmationModal';
import { RoleAssignmentTableRow } from './RoleAssignmentTableRow';
const StyledBottomSection = styled(Section)<{ hasRows: boolean }>`
${({ hasRows, theme }) =>
hasRows
? `
border-top: 1px solid ${theme.border.color.light};
margin-top: ${theme.spacing(2)};
padding-top: ${theme.spacing(4)};
`
: `
margin-top: ${theme.spacing(8)};
`}
display: flex;
justify-content: flex-end;
`;
const StyledSearchContainer = styled.div`
margin: ${({ theme }) => theme.spacing(2)} 0;
`;
const StyledSearchInput = styled(TextInput)`
input {
background: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
&:hover {
border: 1px solid ${({ theme }) => theme.border.color.medium};
}
}
`;
type RoleAssignmentProps = {
role: Pick<Role, 'id' | 'label' | 'canUpdateAllSettings'> & {
workspaceMembers: Array<WorkspaceMember>;
};
};
export const RoleAssignment = ({ role }: RoleAssignmentProps) => {
const navigateSettings = useNavigateSettings();
const [updateWorkspaceMemberRole] = useUpdateWorkspaceMemberRoleMutation({
refetchQueries: [GetRolesDocument],
});
const [confirmationModalIsOpen, setConfirmationModalIsOpen] =
useState<boolean>(false);
const [selectedWorkspaceMember, setSelectedWorkspaceMember] =
useState<RoleAssignmentConfirmationModalSelectedWorkspaceMember | null>(
null,
);
const { data: rolesData } = useGetRolesQuery();
const { closeDropdown } = useDropdown('role-member-select');
const [searchFilter, setSearchFilter] = useState('');
const currentWorkspaceMembers = useRecoilValue(currentWorkspaceMembersState);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const workspaceMemberRoleMap = new Map<
string,
{ id: string; label: string }
>();
rolesData?.getRoles?.forEach((role) => {
role.workspaceMembers.forEach((member) => {
workspaceMemberRoleMap.set(member.id, { id: role.id, label: role.label });
});
});
const filteredWorkspaceMembers = !searchFilter
? role.workspaceMembers
: role.workspaceMembers.filter((member) => {
const searchTerm = searchFilter.toLowerCase();
const firstName = member.name.firstName?.toLowerCase() || '';
const lastName = member.name.lastName?.toLowerCase() || '';
const email = member.userEmail?.toLowerCase() || '';
return (
firstName.includes(searchTerm) ||
lastName.includes(searchTerm) ||
email.includes(searchTerm)
);
});
const assignedWorkspaceMemberIds = role.workspaceMembers.map(
(workspaceMember) => workspaceMember.id,
);
const assignableWorkspaceMembers = currentWorkspaceMembers.filter(
(member) => member.id !== currentWorkspaceMember?.id,
);
const allWorkspaceMembersHaveThisRole = assignableWorkspaceMembers.every(
(member) => assignedWorkspaceMemberIds.includes(member.id),
);
const handleModalClose = () => {
setConfirmationModalIsOpen(false);
setSelectedWorkspaceMember(null);
};
const handleSelectWorkspaceMember = (workspaceMember: WorkspaceMember) => {
const existingRole = workspaceMemberRoleMap.get(workspaceMember.id);
setSelectedWorkspaceMember({
id: workspaceMember.id,
name: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
role: existingRole,
});
setConfirmationModalIsOpen(true);
closeDropdown();
};
const handleConfirm = async () => {
if (!selectedWorkspaceMember || !confirmationModalIsOpen) return;
await updateWorkspaceMemberRole({
variables: {
workspaceMemberId: selectedWorkspaceMember.id,
roleId: role.id,
},
});
handleModalClose();
};
const handleRoleClick = (roleId: string) => {
navigateSettings(SettingsPath.RoleDetail, { roleId });
handleModalClose();
};
const handleSearchChange = (text: string) => {
setSearchFilter(text);
};
return (
<>
<Section>
<H2Title
title={t`Assigned members`}
description={t`This role is assigned to these workspace members.`}
/>
<StyledSearchContainer>
<StyledSearchInput
value={searchFilter}
onChange={handleSearchChange}
placeholder={t`Search a member`}
fullWidth
LeftIcon={IconSearch}
sizeVariant="lg"
/>
</StyledSearchContainer>
<Table>
<RoleAssignmentTableHeader />
{filteredWorkspaceMembers.map((workspaceMember) => (
<RoleAssignmentTableRow
key={workspaceMember.id}
workspaceMember={workspaceMember}
/>
))}
</Table>
</Section>
<StyledBottomSection hasRows={filteredWorkspaceMembers.length > 0}>
<Dropdown
dropdownId="role-member-select"
dropdownHotkeyScope={{ scope: 'roleAssignment' }}
clickableComponent={
<>
<div id="assign-member">
<Button
Icon={IconPlus}
title={t`Assign to member`}
variant="secondary"
size="small"
disabled={allWorkspaceMembersHaveThisRole}
/>
</div>
<AppTooltip
anchorSelect="#assign-member"
content={t`No more members to assign`}
delay={TooltipDelay.noDelay}
hidden={!allWorkspaceMembersHaveThisRole}
/>
</>
}
dropdownComponents={
<RoleAssignmentWorkspaceMemberPickerDropdown
excludedWorkspaceMemberIds={[
...assignedWorkspaceMemberIds,
currentWorkspaceMember?.id,
]}
onSelect={handleSelectWorkspaceMember}
/>
}
/>
</StyledBottomSection>
{confirmationModalIsOpen && selectedWorkspaceMember && (
<RoleAssignmentConfirmationModal
selectedWorkspaceMember={selectedWorkspaceMember}
isOpen={true}
onClose={handleModalClose}
onConfirm={handleConfirm}
onRoleClick={handleRoleClick}
/>
)}
</>
);
};

View File

@ -0,0 +1,41 @@
import { RoleAssignmentConfirmationModalSubtitle } from '@/settings/roles/role-assignment/components/RoleAssignmentConfirmationModalSubtitle';
import { RoleAssignmentConfirmationModalSelectedWorkspaceMember } from '@/settings/roles/role-assignment/types/RoleAssignmentConfirmationModalSelectedWorkspaceMember';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { t } from '@lingui/core/macro';
type RoleAssignmentConfirmationModalProps = {
selectedWorkspaceMember: RoleAssignmentConfirmationModalSelectedWorkspaceMember;
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
onRoleClick: (roleId: string) => void;
};
export const RoleAssignmentConfirmationModal = ({
selectedWorkspaceMember,
isOpen,
onClose,
onConfirm,
onRoleClick,
}: RoleAssignmentConfirmationModalProps) => {
const workspaceMemberName = selectedWorkspaceMember.name;
const title = t`Assign ${workspaceMemberName}?`;
return (
<ConfirmationModal
isOpen={isOpen}
setIsOpen={onClose}
title={title}
subtitle={
<RoleAssignmentConfirmationModalSubtitle
selectedWorkspaceMember={selectedWorkspaceMember}
onRoleClick={onRoleClick}
/>
}
onConfirmClick={onConfirm}
deleteButtonText={t`Confirm`}
confirmButtonAccent="blue"
/>
);
};

View File

@ -0,0 +1,37 @@
import { SettingsCard } from '@/settings/components/SettingsCard';
import { RoleAssignmentConfirmationModalSelectedWorkspaceMember } from '@/settings/roles/role-assignment/types/RoleAssignmentConfirmationModalSelectedWorkspaceMember';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconUser } from 'twenty-ui';
const StyledSettingsCardContainer = styled.div`
margin-top: ${({ theme }) => theme.spacing(2)};
`;
type RoleAssignmentConfirmationModalSubtitleProps = {
selectedWorkspaceMember: RoleAssignmentConfirmationModalSelectedWorkspaceMember;
onRoleClick: (roleId: string) => void;
};
export const RoleAssignmentConfirmationModalSubtitle = ({
selectedWorkspaceMember,
onRoleClick,
}: RoleAssignmentConfirmationModalSubtitleProps) => {
const workspaceMemberName = selectedWorkspaceMember.name;
return (
<>
{t`${workspaceMemberName} will be unassigned from the following role:`}
<StyledSettingsCardContainer>
<SettingsCard
title={selectedWorkspaceMember.role?.label || ''}
Icon={<IconUser />}
onClick={() =>
selectedWorkspaceMember.role &&
onRoleClick(selectedWorkspaceMember.role.id)
}
/>
</StyledSettingsCardContainer>
</>
);
};

View File

@ -0,0 +1,19 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
const StyledTableHeaderRow = styled(Table)`
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
export const RoleAssignmentTableHeader = () => (
<StyledTableHeaderRow>
<TableRow gridAutoColumns="150px 1fr 1fr">
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Email`}</TableHeader>
<TableHeader align={'right'} aria-label={t`Actions`}></TableHeader>
</TableRow>
</StyledTableHeaderRow>
);

View File

@ -0,0 +1,48 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { Avatar, OverflowingTextWithTooltip } from 'twenty-ui';
import { WorkspaceMember } from '~/generated-metadata/graphql';
const StyledTable = styled(Table)`
margin-top: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledIconWrapper = styled.div`
display: flex;
align-items: center;
margin-right: ${({ theme }) => theme.spacing(2)};
`;
type RoleAssignmentTableRowProps = {
workspaceMember: WorkspaceMember;
};
export const RoleAssignmentTableRow = ({
workspaceMember,
}: RoleAssignmentTableRowProps) => {
return (
<StyledTable>
<TableRow gridAutoColumns="150px 1fr 1fr">
<TableCell>
<StyledIconWrapper>
<Avatar
avatarUrl={workspaceMember.avatarUrl}
placeholderColorSeed={workspaceMember.id}
placeholder={workspaceMember.name.firstName ?? ''}
type="rounded"
size="md"
/>
</StyledIconWrapper>
<OverflowingTextWithTooltip
text={`${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`}
/>
</TableCell>
<TableCell>
<OverflowingTextWithTooltip text={workspaceMember.userEmail} />
</TableCell>
</TableRow>
</StyledTable>
);
};

View File

@ -0,0 +1,54 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useSearchRecords } from '@/object-record/hooks/useSearchRecords';
import { RoleAssignmentWorkspaceMemberPickerDropdownContent } from '@/settings/roles/role-assignment/components/RoleAssignmentWorkspaceMemberPickerDropdownContent';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { ChangeEvent, useState } from 'react';
import { WorkspaceMember } from '~/generated-metadata/graphql';
type RoleAssignmentWorkspaceMemberPickerDropdownProps = {
excludedWorkspaceMemberIds: string[];
onSelect: (workspaceMember: WorkspaceMember) => void;
};
export const RoleAssignmentWorkspaceMemberPickerDropdown = ({
excludedWorkspaceMemberIds,
onSelect,
}: RoleAssignmentWorkspaceMemberPickerDropdownProps) => {
const [searchFilter, setSearchFilter] = useState('');
const { loading, records: workspaceMembers } = useSearchRecords({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
searchInput: searchFilter,
});
const filteredWorkspaceMembers = (workspaceMembers?.filter(
(workspaceMember) =>
!excludedWorkspaceMemberIds.includes(workspaceMember.id),
) ?? []) as WorkspaceMember[];
const handleSearchFilterChange = (event: ChangeEvent<HTMLInputElement>) => {
setSearchFilter(event.target.value);
};
return (
<DropdownMenu>
<DropdownMenuSearchInput
value={searchFilter}
onChange={handleSearchFilterChange}
placeholder="Search"
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<RoleAssignmentWorkspaceMemberPickerDropdownContent
loading={loading}
searchFilter={searchFilter}
filteredWorkspaceMembers={filteredWorkspaceMembers}
onSelect={onSelect}
/>
</DropdownMenuItemsContainer>
</DropdownMenu>
);
};

View File

@ -0,0 +1,43 @@
import { t } from '@lingui/core/macro';
import { MenuItem, MenuItemAvatar } from 'twenty-ui';
import { WorkspaceMember } from '~/generated-metadata/graphql';
type RoleAssignmentWorkspaceMemberPickerDropdownContentProps = {
loading: boolean;
searchFilter: string;
filteredWorkspaceMembers: WorkspaceMember[];
onSelect: (workspaceMember: WorkspaceMember) => void;
};
export const RoleAssignmentWorkspaceMemberPickerDropdownContent = ({
loading,
searchFilter,
filteredWorkspaceMembers,
onSelect,
}: RoleAssignmentWorkspaceMemberPickerDropdownContentProps) => {
if (loading) {
return null;
}
if (!filteredWorkspaceMembers.length && searchFilter.length > 0) {
return <MenuItem disabled text={t`No Results`} />;
}
return (
<>
{filteredWorkspaceMembers.map((workspaceMember) => (
<MenuItemAvatar
key={workspaceMember.id}
onClick={() => onSelect(workspaceMember)}
avatar={{
type: 'rounded',
size: 'md',
placeholder: workspaceMember.name.firstName ?? '',
placeholderColorSeed: workspaceMember.id,
}}
text={`${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`}
/>
))}
</>
);
};

View File

@ -0,0 +1,5 @@
export type RoleAssignmentConfirmationModalSelectedWorkspaceMember = {
id: string;
name: string;
role?: { id: string; label: string };
};

View File

@ -0,0 +1,139 @@
import { RolePermissionsObjectsTableHeader } from '@/settings/roles/role-permissions/components/RolePermissionsObjectsTableHeader';
import { RolePermissionsSettingsTableHeader } from '@/settings/roles/role-permissions/components/RolePermissionsSettingsTableHeader';
import { RolePermissionsSettingsTableRow } from '@/settings/roles/role-permissions/components/RolePermissionsSettingsTableRow';
import { RolePermissionsObjectPermission } from '@/settings/roles/types/RolePermissionsObjectPermission';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import {
H2Title,
IconEye,
IconPencil,
IconTrash,
IconTrashX,
Section,
} from 'twenty-ui';
import { Role } from '~/generated-metadata/graphql';
import { SettingsPermissions } from '~/generated/graphql';
import { RolePermissionsObjectsTableRow } from './RolePermissionsObjectsTableRow';
const StyledRolePermissionsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(8)};
`;
type RolePermissionsProps = {
role: Pick<
Role,
| 'id'
| 'canUpdateAllSettings'
| 'canReadAllObjectRecords'
| 'canUpdateAllObjectRecords'
| 'canSoftDeleteAllObjectRecords'
| 'canDestroyAllObjectRecords'
>;
};
export const RolePermissions = ({ role }: RolePermissionsProps) => {
const objectPermissionsConfig: RolePermissionsObjectPermission[] = [
{
key: 'seeRecords',
label: 'See Records on All Objects',
icon: <IconEye size={14} />,
value: role.canReadAllObjectRecords,
},
{
key: 'editRecords',
label: 'Edit Records on All Objects',
icon: <IconPencil size={14} />,
value: role.canUpdateAllObjectRecords,
},
{
key: 'deleteRecords',
label: 'Delete Records on All Objects',
icon: <IconTrash size={14} />,
value: role.canSoftDeleteAllObjectRecords,
},
{
key: 'destroyRecords',
label: 'Destroy Records on All Objects',
icon: <IconTrashX size={14} />,
value: role.canDestroyAllObjectRecords,
},
];
const settingsPermissionsConfig = [
{
key: SettingsPermissions.API_KEYS_AND_WEBHOOKS,
label: 'API Keys and Webhooks',
type: 'Developer',
value: role.canUpdateAllSettings,
},
{
key: SettingsPermissions.ROLES,
label: 'Roles',
type: 'Members',
value: role.canUpdateAllSettings,
},
{
key: SettingsPermissions.WORKSPACE,
label: 'Workspace Settings',
type: 'General',
value: role.canUpdateAllSettings,
},
{
key: SettingsPermissions.WORKSPACE_MEMBERS,
label: 'Workspace Users',
type: 'Members',
value: role.canUpdateAllSettings,
},
{
key: SettingsPermissions.DATA_MODEL,
label: 'Data Model',
type: 'Data Model',
value: role.canUpdateAllSettings,
},
{
key: SettingsPermissions.ADMIN_PANEL,
label: 'Admin Panel',
type: 'Admin Panel',
value: role.canUpdateAllSettings,
},
{
key: SettingsPermissions.SECURITY,
label: 'Security Settings',
type: 'Security',
value: role.canUpdateAllSettings,
},
];
return (
<StyledRolePermissionsContainer>
<Section>
<H2Title
title={t`Objects`}
description={t`Ability to interact with each object`}
/>
<RolePermissionsObjectsTableHeader allPermissions={true} />
{objectPermissionsConfig.map((permission) => (
<RolePermissionsObjectsTableRow
key={permission.key}
permission={permission}
/>
))}
</Section>
<Section>
<H2Title title={t`Settings`} description={t`Settings permissions`} />
<RolePermissionsSettingsTableHeader
allPermissions={role.canUpdateAllSettings}
/>
{settingsPermissionsConfig.map((permission) => (
<RolePermissionsSettingsTableRow
key={permission.key}
permission={permission}
/>
))}
</Section>
</StyledRolePermissionsContainer>
);
};

View File

@ -0,0 +1,44 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { Checkbox } from 'twenty-ui';
const StyledTableHeaderRow = styled(TableRow)`
display: flex;
`;
const StyledNameHeader = styled(TableHeader)`
flex: 1;
`;
const StyledActionsHeader = styled(TableHeader)`
align-items: center;
display: flex;
justify-content: flex-end;
padding-right: ${({ theme }) => theme.spacing(4)};
`;
const StyledTable = styled(Table)`
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
type RolePermissionsObjectsTableHeaderProps = {
className?: string;
allPermissions: boolean;
};
export const RolePermissionsObjectsTableHeader = ({
className,
allPermissions,
}: RolePermissionsObjectsTableHeaderProps) => (
<StyledTable className={className}>
<StyledTableHeaderRow>
<StyledNameHeader>{t`Name`}</StyledNameHeader>
<StyledActionsHeader aria-label={t`Actions`}>
<Checkbox checked={allPermissions} disabled />
</StyledActionsHeader>
</StyledTableHeaderRow>
</StyledTable>
);

View File

@ -0,0 +1,69 @@
import { RolePermissionsObjectPermission } from '@/settings/roles/types/RolePermissionsObjectPermission';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { Checkbox } from 'twenty-ui';
const StyledIconWrapper = styled.div`
align-items: center;
background: ${({ theme }) => theme.adaptiveColors.blue1};
border: 1px solid ${({ theme }) => theme.adaptiveColors.blue3};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
height: ${({ theme }) => theme.spacing(4)};
justify-content: center;
width: ${({ theme }) => theme.spacing(4)};
`;
const StyledIcon = styled.div`
align-items: center;
display: flex;
color: ${({ theme }) => theme.color.blue};
justify-content: center;
`;
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.primary};
`;
const StyledPermissionCell = styled(TableCell)`
align-items: center;
display: flex;
flex: 1;
gap: ${({ theme }) => theme.spacing(2)};
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledCheckboxCell = styled(TableCell)`
align-items: center;
display: flex;
justify-content: flex-end;
padding-right: ${({ theme }) => theme.spacing(4)};
`;
const StyledTableRow = styled(TableRow)`
align-items: center;
display: flex;
`;
type RolePermissionsObjectsTableRowProps = {
permission: RolePermissionsObjectPermission;
};
export const RolePermissionsObjectsTableRow = ({
permission,
}: RolePermissionsObjectsTableRowProps) => {
return (
<StyledTableRow key={permission.key}>
<StyledPermissionCell>
<StyledIconWrapper>
<StyledIcon>{permission.icon}</StyledIcon>
</StyledIconWrapper>
<StyledLabel>{permission.label}</StyledLabel>
</StyledPermissionCell>
<StyledCheckboxCell>
<Checkbox checked={permission.value} disabled />
</StyledCheckboxCell>
</StyledTableRow>
);
};

View File

@ -0,0 +1,52 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { Checkbox } from 'twenty-ui';
const StyledTableHeaderRow = styled(TableRow)`
align-items: center;
display: flex;
height: ${({ theme }) => theme.spacing(8)};
`;
const StyledNameHeader = styled(TableHeader)`
flex: 1;
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledActionsHeader = styled(TableHeader)`
align-items: center;
display: flex;
justify-content: flex-end;
padding-right: ${({ theme }) => theme.spacing(4)};
`;
const StyledTable = styled(Table)`
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledTypeHeader = styled(TableHeader)`
flex: 1;
`;
type RolePermissionsSettingsTableHeaderProps = {
className?: string;
allPermissions: boolean;
};
export const RolePermissionsSettingsTableHeader = ({
className,
allPermissions,
}: RolePermissionsSettingsTableHeaderProps) => (
<StyledTable className={className}>
<StyledTableHeaderRow>
<StyledNameHeader>{t`Name`}</StyledNameHeader>
<StyledTypeHeader>{t`Type`}</StyledTypeHeader>
<StyledActionsHeader aria-label={t`Actions`}>
<Checkbox checked={allPermissions} disabled />
</StyledActionsHeader>
</StyledTableHeaderRow>
</StyledTable>
);

View File

@ -0,0 +1,55 @@
import { RolePermissionsSettingPermission } from '@/settings/roles/types/RolePermissionsSettingPermission';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { Checkbox } from 'twenty-ui';
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.primary};
`;
const StyledType = styled(StyledLabel)`
color: ${({ theme }) => theme.font.color.secondary};
`;
const StyledPermissionCell = styled(TableCell)`
align-items: center;
display: flex;
flex: 1;
gap: ${({ theme }) => theme.spacing(2)};
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledCheckboxCell = styled(TableCell)`
align-items: center;
display: flex;
justify-content: flex-end;
padding-right: ${({ theme }) => theme.spacing(4)};
`;
const StyledTableRow = styled(TableRow)`
align-items: center;
display: flex;
`;
type RolePermissionsSettingsTableRowProps = {
permission: RolePermissionsSettingPermission;
};
export const RolePermissionsSettingsTableRow = ({
permission,
}: RolePermissionsSettingsTableRowProps) => {
return (
<StyledTableRow key={permission.key}>
<StyledPermissionCell>
<StyledLabel>{permission.label}</StyledLabel>
</StyledPermissionCell>
<StyledPermissionCell>
<StyledType>{permission.type}</StyledType>
</StyledPermissionCell>
<StyledCheckboxCell>
<Checkbox checked={permission.value} disabled />
</StyledCheckboxCell>
</StyledTableRow>
);
};

View File

@ -0,0 +1,46 @@
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput';
import { Role } from '~/generated-metadata/graphql';
const StyledInputsContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledInputContainer = styled.div`
display: flex;
flex-direction: column;
`;
type RoleSettingsProps = {
role: Pick<Role, 'id' | 'label' | 'description'>;
};
export const RoleSettings = ({ role }: RoleSettingsProps) => {
return (
<>
<StyledInputsContainer>
<StyledInputContainer>
<IconPicker
disabled={true}
selectedIconKey={'IconUser'}
onChange={() => {}}
/>
</StyledInputContainer>
<TextInput value={role.label} disabled fullWidth />
</StyledInputsContainer>
<TextArea
minRows={4}
placeholder={t`Write a description`}
value={role.description || ''}
disabled
/>
</>
);
};

View File

@ -0,0 +1,6 @@
export type RolePermissionsObjectPermission = {
key: string;
label: string;
icon: React.ReactNode;
value: boolean;
};

View File

@ -0,0 +1,6 @@
export type RolePermissionsSettingPermission = {
key: string;
label: string;
type: string;
value: boolean;
};

View File

@ -24,6 +24,7 @@ const workspaceMember: Omit<
lastName: 'lastName',
},
locale: 'en',
colorScheme: 'System',
};
describe('useColorScheme', () => {

View File

@ -15,7 +15,7 @@ export type WorkspaceMember = {
};
avatarUrl?: string | null;
locale: string | null;
colorScheme?: ColorScheme;
colorScheme: ColorScheme;
createdAt: string;
updatedAt: string;
userEmail: string;