Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code 136daecbf5 fix: enforce API key name validation on creation and detail page
https://sonarly.com/issue/18288?type=bug

Users can create API keys without a name because `isDefined(canSave)` evaluates to `true` even when `canSave` is `false`. The resulting nameless API key renders as a blank page because the detail page conditionally renders only when `apiKey?.name` is truthy.

Fix: Two changes fix both the cause and the symptom:

**1. `SettingsDevelopersApiKeysNew.tsx` — Restore correct save button validation**

Changed `isSaveDisabled={!isDefined(canSave)}` back to `isSaveDisabled={!canSave}`. The `isDefined` wrapper was incorrectly added during the ESLint-to-OxLint migration (commit `9d57bc39e5d`). Since `canSave` is a boolean, `isDefined(false)` returns `true`, which meant the save button was always enabled. The original `!canSave` correctly disables save when the name is empty.

Also added `if (!formValues.name) return;` guard at the top of `handleSave()` as defense-in-depth, since the Enter key handler on the name input calls `handleSave()` directly without checking the `canSave` flag.

**2. `SettingsDevelopersApiKeyDetail.tsx` — Render page for nameless API keys**

Changed the render guard from `{apiKey?.name && (` to `{isDefined(apiKey) && (`. The old guard treated an empty-string name as falsy, hiding the entire page content. The new guard correctly checks for data presence. The title and breadcrumb now fall back to a translated `"Unnamed API Key"` label when the name is empty, keeping the page usable so users can either rename or delete the broken API key.
2026-03-25 13:39:33 +00:00
30 changed files with 167 additions and 715 deletions
@@ -4089,7 +4089,6 @@ input UpdateObjectPayload {
labelIdentifierFieldMetadataId: UUID
imageIdentifierFieldMetadataId: UUID
isLabelSyncedWithName: Boolean
isSearchable: Boolean
}
input UpdateViewFieldInput {
@@ -6475,7 +6475,7 @@ export interface UpdateOneObjectInput {update: UpdateObjectPayload,
/** The id of the object to update */
id: Scalars['UUID']}
export interface UpdateObjectPayload {labelSingular?: (Scalars['String'] | null),labelPlural?: (Scalars['String'] | null),nameSingular?: (Scalars['String'] | null),namePlural?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),color?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),labelIdentifierFieldMetadataId?: (Scalars['UUID'] | null),imageIdentifierFieldMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null),isSearchable?: (Scalars['Boolean'] | null)}
export interface UpdateObjectPayload {labelSingular?: (Scalars['String'] | null),labelPlural?: (Scalars['String'] | null),nameSingular?: (Scalars['String'] | null),namePlural?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),color?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),labelIdentifierFieldMetadataId?: (Scalars['UUID'] | null),imageIdentifierFieldMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null)}
export interface UpdateViewFieldInput {
/** The id of the view field to update */
@@ -10303,9 +10303,6 @@ export default {
"isLabelSyncedWithName": [
6
],
"isSearchable": [
6
],
"__typename": [
1
]
@@ -5326,7 +5326,6 @@ export type UpdateObjectPayload = {
imageIdentifierFieldMetadataId?: InputMaybe<Scalars['UUID']>;
isActive?: InputMaybe<Scalars['Boolean']>;
isLabelSyncedWithName?: InputMaybe<Scalars['Boolean']>;
isSearchable?: InputMaybe<Scalars['Boolean']>;
labelIdentifierFieldMetadataId?: InputMaybe<Scalars['UUID']>;
labelPlural?: InputMaybe<Scalars['String']>;
labelSingular?: InputMaybe<Scalars['String']>;
@@ -1,8 +1,8 @@
import { SEARCH_QUERY } from '@/command-menu/graphql/queries/search';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { filterReadableActiveObjectMetadataItems } from '@/object-metadata/utils/filterReadableActiveObjectMetadataItems';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId';
import { useCallback, useMemo } from 'react';
import {
type SearchQuery,
@@ -19,10 +19,15 @@ export const useMentionSearch = () => {
const searchableObjectMetadataItems = useMemo(
() =>
filterReadableActiveObjectMetadataItems(
activeObjectMetadataItems,
objectPermissionsByObjectMetadataId,
).filter((item) => !item.isSystem && item.isSearchable),
activeObjectMetadataItems.filter(
(item) =>
!item.isSystem &&
item.isSearchable &&
getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: item.id,
}).canReadObjectRecords === true,
),
[activeObjectMetadataItems, objectPermissionsByObjectMetadataId],
);
@@ -1,19 +1,18 @@
import { useLingui } from '@lingui/react/macro';
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useDebounce } from 'use-debounce';
import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults';
import { useDraftNavigationMenuItems } from '@/navigation-menu-item/edit/hooks/useDraftNavigationMenuItems';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useReadableObjectMetadataItems } from '@/object-metadata/hooks/useReadableObjectMetadataItems';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId';
import { SidePanelAddToNavigationDroppable } from '@/side-panel/components/SidePanelAddToNavigationDroppable';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { SidePanelObjectFilterDropdown } from '@/side-panel/components/SidePanelObjectFilterDropdown';
import { sidePanelShowHiddenObjectsState } from '@/side-panel/states/sidePanelShowHiddenObjectsState';
import { SidePanelSubViewWithSearch } from '@/side-panel/components/SidePanelSubViewWithSearch';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { SidePanelNewSidebarItemRecordItem } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemRecordItem';
import { useQuery } from '@apollo/client/react';
import { SearchDocument } from '~/generated/graphql';
@@ -28,30 +27,21 @@ type SearchRecordBase = {
export const SidePanelNewSidebarItemRecordSubPage = () => {
const { t } = useLingui();
const { currentDraft } = useDraftNavigationMenuItems();
const { objectMetadataItems } = useObjectMetadataItems();
const [recordSearchInput, setRecordSearchInput] = useState('');
const [deferredRecordSearchInput] = useDebounce(recordSearchInput, 300);
const coreClient = useApolloCoreClient();
const { readableObjectMetadataItems } = useReadableObjectMetadataItems();
const [selectedObjectNameSingular, setSelectedObjectNameSingular] = useState<
string | null
>(null);
const sidePanelShowHiddenObjects = useAtomStateValue(
sidePanelShowHiddenObjectsState,
);
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const includedObjectNameSingulars = useMemo(() => {
if (isDefined(selectedObjectNameSingular)) {
return [selectedObjectNameSingular];
}
return readableObjectMetadataItems
.filter((item) => sidePanelShowHiddenObjects || item.isSearchable)
.map((item) => item.nameSingular);
}, [
readableObjectMetadataItems,
selectedObjectNameSingular,
sidePanelShowHiddenObjects,
]);
const nonReadableObjectMetadataItemsNameSingular = objectMetadataItems
.filter(
(objectMetadataItem) =>
!getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: objectMetadataItem.id,
})?.canReadObjectRecords,
)
.map((objectMetadataItem) => objectMetadataItem.nameSingular);
const { data: searchData, loading: recordSearchLoading } = useQuery(
SearchDocument,
@@ -60,7 +50,10 @@ export const SidePanelNewSidebarItemRecordSubPage = () => {
variables: {
searchInput: deferredRecordSearchInput ?? '',
limit: MAX_SEARCH_RESULTS,
includedObjectNameSingulars,
excludedObjectNameSingulars: [
'workspaceMember',
...nonReadableObjectMetadataItemsNameSingular,
],
},
},
);
@@ -91,12 +84,6 @@ export const SidePanelNewSidebarItemRecordSubPage = () => {
searchPlaceholder={t`Search records...`}
searchValue={recordSearchInput}
onSearchChange={setRecordSearchInput}
rightElement={
<SidePanelObjectFilterDropdown
selectedObjectNameSingular={selectedObjectNameSingular}
onSelectObject={setSelectedObjectNameSingular}
/>
}
>
<SidePanelAddToNavigationDroppable>
{({ innerRef, droppableProps, placeholder }) => (
@@ -2,8 +2,8 @@ import { currentUserState } from '@/auth/states/currentUserState';
import { lastVisitedObjectMetadataItemIdState } from '@/navigation/states/lastVisitedObjectMetadataItemIdState';
import { type ObjectPathInfo } from '@/navigation/types/ObjectPathInfo';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { filterReadableActiveObjectMetadataItems } from '@/object-metadata/utils/filterReadableActiveObjectMetadataItems';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import isEmpty from 'lodash.isempty';
@@ -17,26 +17,29 @@ export const useDefaultHomePagePath = () => {
const currentUser = useAtomStateValue(currentUserState);
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const { alphaSortedActiveNonSystemObjectMetadataItems } =
useFilteredObjectMetadataItems();
const readableNonSystemObjectMetadataItems = useMemo(
() =>
filterReadableActiveObjectMetadataItems(
activeObjectMetadataItems,
const readableAlphaSortedActiveNonSystemObjectMetadataItems = useMemo(() => {
return alphaSortedActiveNonSystemObjectMetadataItems.filter((item) => {
const objectPermissions = getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
)
.filter((item) => !item.isSystem)
.sort((a, b) => a.nameSingular.localeCompare(b.nameSingular)),
[activeObjectMetadataItems, objectPermissionsByObjectMetadataId],
);
objectMetadataId: item.id,
});
return objectPermissions?.canReadObjectRecords;
});
}, [
alphaSortedActiveNonSystemObjectMetadataItems,
objectPermissionsByObjectMetadataId,
]);
const getActiveObjectMetadataItemMatchingId = useCallback(
(objectMetadataId: string) => {
return readableNonSystemObjectMetadataItems.find(
return readableAlphaSortedActiveNonSystemObjectMetadataItems.find(
(item) => item.id === objectMetadataId,
);
},
[readableNonSystemObjectMetadataItems],
[readableAlphaSortedActiveNonSystemObjectMetadataItems],
);
const views = useAtomStateValue(viewsSelector);
@@ -51,7 +54,8 @@ export const useDefaultHomePagePath = () => {
);
const firstObjectPathInfo = useMemo<ObjectPathInfo | null>(() => {
const [firstObjectMetadataItem] = readableNonSystemObjectMetadataItems;
const [firstObjectMetadataItem] =
readableAlphaSortedActiveNonSystemObjectMetadataItems;
if (!isDefined(firstObjectMetadataItem)) {
return null;
@@ -60,7 +64,7 @@ export const useDefaultHomePagePath = () => {
const view = getFirstView(firstObjectMetadataItem?.id);
return { objectMetadataItem: firstObjectMetadataItem, view };
}, [getFirstView, readableNonSystemObjectMetadataItems]);
}, [getFirstView, readableAlphaSortedActiveNonSystemObjectMetadataItems]);
const getDefaultObjectPathInfo = useCallback(() => {
const lastVisitedObjectMetadataItemId = store.get(
@@ -93,7 +97,7 @@ export const useDefaultHomePagePath = () => {
return AppPath.SignInUp;
}
if (isEmpty(readableNonSystemObjectMetadataItems)) {
if (isEmpty(readableAlphaSortedActiveNonSystemObjectMetadataItems)) {
return getSettingsPath(SettingsPath.ProfilePage);
}
@@ -114,7 +118,7 @@ export const useDefaultHomePagePath = () => {
}, [
currentUser,
getDefaultObjectPathInfo,
readableNonSystemObjectMetadataItems,
readableAlphaSortedActiveNonSystemObjectMetadataItems,
]);
return { defaultHomePagePath };
@@ -1,21 +0,0 @@
import { useMemo } from 'react';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { filterReadableActiveObjectMetadataItems } from '@/object-metadata/utils/filterReadableActiveObjectMetadataItems';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
export const useReadableObjectMetadataItems = () => {
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const readableObjectMetadataItems = useMemo(
() =>
filterReadableActiveObjectMetadataItems(
activeObjectMetadataItems,
objectPermissionsByObjectMetadataId,
),
[activeObjectMetadataItems, objectPermissionsByObjectMetadataId],
);
return { readableObjectMetadataItems };
};
@@ -3,9 +3,7 @@ import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/Enriche
import { useDeleteOneObjectMetadataItem } from '@/object-metadata/hooks/useDeleteOneObjectMetadataItem';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper';
import { SettingsUpdateDataModelObjectAboutForm } from '@/settings/data-model/object-details/components/SettingsUpdateDataModelObjectAboutForm';
import { SettingsObjectSearchSection } from '@/settings/data-model/object-details/components/tabs/SettingsObjectSearchSection';
import { SettingsDataModelObjectSettingsFormCard } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
@@ -116,20 +114,6 @@ export const ObjectSettings = ({
/>
</Section>
</StyledFormSectionContainer>
<AdvancedSettingsWrapper>
<StyledFormSectionContainer>
<Section>
<H2Title
title={t`Search`}
description={t`Configure how this object appears in search results`}
/>
<SettingsObjectSearchSection
objectMetadataItem={objectMetadataItem}
isReadOnly={isReadOnly}
/>
</Section>
</StyledFormSectionContainer>
</AdvancedSettingsWrapper>
{!isReadOnly && (
<StyledFormSectionContainer>
<Section>
@@ -1,194 +0,0 @@
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { SEARCH_VECTOR_FIELD_NAME } from '@/object-record/constants/SearchVectorFieldName';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { SettingsObjectFieldDataType } from '@/settings/data-model/object-details/components/SettingsObjectFieldDataType';
import { type SettingsFieldType } from '@/settings/data-model/types/SettingsFieldType';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext, useMemo, useState } from 'react';
import { IconEye, IconSearch, useIcons } from 'twenty-ui/display';
import { Card } from 'twenty-ui/layout';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
type SettingsObjectSearchSectionProps = {
objectMetadataItem: EnrichedObjectMetadataItem;
isReadOnly: boolean;
};
type IndexedFieldEntry = {
id: string;
label: string;
icon?: string | null;
weight: number;
fieldType: string;
};
const StyledSearchSectionContent = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
`;
const StyledNameLabel = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const INDEXED_FIELDS_GRID_TEMPLATE_COLUMNS = 'minmax(0, 1fr) 100px 148px';
// TODO: This is very DIRTY ; let's migrate searchVector to be proper tables
// Already tracked here: https://github.com/twentyhq/core-team-issues/issues/1428
const extractIndexedFields = (
objectMetadataItem: EnrichedObjectMetadataItem,
): IndexedFieldEntry[] => {
const searchVectorField = objectMetadataItem.fields.find(
(field) => field.name === SEARCH_VECTOR_FIELD_NAME,
);
const asExpression = (
searchVectorField?.settings as { asExpression?: string } | null
)?.asExpression;
if (!asExpression) {
return [];
}
const columnNames = [
...new Set(
Array.from(asExpression.matchAll(/"([^"]+)"/g), (match) => match[1]),
),
];
const seenFieldIds = new Set<string>();
const entries: IndexedFieldEntry[] = [];
for (const columnName of columnNames) {
const field = objectMetadataItem.fields.find(
(fieldItem) =>
fieldItem.name === columnName || columnName.startsWith(fieldItem.name),
);
if (
field &&
field.name !== SEARCH_VECTOR_FIELD_NAME &&
!seenFieldIds.has(field.id)
) {
seenFieldIds.add(field.id);
entries.push({
id: field.id,
label: field.label,
icon: field.icon,
weight: 1,
fieldType: field.type,
});
}
}
return entries;
};
export const SettingsObjectSearchSection = ({
objectMetadataItem,
isReadOnly,
}: SettingsObjectSearchSectionProps) => {
const { t } = useLingui();
const { getIcon } = useIcons();
const { theme } = useContext(ThemeContext);
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const [isSearchable, setIsSearchable] = useState(
objectMetadataItem.isSearchable,
);
const [searchTerm, setSearchTerm] = useState('');
const indexedFields = useMemo(
() => extractIndexedFields(objectMetadataItem),
[objectMetadataItem],
);
const filteredIndexedFields = searchTerm
? indexedFields.filter((entry) =>
entry.label.toLowerCase().includes(searchTerm.toLowerCase()),
)
: indexedFields;
const handleToggleSearchable = async (value: boolean) => {
setIsSearchable(value);
await updateOneObjectMetadataItem({
idToUpdate: objectMetadataItem.id,
updatePayload: { isSearchable: value },
});
};
return (
<StyledSearchSectionContent>
{!isReadOnly && (
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconEye}
title={t`Include in default search`}
description={t`If disabled, use advanced search filters to find these records`}
checked={isSearchable}
advancedMode
onChange={handleToggleSearchable}
/>
</Card>
)}
{indexedFields.length > 0 && (
<>
<SettingsTextInput
instanceId="indexed-fields-search"
LeftIcon={IconSearch}
placeholder={t`Search across indexed fields...`}
value={searchTerm}
onChange={setSearchTerm}
/>
<Table>
<TableRow
gridTemplateColumns={INDEXED_FIELDS_GRID_TEMPLATE_COLUMNS}
>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Weight`}</TableHeader>
<TableHeader>{t`Data type`}</TableHeader>
</TableRow>
{filteredIndexedFields.map((entry) => {
const FieldIcon = getIcon(entry.icon);
return (
<TableRow
key={entry.id}
gridTemplateColumns={INDEXED_FIELDS_GRID_TEMPLATE_COLUMNS}
>
<TableCell
color={theme.font.color.primary}
gap={theme.spacing[2]}
>
<FieldIcon
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
<StyledNameLabel>{entry.label}</StyledNameLabel>
</TableCell>
<TableCell>{entry.weight}</TableCell>
<TableCell>
<SettingsObjectFieldDataType
value={entry.fieldType as SettingsFieldType}
/>
</TableCell>
</TableRow>
);
})}
</Table>
</>
)}
</StyledSearchSectionContent>
);
};
@@ -1,44 +0,0 @@
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconFilter } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { SidePanelObjectFilterDropdownContent } from '@/side-panel/components/SidePanelObjectFilterDropdownContent';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
export const OBJECT_FILTER_DROPDOWN_ID = 'side-panel-object-filter-dropdown';
type SidePanelObjectFilterDropdownProps = {
selectedObjectNameSingular: string | null;
onSelectObject: (objectNameSingular: string | null) => void;
};
export const SidePanelObjectFilterDropdown = ({
selectedObjectNameSingular,
onSelectObject,
}: SidePanelObjectFilterDropdownProps) => {
const { t } = useLingui();
const isFilterActive = isDefined(selectedObjectNameSingular);
return (
<Dropdown
dropdownId={OBJECT_FILTER_DROPDOWN_ID}
dropdownPlacement="bottom-end"
clickableComponent={
<IconButton
Icon={IconFilter}
variant="tertiary"
accent={isFilterActive ? 'blue' : 'default'}
size="small"
ariaLabel={t`Filter by object type`}
/>
}
dropdownComponents={
<SidePanelObjectFilterDropdownContent
selectedObjectNameSingular={selectedObjectNameSingular}
onSelectObject={onSelectObject}
/>
}
/>
);
};
@@ -1,149 +0,0 @@
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS } from 'twenty-shared/constants';
import { IconCube, useIcons } from 'twenty-ui/display';
import { MenuItemSelectAvatar, MenuItemToggle } from 'twenty-ui/navigation';
import { NavigationMenuItemStyleIcon } from '@/navigation-menu-item/display/components/NavigationMenuItemStyleIcon';
import { useReadableObjectMetadataItems } from '@/object-metadata/hooks/useReadableObjectMetadataItems';
import { getObjectColorWithFallback } from '@/object-metadata/utils/getObjectColorWithFallback';
import { OBJECT_FILTER_DROPDOWN_ID } from '@/side-panel/components/SidePanelObjectFilterDropdown';
import { sidePanelShowHiddenObjectsState } from '@/side-panel/states/sidePanelShowHiddenObjectsState';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
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 { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
const ALL_OBJECTS_ITEM_ID = 'all-objects';
type SidePanelObjectFilterDropdownContentProps = {
selectedObjectNameSingular: string | null;
onSelectObject: (objectNameSingular: string | null) => void;
};
export const SidePanelObjectFilterDropdownContent = ({
selectedObjectNameSingular,
onSelectObject,
}: SidePanelObjectFilterDropdownContentProps) => {
const { t } = useLingui();
const { getIcon } = useIcons();
const [filterSearch, setFilterSearch] = useState('');
const [sidePanelShowHiddenObjects, setSidePanelShowHiddenObjects] =
useAtomState(sidePanelShowHiddenObjectsState);
const { readableObjectMetadataItems } = useReadableObjectMetadataItems();
const { closeDropdown } = useCloseDropdown();
const searchFilter = filterSearch.toLowerCase();
const displayedObjects = readableObjectMetadataItems.filter((item) => {
if (
OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS.includes(
item.nameSingular as (typeof OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS)[number],
)
) {
return false;
}
if (!sidePanelShowHiddenObjects && !item.isSearchable) {
return false;
}
return item.labelPlural.toLowerCase().includes(searchFilter);
});
const handleSelect = (objectNameSingular: string | null) => {
onSelectObject(objectNameSingular);
closeDropdown(OBJECT_FILTER_DROPDOWN_ID);
};
const selectableItemIdArray = [
ALL_OBJECTS_ITEM_ID,
...displayedObjects.map((item) => item.nameSingular),
];
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
OBJECT_FILTER_DROPDOWN_ID,
);
return (
<DropdownContent>
<DropdownMenuHeader>{t`Object`}</DropdownMenuHeader>
<DropdownMenuSearchInput
value={filterSearch}
onChange={(event) => setFilterSearch(event.target.value)}
autoFocus
/>
<DropdownMenuSeparator />
<SelectableList
selectableListInstanceId={OBJECT_FILTER_DROPDOWN_ID}
focusId={OBJECT_FILTER_DROPDOWN_ID}
selectableItemIdArray={selectableItemIdArray}
>
<DropdownMenuItemsContainer hasMaxHeight>
<SelectableListItem
itemId={ALL_OBJECTS_ITEM_ID}
onEnter={() => handleSelect(null)}
>
<MenuItemSelectAvatar
avatar={
<NavigationMenuItemStyleIcon Icon={IconCube} color="gray" />
}
text={t`All objects`}
selected={selectedObjectNameSingular === null}
onClick={() => handleSelect(null)}
focused={selectedItemId === ALL_OBJECTS_ITEM_ID}
/>
</SelectableListItem>
{displayedObjects.map((objectMetadataItem) => {
const ObjectIcon = getIcon(objectMetadataItem.icon);
const iconColor = getObjectColorWithFallback(objectMetadataItem);
return (
<SelectableListItem
key={objectMetadataItem.id}
itemId={objectMetadataItem.nameSingular}
onEnter={() => handleSelect(objectMetadataItem.nameSingular)}
>
<MenuItemSelectAvatar
avatar={
<NavigationMenuItemStyleIcon
Icon={ObjectIcon}
color={iconColor}
/>
}
text={objectMetadataItem.labelPlural}
selected={
selectedObjectNameSingular ===
objectMetadataItem.nameSingular
}
onClick={() => handleSelect(objectMetadataItem.nameSingular)}
focused={selectedItemId === objectMetadataItem.nameSingular}
/>
</SelectableListItem>
);
})}
</DropdownMenuItemsContainer>
</SelectableList>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconCube}
onToggleChange={() =>
setSidePanelShowHiddenObjects(!sidePanelShowHiddenObjects)
}
toggled={sidePanelShowHiddenObjects}
text={t`Show hidden objects`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -53,7 +53,6 @@ type SidePanelSubViewWithSearchProps = {
searchValue: string;
onSearchChange: (value: string) => void;
searchInputProps?: React.InputHTMLAttributes<HTMLInputElement>;
rightElement?: ReactNode;
children?: ReactNode;
};
@@ -62,7 +61,6 @@ export const SidePanelSubViewWithSearch = ({
searchValue,
onSearchChange,
searchInputProps,
rightElement,
children,
}: SidePanelSubViewWithSearchProps) => (
<StyledSubViewContainer>
@@ -75,7 +73,6 @@ export const SidePanelSubViewWithSearch = ({
// oxlint-disable-next-line react/jsx-props-no-spreading
{...searchInputProps}
/>
{rightElement}
</StyledSearchContainer>
{children != null && (
<StyledScrollableListWrapper>{children}</StyledScrollableListWrapper>
@@ -1,17 +1,16 @@
import { useSwitchToNewAIChat } from '@/ai/hooks/useSwitchToNewAIChat';
import { SidePanelObjectFilterDropdown } from '@/side-panel/components/SidePanelObjectFilterDropdown';
import { useOpenAskAIPageInSidePanel } from '@/side-panel/hooks/useOpenAskAIPageInSidePanel';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { sidePanelSearchObjectFilterState } from '@/side-panel/states/sidePanelSearchObjectFilterState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { SidePanelPages } from 'twenty-shared/types';
import { IconEdit } from 'twenty-ui/display';
import { IconEdit, IconSparkles } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { useIsMobile } from 'twenty-ui/utilities';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { useSwitchToNewAIChat } from '@/ai/hooks/useSwitchToNewAIChat';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledIconButtonContainer = styled.div`
@@ -22,19 +21,11 @@ export const SidePanelTopBarRightCornerIcon = () => {
const isMobile = useIsMobile();
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const sidePanelPage = useAtomStateValue(sidePanelPageState);
const { openAskAIPage } = useOpenAskAIPageInSidePanel();
const { switchToNewChat } = useSwitchToNewAIChat();
const [sidePanelSearchObjectFilter, setSidePanelSearchObjectFilter] =
useAtomState(sidePanelSearchObjectFilterState);
const isOnSearchPage = sidePanelPage === SidePanelPages.SearchRecords;
if (isOnSearchPage) {
return (
<SidePanelObjectFilterDropdown
selectedObjectNameSingular={sidePanelSearchObjectFilter}
onSelectObject={setSidePanelSearchObjectFilter}
/>
);
if (isMobile || !isAiEnabled) {
return null;
}
const isOnAskAIPage = [
@@ -42,8 +33,17 @@ export const SidePanelTopBarRightCornerIcon = () => {
SidePanelPages.ViewPreviousAIChats,
].includes(sidePanelPage);
if (isMobile || !isAiEnabled || !isOnAskAIPage) {
return null;
if (!isOnAskAIPage) {
return (
<StyledIconButtonContainer>
<IconButton
onClick={() => openAskAIPage({ resetNavigationStack: false })}
Icon={IconSparkles}
variant="tertiary"
size="small"
/>
</StyledIconButtonContainer>
);
}
return (
@@ -7,9 +7,7 @@ import { sidePanelNavigationMorphItemsByPageState } from '@/side-panel/states/si
import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavigationStackState';
import { sidePanelPageInfoState } from '@/side-panel/states/sidePanelPageInfoState';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { sidePanelSearchObjectFilterState } from '@/side-panel/states/sidePanelSearchObjectFilterState';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { sidePanelShowHiddenObjectsState } from '@/side-panel/states/sidePanelShowHiddenObjectsState';
import { hasUserSelectedSidePanelListItemState } from '@/side-panel/states/hasUserSelectedSidePanelListItemState';
import { isSidePanelClosingState } from '@/side-panel/states/isSidePanelClosingState';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
@@ -106,8 +104,6 @@ export const useSidePanelCloseAnimationCompleteCleanup = () => {
});
store.set(isSidePanelOpenedState.atom, false);
store.set(sidePanelSearchState.atom, '');
store.set(sidePanelSearchObjectFilterState.atom, null);
store.set(sidePanelShowHiddenObjectsState.atom, false);
store.set(sidePanelNavigationMorphItemsByPageState.atom, new Map());
store.set(sidePanelNavigationStackState.atom, []);
resetSelectedItem();
@@ -5,7 +5,6 @@ import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { isSidePanelClosingState } from '@/side-panel/states/isSidePanelClosingState';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { sidePanelSearchObjectFilterState } from '@/side-panel/states/sidePanelSearchObjectFilterState';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { useCloseAnyOpenDropdown } from '@/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown';
import { emitSidePanelOpenEvent } from '@/ui/layout/side-panel/utils/emitSidePanelOpenEvent';
@@ -81,7 +80,6 @@ export const useSidePanelMenu = () => {
const isSidePanelOpened = store.get(isSidePanelOpenedState.atom);
store.set(sidePanelSearchState.atom, '');
store.set(sidePanelSearchObjectFilterState.atom, null);
if (isSidePanelOpened) {
closeSidePanelMenu();
@@ -3,17 +3,16 @@ import { CommandLink } from '@/command-menu-item/display/components/CommandLink'
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults';
import { useReadableObjectMetadataItems } from '@/object-metadata/hooks/useReadableObjectMetadataItems';
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
import { sidePanelSearchObjectFilterState } from '@/side-panel/states/sidePanelSearchObjectFilterState';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { sidePanelShowHiddenObjectsState } from '@/side-panel/states/sidePanelShowHiddenObjectsState';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular, AppPath } from 'twenty-shared/types';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId';
import { t } from '@lingui/core/macro';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/display';
import { useDebounce } from 'use-debounce';
import { useQuery } from '@apollo/client/react';
@@ -21,37 +20,34 @@ import { SearchDocument } from '~/generated/graphql';
export const useSidePanelSearchRecords = () => {
const sidePanelSearch = useAtomStateValue(sidePanelSearchState);
const sidePanelSearchObjectFilter = useAtomStateValue(
sidePanelSearchObjectFilterState,
);
const sidePanelShowHiddenObjects = useAtomStateValue(
sidePanelShowHiddenObjectsState,
);
const coreClient = useApolloCoreClient();
const [deferredSidePanelSearch] = useDebounce(sidePanelSearch, 300);
const { readableObjectMetadataItems } = useReadableObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { objectMetadataItems } = useObjectMetadataItems();
const includedObjectNameSingulars = useMemo(() => {
if (isDefined(sidePanelSearchObjectFilter)) {
return [sidePanelSearchObjectFilter];
}
const nonReadableObjectMetadataItemsNameSingular = useMemo(() => {
return Object.values(objectMetadataItems)
.filter((objectMetadataItem) => {
const objectPermission = getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: objectMetadataItem.id,
});
return readableObjectMetadataItems
.filter((item) => sidePanelShowHiddenObjects || item.isSearchable)
.map((item) => item.nameSingular);
}, [
readableObjectMetadataItems,
sidePanelSearchObjectFilter,
sidePanelShowHiddenObjects,
]);
return !objectPermission?.canReadObjectRecords;
})
.map((objectMetadataItem) => objectMetadataItem.nameSingular);
}, [objectMetadataItems, objectPermissionsByObjectMetadataId]);
const { data: searchData, loading } = useQuery(SearchDocument, {
client: coreClient,
variables: {
searchInput: deferredSidePanelSearch ?? '',
limit: MAX_SEARCH_RESULTS,
includedObjectNameSingulars,
excludedObjectNameSingulars: [
'workspaceMember',
...nonReadableObjectMetadataItemsNameSingular,
],
},
});
@@ -81,7 +77,7 @@ export const useSidePanelSearchRecords = () => {
),
shouldBeRegistered: () => true,
description:
readableObjectMetadataItems.find(
objectMetadataItems.find(
(item) => item.nameSingular === searchRecord.objectNameSingular,
)?.labelSingular ?? searchRecord.objectNameSingular,
};
@@ -96,8 +92,7 @@ export const useSidePanelSearchRecords = () => {
component: (
<Command
onClick={() => {
searchRecord.objectNameSingular ===
CoreObjectNameSingular.Task
searchRecord.objectNameSingular === 'task'
? openRecordInSidePanel({
recordId: searchRecord.recordId,
objectNameSingular: CoreObjectNameSingular.Task,
@@ -127,7 +122,7 @@ export const useSidePanelSearchRecords = () => {
};
},
);
}, [searchData, openRecordInSidePanel, readableObjectMetadataItems]);
}, [searchData, openRecordInSidePanel, objectMetadataItems]);
return {
loading,
@@ -1,6 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const sidePanelSearchObjectFilterState = createAtomState<string | null>({
key: 'side-panel/sidePanelSearchObjectFilterState',
defaultValue: null,
});
@@ -1,6 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const sidePanelShowHiddenObjectsState = createAtomState<boolean>({
key: 'side-panel/sidePanelShowHiddenObjectsState',
defaultValue: false,
});
@@ -1,4 +1,6 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useFindOneRecordQuery } from '@/object-record/hooks/useFindOneRecordQuery';
import { DELETE_WORKFLOW_VERSION_STEP } from '@/workflow/graphql/mutations/deleteWorkflowVersionStep';
import { useUpdateWorkflowVersionCache } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache';
import { useMutation } from '@apollo/client/react';
@@ -13,6 +15,11 @@ export const useDeleteWorkflowVersionStep = () => {
const { updateWorkflowVersionCache } = useUpdateWorkflowVersionCache();
const { findOneRecordQuery: findOneWorkflowVersionQuery } =
useFindOneRecordQuery({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const [mutate] = useMutation<
DeleteWorkflowVersionStepMutation,
DeleteWorkflowVersionStepMutationVariables
@@ -25,6 +32,13 @@ export const useDeleteWorkflowVersionStep = () => {
) => {
const result = await mutate({
variables: { input },
awaitRefetchQueries: true,
refetchQueries: [
{
query: findOneWorkflowVersionQuery,
variables: { objectRecordId: input.workflowVersionId },
},
],
});
const workflowVersionStepChanges = result?.data?.deleteWorkflowVersionStep;
@@ -233,9 +233,9 @@ export const SettingsDevelopersApiKeyDetail = () => {
return (
<>
{apiKey?.name && (
{isDefined(apiKey) && (
<SubMenuTopBarContainer
title={apiKey?.name}
title={apiKey.name || t`Unnamed API Key`}
links={[
{
children: t`Workspace`,
@@ -245,7 +245,7 @@ export const SettingsDevelopersApiKeyDetail = () => {
children: t`APIs & Webhooks`,
href: getSettingsPath(SettingsPath.ApiWebhooks),
},
{ children: apiKey?.name },
{ children: apiKey.name || t`Unnamed API Key` },
]}
>
<SettingsPageContainer>
@@ -75,6 +75,10 @@ export const SettingsDevelopersApiKeysNew = () => {
formValues.expirationDate ?? 30,
).toISOString();
if (!formValues.name) {
return;
}
const roleIdToUse = formValues.roleId;
if (!roleIdToUse) {
@@ -137,7 +141,7 @@ export const SettingsDevelopersApiKeysNew = () => {
]}
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!isDefined(canSave)}
isSaveDisabled={!canSave}
onCancel={() => {
navigateSettings(SettingsPath.ApiWebhooks);
}}
@@ -10,7 +10,6 @@ import { v4 as uuidv4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
@@ -77,28 +76,20 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaces
return;
}
const { twentyStandardFlatApplication, workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const standardCommandMenuItems =
await this.computeStandardCommandMenuItemsToCreate(
workspaceId,
twentyStandardFlatApplication,
);
await this.computeStandardCommandMenuItemsToCreate(workspaceId);
const triggerWorkflowVersionCommandMenuItems =
await this.computeTriggerWorkflowVersionCommandMenuItemsToCreate(
workspaceId,
workspaceCustomFlatApplication,
);
const totalCount =
standardCommandMenuItems.length +
triggerWorkflowVersionCommandMenuItems.length;
const allCommandMenuItemsToCreate = [
...standardCommandMenuItems,
...triggerWorkflowVersionCommandMenuItems,
];
if (totalCount === 0) {
if (allCommandMenuItemsToCreate.length === 0) {
this.logger.log(
`No missing command menu items for workspace ${workspaceId}`,
);
@@ -107,33 +98,46 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaces
}
this.logger.log(
`Found ${totalCount} missing command menu item(s) for workspace ${workspaceId} (${standardCommandMenuItems.length} standard, ${triggerWorkflowVersionCommandMenuItems.length} trigger workflow version)`,
`Found ${allCommandMenuItemsToCreate.length} missing command menu item(s) for workspace ${workspaceId} (${standardCommandMenuItems.length} standard, ${triggerWorkflowVersionCommandMenuItems.length} trigger workflow version)`,
);
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would create ${totalCount} command menu item(s) for workspace ${workspaceId}`,
`[DRY RUN] Would create ${allCommandMenuItemsToCreate.length} command menu item(s) for workspace ${workspaceId}`,
);
return;
}
if (standardCommandMenuItems.length > 0) {
await this.createCommandMenuItems({
workspaceId,
flatCommandMenuItemsToCreate: standardCommandMenuItems,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
});
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
if (triggerWorkflowVersionCommandMenuItems.length > 0) {
await this.createCommandMenuItems({
workspaceId,
flatCommandMenuItemsToCreate: triggerWorkflowVersionCommandMenuItems,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: allCommandMenuItemsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to backfill command menu items:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to backfill command menu items for workspace ${workspaceId}`,
);
}
await this.featureFlagService.enableFeatureFlags(
@@ -142,14 +146,18 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaces
);
this.logger.log(
`Successfully backfilled ${totalCount} command menu item(s) for workspace ${workspaceId}`,
`Successfully backfilled ${allCommandMenuItemsToCreate.length} command menu item(s) for workspace ${workspaceId}`,
);
}
private async computeStandardCommandMenuItemsToCreate(
workspaceId: string,
twentyStandardFlatApplication: FlatApplication,
): Promise<FlatCommandMenuItem[]> {
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
shouldIncludeRecordPageLayouts: true,
@@ -187,7 +195,6 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaces
private async computeTriggerWorkflowVersionCommandMenuItemsToCreate(
workspaceId: string,
workspaceCustomFlatApplication: FlatApplication,
): Promise<FlatCommandMenuItem[]> {
const authContext = buildSystemAuthContext(workspaceId);
@@ -237,6 +244,11 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaces
{ shouldBypassPermissionChecks: true },
);
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const flatCommandMenuItemsToCreate: FlatCommandMenuItem[] = [];
for (const workflowVersion of manualTriggerVersions) {
@@ -299,41 +311,6 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaces
);
}
private async createCommandMenuItems({
workspaceId,
flatCommandMenuItemsToCreate,
applicationUniversalIdentifier,
}: {
workspaceId: string;
flatCommandMenuItemsToCreate: FlatCommandMenuItem[];
applicationUniversalIdentifier: string;
}): Promise<void> {
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: flatCommandMenuItemsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to backfill command menu items:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to backfill command menu items for workspace ${workspaceId}`,
);
}
}
private async resolveManualTriggerAvailability(
trigger: WorkflowManualTrigger,
workspaceId: string,
@@ -84,24 +84,6 @@ export const mockFlatObjectMetadatas: FlatObjectMetadata[] = [
universalIdentifier: 'non-searchable-object-universal-id',
applicationId: workspaceId,
}),
getFlatObjectMetadataMock({
id: '20202020-6a7c-4e3f-9b2d-1d8f7a3e5c4b',
nameSingular: 'message',
namePlural: 'messages',
labelSingular: 'Message',
labelPlural: 'Messages',
description: 'Message',
icon: 'IconMessage',
isCustom: false,
isSystem: true,
isSearchable: false,
labelIdentifierFieldMetadataId: null,
imageIdentifierFieldMetadataId: null,
workspaceId,
fieldIds: [],
universalIdentifier: 'message-universal-id',
applicationId: workspaceId,
}),
];
export const mockFlatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata> = {
@@ -65,24 +65,6 @@ describe('SearchService', () => {
expect(objectMetadataItems).toEqual([mockFlatObjectMetadatas[1]]);
});
it('should allow non-searchable objects when explicitly included', () => {
const objectMetadataItems = service.filterObjectMetadataItems({
flatObjectMetadatas: mockFlatObjectMetadatas,
includedObjectNameSingulars: ['non-searchable-object'],
excludedObjectNameSingulars: [],
});
expect(objectMetadataItems).toEqual([mockFlatObjectMetadatas[3]]);
});
it('should block objects with channel visibility constraints even when explicitly included', () => {
const objectMetadataItems = service.filterObjectMetadataItems({
flatObjectMetadatas: mockFlatObjectMetadatas,
includedObjectNameSingulars: ['message'],
excludedObjectNameSingulars: [],
});
expect(objectMetadataItems).toEqual([]);
});
});
describe('getLabelIdentifierColumns', () => {
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import chunk from 'lodash.chunk';
import { OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS } from 'twenty-shared/constants';
import { FieldMetadataType, ObjectRecord } from 'twenty-shared/types';
import { getLogoUrlFromDomainName, isDefined } from 'twenty-shared/utils';
import { Brackets, type ObjectLiteral } from 'typeorm';
@@ -139,36 +138,20 @@ export class SearchService {
includedObjectNameSingulars: string[];
excludedObjectNameSingulars: string[];
}) {
const hasExplicitInclusion = includedObjectNameSingulars.length > 0;
return flatObjectMetadatas.filter(
({ nameSingular, isSearchable, isActive }) => {
if (!isActive) {
return false;
}
if (hasExplicitInclusion) {
if (
OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS.includes(
nameSingular as (typeof OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS)[number],
)
) {
return false;
}
return (
includedObjectNameSingulars.includes(nameSingular) &&
!excludedObjectNameSingulars.includes(nameSingular)
);
}
if (!isSearchable) {
return false;
}
if (!isActive) {
return false;
}
if (excludedObjectNameSingulars.includes(nameSingular)) {
return false;
}
if (includedObjectNameSingulars.length > 0) {
return includedObjectNameSingulars.includes(nameSingular);
}
return true;
},
@@ -7,7 +7,6 @@ export const FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES = {
'icon',
'isActive',
'isLabelSyncedWithName',
'isSearchable',
'labelPlural',
'labelSingular',
'namePlural',
@@ -19,7 +18,6 @@ export const FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES = {
'description',
'icon',
'isActive',
'isSearchable',
'labelPlural',
'labelSingular',
],
@@ -76,11 +76,6 @@ export class UpdateObjectPayload {
@IsOptional()
@Field({ nullable: true })
isLabelSyncedWithName?: boolean;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
isSearchable?: boolean;
}
@InputType()
@@ -1,23 +0,0 @@
// TODO: These objects are tied to connected accounts and have channel-based visibility
// settings (MessageChannelVisibility / CalendarChannelVisibility) that control what data
// workspace members can see. The global search service does not yet enforce these visibility
// rules, so we exclude these objects from explicit search inclusion to prevent leaking
// restricted content (e.g. message subjects, calendar event titles).
// Once the search service properly joins channel tables and applies visibility filtering,
// this list can be removed.
export const OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS = [
'blocklist',
'connectedAccount',
'message',
'messageThread',
'messageChannel',
'messageParticipant',
'messageFolder',
'messageChannelMessageAssociation',
'messageChannelMessageAssociationMessageFolder',
'messageThreadSubscriber',
'calendarEvent',
'calendarChannel',
'calendarChannelEventAssociation',
'calendarEventParticipant',
] as const;
@@ -32,7 +32,6 @@ export { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from './LabelIdentifierFieldMet
export { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from './MultiItemFieldDefaultMaxValues';
export { MULTI_ITEM_FIELD_MIN_MAX_VALUES } from './MultiItemFieldMinMaxValues';
export { MUTATION_MAX_MERGE_RECORDS } from './MutationMaxMergeRecords';
export { OBJECTS_WITH_CHANNEL_VISIBILITY_CONSTRAINTS } from './ObjectsWithChannelVisibilityConstraints';
export { PermissionFlagType } from './PermissionFlagType';
export { PermissionsOnAllObjectRecords } from './PermissionsOnAllObjectRecords';
export { QUERY_DEFAULT_LIMIT_RECORDS } from './QueryDefaultLimitRecords';