feat: Add filter button to objects table and enable system objects access (#15856)

## Summary
This PR replaces the Active/Inactive accordion sections with a modern
filter dropdown button and enables full access to system objects in
advanced mode.

## Changes

### UI Improvements
-  Replaced accordion sections with a filter button dropdown (matching
the design pattern from the Group filter)
-  Added 'Deactivated' toggle filter (hidden by default, uses
IconArchive)
-  Added 'System objects' toggle filter (only visible in advanced mode,
uses IconSettings)
-  Fixed search input width to properly fill available space
-  Proper button sizing and alignment

### System Objects Support
-  Made system objects visible when 'System objects' filter is toggled
on
-  System objects are now fully clickable and accessible
-  Updated object detail page to support system objects
-  Updated field creation/edit pages to support system objects
-  System objects can now have custom fields added

### Architecture
-  Implemented scalable filter architecture using a single filtered
list
-  Easy to add more filters in the future (e.g., show remote objects)
-  All filters work independently and can be combined

## Testing
- [x] Tested deactivated objects toggle
- [x] Tested system objects toggle (only shows in advanced mode)
- [x] Tested clicking on system objects
- [x] Tested adding custom fields to system objects
- [x] No linter errors

## Screenshots
See attached screenshots in the conversation for the new filter UI.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds a filter dropdown to the Settings Objects table (incl.
deactivated/system toggles), enables system objects across
settings/field flows, seeds core views (workspace members, messages,
threads, calendar events), and adds actions for Workspace Members.
> 
> - **Settings UI**
> - **Objects Table**: Replaces Active/Inactive sections with a single
filterable list and dropdown (`Deactivated`, `System objects` in
advanced mode); updates props to `objectMetadataItems` and removes
accordion sections.
> - **Search/UX**: Search input fills available space; inactive rows
show activation/delete menu; active rows remain navigable.
> - **Pages Updated**: `SettingsObjects`,
`SettingsApplicationDetailContentTab` switch to new table API; object
detail and new-field flows use `findObjectMetadataItemByNamePlural`
(works with system objects).
> - **Action Menu**
> - **Workspace Members**: Adds `WORKSPACE_MEMBERS_ACTIONS_CONFIG` with
"Manage members in settings" action; wired into `getActionConfig` for
`WorkspaceMember`.
> - **Server (Core Views Seed)**
> - Adds default views: `workspaceMembersAllView`, `messagesAllView`,
`messageThreadsAllView`, `calendarEventsAllView`; included in
`prefillCoreViews`.
> - Marks workflow entities as system (`WorkflowRun`,
`WorkflowVersion`).
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
6a2856df85104665bd986cc63f2d54f19bf668d0. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Félix Malfait
2025-11-17 19:10:19 +01:00
committed by GitHub
parent 60ed9b53f4
commit 0fa2a4524a
16 changed files with 690 additions and 118 deletions
@@ -9,19 +9,30 @@ import {
import { SettingsObjectInactiveMenuDropDown } from '@/settings/data-model/objects/components/SettingsObjectInactiveMenuDropDown';
import { getItemTagInfo } from '@/settings/data-model/utils/getItemTagInfo';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { SortableTableHeader } from '@/ui/layout/table/components/SortableTableHeader';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { useSortedArray } from '@/ui/layout/table/hooks/useSortedArray';
import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyArray } from '@sniptt/guards';
import { useMemo, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconChevronRight, IconSearch } from 'twenty-ui/display';
import {
IconArchive,
IconChevronRight,
IconFilter,
IconSearch,
IconSettings,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { MenuItemToggle } from 'twenty-ui/navigation';
import { GET_SETTINGS_OBJECT_TABLE_METADATA } from '~/pages/settings/data-model/constants/SettingsObjectTableMetadata';
import type { SettingsObjectTableItem } from '~/pages/settings/data-model/types/SettingsObjectTableItem';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
@@ -30,25 +41,34 @@ const StyledIconChevronRight = styled(IconChevronRight)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
const StyledSearchInput = styled(SettingsTextInput)`
const StyledSearchAndFilterContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
align-items: center;
padding-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledSearchInput = styled(SettingsTextInput)`
flex: 1;
width: 100%;
`;
export const SettingsObjectTable = ({
activeObjects,
inactiveObjects,
objectMetadataItems,
withSearchBar = true,
}: {
activeObjects: ObjectMetadataItem[];
inactiveObjects: ObjectMetadataItem[];
objectMetadataItems: ObjectMetadataItem[];
withSearchBar?: boolean;
}) => {
const { t } = useLingui();
const theme = useTheme();
const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
const [searchTerm, setSearchTerm] = useState('');
const [showDeactivated, setShowDeactivated] = useState(false);
const [showSystemObjects, setShowSystemObjects] = useState(false);
const { deleteOneObjectMetadataItem } = useDeleteOneObjectMetadataItem();
@@ -56,13 +76,13 @@ export const SettingsObjectTable = ({
const { totalCountByObjectMetadataItemNamePlural } = useCombinedGetTotalCount(
{
objectMetadataItems: [...activeObjects, ...inactiveObjects],
objectMetadataItems,
},
);
const activeObjectSettingsArray = useMemo(
const allObjectSettingsArray = useMemo(
() =>
activeObjects.map(
objectMetadataItems.map(
(objectMetadataItem) =>
({
objectMetadataItem,
@@ -77,76 +97,91 @@ export const SettingsObjectTable = ({
] ?? 0,
}) satisfies SettingsObjectTableItem,
),
[activeObjects, totalCountByObjectMetadataItemNamePlural],
[objectMetadataItems, totalCountByObjectMetadataItemNamePlural],
);
const inactiveObjectSettingsArray = useMemo(
() =>
inactiveObjects.map(
(objectMetadataItem) =>
({
objectMetadataItem,
labelPlural: objectMetadataItem.labelPlural,
objectTypeLabel: getItemTagInfo({
isCustom: objectMetadataItem.isCustom,
isRemote: objectMetadataItem.isRemote,
}).labelText,
fieldsCount: objectMetadataItem.fields.filter(
(field) => !field.isSystem,
).length,
totalObjectCount:
totalCountByObjectMetadataItemNamePlural[
objectMetadataItem.namePlural
] ?? 0,
}) satisfies SettingsObjectTableItem,
),
[inactiveObjects, totalCountByObjectMetadataItemNamePlural],
);
const sortedActiveObjectSettingsItems = useSortedArray(
activeObjectSettingsArray,
const sortedObjectSettingsItems = useSortedArray(
allObjectSettingsArray,
GET_SETTINGS_OBJECT_TABLE_METADATA,
);
const sortedInactiveObjectSettingsItems = useSortedArray(
inactiveObjectSettingsArray,
GET_SETTINGS_OBJECT_TABLE_METADATA,
);
const filteredActiveObjectSettingsItems = useMemo(
const filteredObjectSettingsItems = useMemo(
() =>
sortedActiveObjectSettingsItems.filter((item) => {
sortedObjectSettingsItems.filter((item) => {
const searchNormalized = normalizeSearchText(searchTerm);
return (
const matchesSearch =
normalizeSearchText(item.labelPlural).includes(searchNormalized) ||
normalizeSearchText(item.objectTypeLabel).includes(searchNormalized)
);
}),
[sortedActiveObjectSettingsItems, searchTerm],
);
normalizeSearchText(item.objectTypeLabel).includes(searchNormalized);
const filteredInactiveObjectSettingsItems = useMemo(
() =>
sortedInactiveObjectSettingsItems.filter((item) => {
const searchNormalized = normalizeSearchText(searchTerm);
return (
normalizeSearchText(item.labelPlural).includes(searchNormalized) ||
normalizeSearchText(item.objectTypeLabel).includes(searchNormalized)
);
if (!matchesSearch) {
return false;
}
const isActive = item.objectMetadataItem.isActive;
if (!isActive && !showDeactivated) {
return false;
}
const isSystem = item.objectMetadataItem.isSystem;
if (isSystem && !showSystemObjects) {
return false;
}
return true;
}),
[sortedInactiveObjectSettingsItems, searchTerm],
[sortedObjectSettingsItems, searchTerm, showDeactivated, showSystemObjects],
);
return (
<>
{withSearchBar && (
<StyledSearchInput
instanceId="settings-objects-search"
LeftIcon={IconSearch}
placeholder={t`Search for an object...`}
value={searchTerm}
onChange={setSearchTerm}
/>
<StyledSearchAndFilterContainer>
<StyledSearchInput
instanceId="settings-objects-search"
LeftIcon={IconSearch}
placeholder={t`Search for an object...`}
value={searchTerm}
onChange={setSearchTerm}
/>
<Dropdown
dropdownId="settings-objects-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={
<Button
Icon={IconFilter}
size="medium"
variant="secondary"
accent="default"
ariaLabel={t`Filter`}
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconArchive}
onToggleChange={() => setShowDeactivated(!showDeactivated)}
toggled={showDeactivated}
text={t`Deactivated`}
toggleSize="small"
/>
{isAdvancedModeEnabled && (
<MenuItemToggle
LeftIcon={IconSettings}
onToggleChange={() =>
setShowSystemObjects(!showSystemObjects)
}
toggled={showSystemObjects}
text={t`System objects`}
toggleSize="small"
/>
)}
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
</StyledSearchAndFilterContainer>
)}
<Table>
@@ -165,35 +200,21 @@ export const SettingsObjectTable = ({
)}
<TableHeader></TableHeader>
</StyledObjectTableRow>
{isNonEmptyArray(sortedActiveObjectSettingsItems) && (
<TableSection title={t`Active`}>
{filteredActiveObjectSettingsItems.map((objectSettingsItem) => (
<SettingsObjectMetadataItemTableRow
key={objectSettingsItem.objectMetadataItem.namePlural}
objectMetadataItem={objectSettingsItem.objectMetadataItem}
totalObjectCount={objectSettingsItem.totalObjectCount}
action={
{filteredObjectSettingsItems.map((objectSettingsItem) => {
const isActive = objectSettingsItem.objectMetadataItem.isActive;
return (
<SettingsObjectMetadataItemTableRow
key={objectSettingsItem.objectMetadataItem.namePlural}
objectMetadataItem={objectSettingsItem.objectMetadataItem}
totalObjectCount={objectSettingsItem.totalObjectCount}
action={
isActive ? (
<StyledIconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
}
link={getSettingsPath(SettingsPath.ObjectDetail, {
objectNamePlural:
objectSettingsItem.objectMetadataItem.namePlural,
})}
/>
))}
</TableSection>
)}
{isNonEmptyArray(sortedInactiveObjectSettingsItems) && (
<TableSection title={t`Inactive`}>
{filteredInactiveObjectSettingsItems.map((objectSettingsItem) => (
<SettingsObjectMetadataItemTableRow
key={objectSettingsItem.objectMetadataItem.namePlural}
objectMetadataItem={objectSettingsItem.objectMetadataItem}
totalObjectCount={objectSettingsItem.totalObjectCount}
action={
) : (
<SettingsObjectInactiveMenuDropDown
isCustomObject={
objectSettingsItem.objectMetadataItem.isCustom
@@ -213,11 +234,19 @@ export const SettingsObjectTable = ({
)
}
/>
}
/>
))}
</TableSection>
)}
)
}
link={
isActive
? getSettingsPath(SettingsPath.ObjectDetail, {
objectNamePlural:
objectSettingsItem.objectMetadataItem.namePlural,
})
: undefined
}
/>
);
})}
</Table>
</>
);