Compare commits

...
Author SHA1 Message Date
Devessier 291b356a8c Merge remote-tracking branch 'origin/main' into fields-widget-edition-cross-button 2026-03-11 13:40:01 +01:00
413d1124bb Fix navigation drag drop indicator position (#18515)
Closes [#2295](https://github.com/twentyhq/core-team-issues/issues/2295)

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
2026-03-11 13:35:09 +01:00
ab5fb1f658 Replace newFieldDefaultConfiguration with newFieldDefaultVisibility (#18539)
https://github.com/user-attachments/assets/365092cb-0fe1-44f7-9ae6-c6fc5edb98b2

---------

Co-authored-by: Weiko <corentin@twenty.com>
2026-03-11 12:14:30 +00:00
Abdul RahmanandGitHub e4e7137660 Navigate to page when clicking nav item in edit mode (#18526)
Closes [#2298](https://github.com/twentyhq/core-team-issues/issues/2298)
2026-03-11 10:59:22 +00:00
Félix MalfaitandGitHub 7fb8cc1c39 Improve apps settings UI and remove unused tarball upload code (#18549)
## Summary
- Improves the Developer Tab in Settings > Applications: adds
source-type badges (Dev / Npm / Internal), better empty state with `yarn
twenty app:dev` CLI command, renames sections to "Create & Develop" and
"Your Apps"
- Simplifies the Distribution Tab: only shows marketplace section for
npm-sourced apps, removes the manual `isListed` toggle (now managed
automatically by the catalog sync cron)
- Removes dead frontend code: `installApplication` mutation (unused —
installs go through `installMarketplaceApp`), `uploadAppTarball`
mutation/hook, and `SettingsUploadTarballModal`

## Test plan
- [ ] Navigate to Settings > Applications > Developer tab: verify badges
show correctly, empty state shows CLI command
- [ ] Create/view a LOCAL app registration: verify Distribution tab does
NOT show marketplace section
- [ ] Create/view an NPM app registration: verify Distribution tab shows
marketplace section with Featured toggle
- [ ] Verify no references to upload tarball or install application
remain in the UI


Made with [Cursor](https://cursor.com)
2026-03-11 11:40:00 +01:00
Devessier 922bb91f7a fix: use correct fields widget page 2026-03-10 19:41:51 +01:00
Devessier dfe4b956b9 refactor: restrict which pages are considered sub pages 2026-03-10 19:20:56 +01:00
Devessier 1501b7f7a4 refactor: improve implementation 2026-03-10 18:20:23 +01:00
Devessier 82d2a7f4fa fix: fix sub page name 2026-03-10 17:37:19 +01:00
Devessier 32212feaa3 Merge remote-tracking branch 'origin/main' into fields-widget-edition-cross-button 2026-03-10 17:22:53 +01:00
Devessier 4eeb26be9b feat: scaffold 2026-03-10 17:22:40 +01:00
42 changed files with 1158 additions and 1642 deletions
File diff suppressed because one or more lines are too long
@@ -1,7 +0,0 @@
import gql from 'graphql-tag';
export const INSTALL_APPLICATION = gql`
mutation InstallApplication($appRegistrationId: String!, $version: String) {
installApplication(appRegistrationId: $appRegistrationId, version: $version)
}
`;
@@ -1,11 +0,0 @@
import { gql } from '@apollo/client';
export const UPLOAD_APP_TARBALL = gql`
mutation UploadAppTarball($file: Upload!, $universalIdentifier: String) {
uploadAppTarball(file: $file, universalIdentifier: $universalIdentifier) {
id
universalIdentifier
name
}
}
`;
@@ -1,61 +0,0 @@
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useMutation } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { UPLOAD_APP_TARBALL } from '~/modules/marketplace/graphql/mutations/uploadAppTarball';
type UploadResult =
| {
success: true;
registrationId: string;
universalIdentifier: string;
}
| {
success: false;
};
export const useUploadAppTarball = () => {
const [uploadAppTarball] = useMutation(UPLOAD_APP_TARBALL);
const { enqueueErrorSnackBar } = useSnackBar();
const [isUploading, setIsUploading] = useState(false);
const upload = async (file: File): Promise<UploadResult> => {
setIsUploading(true);
try {
const result = await uploadAppTarball({
variables: { file },
});
const registration = result.data?.uploadAppTarball;
if (
!isDefined(registration?.id) ||
!isDefined(registration?.universalIdentifier)
) {
enqueueErrorSnackBar({ message: t`Upload failed.` });
return { success: false };
}
return {
success: true,
registrationId: registration.id,
universalIdentifier: registration.universalIdentifier,
};
} catch (error) {
const graphqlMessage = error instanceof Error ? error.message : undefined;
enqueueErrorSnackBar({
message: graphqlMessage ?? t`Failed to upload tarball.`,
});
return { success: false };
} finally {
setIsUploading(false);
}
};
return { upload, isUploading };
};
@@ -1,5 +1,7 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useNavigate } from 'react-router-dom';
import {
IconColumnInsertRight,
IconLink,
@@ -11,10 +13,10 @@ import { LightIconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { FOLDER_ICON_DEFAULT } from '@/navigation-menu-item/constants/FolderIconDefault';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/hooks/useOpenNavigationMenuItemInSidePanel';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/hooks/useSortedNavigationMenuItems';
import {
type NavigationMenuItemClickParams,
useWorkspaceSectionItems,
@@ -30,13 +32,13 @@ import { NavigationDrawerSectionForWorkspaceItems } from '@/object-metadata/comp
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useStore } from 'jotai';
import { SidePanelPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
const StyledRightIconsContainer = styled.div`
align-items: center;
@@ -46,6 +48,7 @@ const StyledRightIconsContainer = styled.div`
export const WorkspaceNavigationMenuItems = () => {
const items = useWorkspaceSectionItems();
const { workspaceNavigationMenuItemsSorted } = useSortedNavigationMenuItems();
const store = useStore();
const enterEditMode = () => {
const prefetchNavigationMenuItems = store.get(
@@ -70,6 +73,7 @@ export const WorkspaceNavigationMenuItems = () => {
const setOpenNavigationMenuItemFolderIds = useSetAtomState(
openNavigationMenuItemFolderIdsState,
);
const navigate = useNavigate();
const { navigateSidePanel } = useNavigateSidePanel();
const { openNavigationMenuItemInSidePanel } =
useOpenNavigationMenuItemInSidePanel();
@@ -83,36 +87,65 @@ export const WorkspaceNavigationMenuItems = () => {
enterEditMode();
};
const openFolderAndNavigateToFirstChild = (
folderId: string,
item: NavigationMenuItemClickParams['item'],
) => {
setOpenNavigationMenuItemFolderIds((current) =>
current.includes(folderId) ? current : [...current, folderId],
);
openNavigationMenuItemInSidePanel({
pageTitle: t`Edit folder`,
pageIcon: getIcon(item.icon ?? item.Icon ?? FOLDER_ICON_DEFAULT),
});
const firstChild = workspaceNavigationMenuItemsSorted.find(
(navItem) =>
navItem.folderId === folderId &&
navItem.itemType !== NavigationMenuItemType.LINK &&
isNonEmptyString(navItem.link),
);
if (firstChild?.link) {
navigate(firstChild.link);
}
};
const openViewOrRecordEditPanelAndNavigate = (
item: NavigationMenuItemClickParams['item'],
objectMetadataItem: ObjectMetadataItem | null | undefined,
) => {
if (objectMetadataItem) {
openNavigationMenuItemInSidePanel({
pageTitle:
item.itemType === NavigationMenuItemType.VIEW
? item.labelIdentifier
: objectMetadataItem.labelSingular,
pageIcon: getIcon(objectMetadataItem.icon),
});
}
const link = 'link' in item ? item.link : undefined;
if (isNonEmptyString(link)) {
navigate(link);
}
};
const handleNavigationMenuItemClick = (
params: NavigationMenuItemClickParams,
) => {
const { item, objectMetadataItem } = params;
const id = item.id;
setSelectedNavigationMenuItemInEditMode(id);
if (item.itemType === NavigationMenuItemType.FOLDER) {
setOpenNavigationMenuItemFolderIds((currentOpenFolders) =>
currentOpenFolders.includes(id)
? currentOpenFolders
: [...currentOpenFolders, id],
);
openNavigationMenuItemInSidePanel({
pageTitle: t`Edit folder`,
pageIcon: getIcon(item.icon ?? item.Icon ?? FOLDER_ICON_DEFAULT),
});
} else if (item.itemType === NavigationMenuItemType.LINK) {
openNavigationMenuItemInSidePanel({
pageTitle: t`Edit link`,
pageIcon: IconLink,
});
} else if (isDefined(objectMetadataItem)) {
const pageTitle =
item.itemType === NavigationMenuItemType.VIEW
? item.labelIdentifier
: objectMetadataItem.labelSingular;
openNavigationMenuItemInSidePanel({
pageTitle,
pageIcon: getIcon(objectMetadataItem.icon),
});
setSelectedNavigationMenuItemInEditMode(item.id);
switch (item.itemType) {
case NavigationMenuItemType.FOLDER:
openFolderAndNavigateToFirstChild(item.id, item);
break;
case NavigationMenuItemType.LINK:
openNavigationMenuItemInSidePanel({
pageTitle: t`Edit link`,
pageIcon: IconLink,
});
break;
default:
openViewOrRecordEditPanelAndNavigate(item, objectMetadataItem);
}
};
@@ -1,4 +1,5 @@
import { type OnDragEndResponder } from '@hello-pangea/dnd';
import { useStore } from 'jotai';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
@@ -11,21 +12,15 @@ import {
matchesWorkspaceFolderId,
validateAndExtractWorkspaceFolderId,
} from '@/navigation-menu-item/utils/validateAndExtractWorkspaceFolderId';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { isDefined } from 'twenty-shared/utils';
import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData';
export const useHandleWorkspaceNavigationMenuItemDragAndDrop = () => {
const store = useStore();
const { workspaceNavigationMenuItems } =
usePrefetchedNavigationMenuItemsData();
const isNavigationMenuInEditMode = useAtomStateValue(
isNavigationMenuInEditModeState,
);
const navigationMenuItemsDraft = useAtomStateValue(
navigationMenuItemsDraftState,
);
const setNavigationMenuItemsDraft = useSetAtomState(
navigationMenuItemsDraftState,
);
@@ -47,7 +42,9 @@ export const useHandleWorkspaceNavigationMenuItemDragAndDrop = () => {
};
const handleWorkspaceNavigationMenuItemDragAndDrop: OnDragEndResponder = (
result,
result: Parameters<OnDragEndResponder>[0] & {
insertBeforeItemId?: string | null;
},
) => {
const { destination, source, draggableId } = result;
@@ -70,6 +67,12 @@ export const useHandleWorkspaceNavigationMenuItemDragAndDrop = () => {
return;
}
const navigationMenuItemsDraft = store.get(
navigationMenuItemsDraftState.atom,
);
const isNavigationMenuInEditMode = store.get(
isNavigationMenuInEditModeState.atom,
);
if (!isNavigationMenuInEditMode || !navigationMenuItemsDraft) {
return;
}
@@ -122,8 +125,24 @@ export const useHandleWorkspaceNavigationMenuItemDragAndDrop = () => {
const listWithoutDragged = sourceList.filter(
(item) => item.id !== draggableId,
);
const prevItem = listWithoutDragged[destination.index - 1];
const nextItem = listWithoutDragged[destination.index];
const sourceIndexInList = sourceList.findIndex(
(item) => item.id === draggableId,
);
const insertBeforeIndex =
result.insertBeforeItemId != null
? sourceList.findIndex(
(item) => item.id === result.insertBeforeItemId,
)
: -1;
const destinationIndexInFullList =
insertBeforeIndex >= 0 ? insertBeforeIndex : destination.index;
const destIndexInListWithoutDragged =
sourceIndexInList < destinationIndexInFullList &&
destinationIndexInFullList <= listWithoutDragged.length
? destinationIndexInFullList - 1
: destinationIndexInFullList;
const prevItem = listWithoutDragged[destIndexInListWithoutDragged - 1];
const nextItem = listWithoutDragged[destIndexInListWithoutDragged];
const newPosition = getPositionBetween(
prevItem?.position,
nextItem?.position,
@@ -1,8 +1,8 @@
import { type DragDropProvider } from '@dnd-kit/react';
import { isSortable } from '@dnd-kit/react/sortable';
import type { ResponderProvided } from '@hello-pangea/dnd';
import { type ComponentProps, useCallback, useState } from 'react';
import { useStore } from 'jotai';
import { type ComponentProps, useCallback, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/constants/AddToNavSourceDroppableId';
@@ -96,6 +96,7 @@ export const useWorkspaceDndKit = (): {
id: string,
source: DropDestination,
destination: DropDestination,
insertBeforeItemId?: string | null,
) => {
const draggedItem = getNavItemById(id);
const destFolderId = validateAndExtractWorkspaceFolderId(
@@ -118,7 +119,11 @@ export const useWorkspaceDndKit = (): {
);
const provided: ResponderProvided = { announce: () => {} };
handleWorkspaceNavigationMenuItemDragAndDrop(
{ ...result, ...DROP_RESULT_OPTIONS },
{
...result,
...DROP_RESULT_OPTIONS,
...(insertBeforeItemId != null && { insertBeforeItemId }),
},
provided,
);
};
@@ -284,10 +289,14 @@ export const useWorkspaceDndKit = (): {
isWorkspaceDroppableId(initialGroupStr) &&
isWorkspaceDroppableId(destGroup);
if (bothWorkspace) {
const insertBeforeItemId = resolved.isTargetFolder
? null
: String(target?.id ?? '');
applyWorkspaceReorderIfAllowed(
draggableId,
{ droppableId: initialGroupStr, index: initialIndex },
resolved.destination,
insertBeforeItemId || undefined,
);
return;
}
@@ -26,6 +26,8 @@ import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/us
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const LazyWorkspaceSectionListDndKit = lazy(() =>
import(
@@ -33,6 +35,10 @@ const LazyWorkspaceSectionListDndKit = lazy(() =>
).then((m) => ({ default: m.WorkspaceSectionListDndKit })),
);
const StyledWorkspaceSectionContentGapOffset = styled.div`
margin-top: calc(-1 * ${themeCssVariables.betweenSiblingsGap});
`;
type NavigationDrawerSectionForWorkspaceItemsProps = {
sectionTitle: string;
items: FlatWorkspaceItem[];
@@ -150,44 +156,48 @@ export const NavigationDrawerSectionForWorkspaceItems = ({
isOpen={isNavigationSectionOpen}
/>
</NavigationDrawerAnimatedCollapseWrapper>
<AnimatedExpandableContainer
isExpanded={
isNavigationSectionOpen || isAddToNavigationDropTargetVisible
}
dimension="height"
mode="fit-content"
containAnimation
initial={false}
>
{isNavigationMenuInEditMode ? (
<Suspense
fallback={
<WorkspaceSectionListEditModeFallback
<StyledWorkspaceSectionContentGapOffset>
<AnimatedExpandableContainer
isExpanded={
isNavigationSectionOpen || isAddToNavigationDropTargetVisible
}
dimension="height"
mode="fit-content"
containAnimation
initial={false}
>
{isNavigationMenuInEditMode ? (
<Suspense
fallback={
<WorkspaceSectionListEditModeFallback
filteredItems={filteredItems}
folderChildrenById={folderChildrenById}
onActiveObjectMetadataItemClick={
onActiveObjectMetadataItemClick
}
/>
}
>
<LazyWorkspaceSectionListDndKit
filteredItems={filteredItems}
getEditModeProps={getEditModeProps}
folderChildrenById={folderChildrenById}
selectedNavigationMenuItemId={selectedNavigationMenuItemId}
onNavigationMenuItemClick={onNavigationMenuItemClick}
onActiveObjectMetadataItemClick={
onActiveObjectMetadataItemClick
}
/>
}
>
<LazyWorkspaceSectionListDndKit
</Suspense>
) : (
<NavigationDrawerSectionForWorkspaceItemsListReadOnly
filteredItems={filteredItems}
getEditModeProps={getEditModeProps}
folderChildrenById={folderChildrenById}
selectedNavigationMenuItemId={selectedNavigationMenuItemId}
onNavigationMenuItemClick={onNavigationMenuItemClick}
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
/>
</Suspense>
) : (
<NavigationDrawerSectionForWorkspaceItemsListReadOnly
filteredItems={filteredItems}
folderChildrenById={folderChildrenById}
onActiveObjectMetadataItemClick={onActiveObjectMetadataItemClick}
/>
)}
</AnimatedExpandableContainer>
)}
</AnimatedExpandableContainer>
</StyledWorkspaceSectionContentGapOffset>
</NavigationDrawerSection>
);
};
@@ -3,7 +3,6 @@ import { WorkspaceDndKitSortableItem } from '@/navigation-menu-item/components/W
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { NavigationDropTargetContext } from '@/navigation-menu-item/contexts/NavigationDropTargetContext';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext';
import { useIsDropDisabledForSection } from '@/navigation-menu-item/hooks/useIsDropDisabledForSection';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -11,6 +10,7 @@ import { styled } from '@linaria/react';
import { useContext } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext';
import { NavigationDrawerSectionForWorkspaceItemContent } from '@/object-metadata/components/NavigationDrawerSectionForWorkspaceItemContent';
import { WorkspaceOrphanDropTarget } from '@/object-metadata/components/WorkspaceOrphanDropTarget';
import { WorkspaceSectionAddMenuItemButton } from '@/object-metadata/components/WorkspaceSectionAddMenuItemButton';
@@ -20,6 +20,7 @@ const StyledList = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.betweenSiblingsGap};
padding-top: ${themeCssVariables.betweenSiblingsGap};
`;
const StyledListItemRow = styled.div`
@@ -10,6 +10,7 @@ const StyledList = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.betweenSiblingsGap};
padding-top: ${themeCssVariables.betweenSiblingsGap};
`;
type NavigationDrawerSectionForWorkspaceItemsListReadOnlyProps = Pick<
@@ -167,11 +167,8 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
... on FieldsConfiguration {
configurationType
viewId
newFieldDefaultVisibility
shouldAllowUserToSeeHiddenFields
newFieldDefaultConfiguration {
isVisible
viewFieldGroupId
}
}
... on FilesConfiguration {
configurationType
@@ -11,18 +11,15 @@ import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fiel
import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState';
import { FieldsConfigurationGroupEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor';
import { FieldsConfigurationUngroupedEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor';
import { NEW_FIELDS_INDICATOR_DRAGGABLE_ID } from '@/page-layout/widgets/fields/constants/NewFieldsIndicatorDraggableId';
import { useCreateFieldsWidgetEditorGroup } from '@/page-layout/widgets/fields/hooks/useCreateFieldsWidgetEditorGroup';
import { useDeleteFieldsWidgetEditorGroup } from '@/page-layout/widgets/fields/hooks/useDeleteFieldsWidgetEditorGroup';
import { useFieldsWidgetEditorMode } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetEditorMode';
import { useGetNewFieldDefaultConfiguration } from '@/page-layout/widgets/fields/hooks/useGetNewFieldDefaultConfiguration';
import { useMoveFieldInDraft } from '@/page-layout/widgets/fields/hooks/useMoveFieldInDraft';
import { useMoveUngroupedFieldInDraft } from '@/page-layout/widgets/fields/hooks/useMoveUngroupedFieldInDraft';
import { useReorderFieldsWidgetEditorGroups } from '@/page-layout/widgets/fields/hooks/useReorderFieldsWidgetEditorGroups';
import { useToggleFieldVisibilityInDraft } from '@/page-layout/widgets/fields/hooks/useToggleFieldVisibilityInDraft';
import { useToggleUngroupedFieldVisibilityInDraft } from '@/page-layout/widgets/fields/hooks/useToggleUngroupedFieldVisibilityInDraft';
import { useUpdateFieldsWidgetEditorGroup } from '@/page-layout/widgets/fields/hooks/useUpdateFieldsWidgetEditorGroup';
import { useUpdateNewFieldDefaultConfiguration } from '@/page-layout/widgets/fields/hooks/useUpdateNewFieldDefaultConfiguration';
import { getFieldsConfigurationGroupRenameDropdownId } from '@/page-layout/widgets/fields/utils/getFieldsConfigurationGroupRenameDropdownId';
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
@@ -107,17 +104,6 @@ export const FieldsConfigurationEditor = ({
widgetId,
});
const { newFieldDefaultConfiguration } = useGetNewFieldDefaultConfiguration({
pageLayoutId,
widgetId,
});
const { updateNewFieldDefaultConfiguration } =
useUpdateNewFieldDefaultConfiguration({
pageLayoutId,
widgetId,
});
const { openDropdown } = useOpenDropdown();
const [renamingGroupValue, setRenamingGroupValue] = useState('');
@@ -141,7 +127,7 @@ export const FieldsConfigurationEditor = ({
};
const handleDragEnd = (result: DropResult) => {
const { source, destination, type, draggableId } = result;
const { source, destination, type } = result;
if (!destination) {
return;
@@ -154,17 +140,6 @@ export const FieldsConfigurationEditor = ({
return;
}
if (draggableId === NEW_FIELDS_INDICATOR_DRAGGABLE_ID) {
const cleanDestinationGroupId = destination.droppableId.replace(
'group-',
'',
);
updateNewFieldDefaultConfiguration({
viewFieldGroupId: cleanDestinationGroupId,
});
return;
}
if (type === 'GROUP') {
handleGroupReorder(source.index, destination.index);
} else if (type === 'FIELD') {
@@ -227,12 +202,6 @@ export const FieldsConfigurationEditor = ({
onMoveField={moveUngroupedField}
onToggleFieldVisibility={toggleUngroupedFieldVisibility}
onAddGroup={() => handleAddGroup({})}
newFieldsIsVisible={newFieldDefaultConfiguration.isVisible}
onToggleNewFieldsVisibility={() =>
updateNewFieldDefaultConfiguration({
isVisible: !newFieldDefaultConfiguration.isVisible,
})
}
/>
);
}
@@ -276,15 +245,6 @@ export const FieldsConfigurationEditor = ({
renamingGroupValue={renamingGroupValue}
onRenamingGroupValueChange={setRenamingGroupValue}
onStartRename={handleStartRename}
showNewFieldsItem={
group.id === newFieldDefaultConfiguration.viewFieldGroupId
}
newFieldsIsVisible={newFieldDefaultConfiguration.isVisible}
onToggleNewFieldsVisibility={() =>
updateNewFieldDefaultConfiguration({
isVisible: !newFieldDefaultConfiguration.isVisible,
})
}
/>
)}
</Draggable>
@@ -8,7 +8,6 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI
import { FieldsConfigurationFieldEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationFieldEditor';
import { FieldsConfigurationGroupDropdown } from '@/page-layout/widgets/fields/components/FieldsConfigurationGroupDropdown';
import { FieldsConfigurationGroupRenameInput } from '@/page-layout/widgets/fields/components/FieldsConfigurationGroupRenameInput';
import { NEW_FIELDS_INDICATOR_DRAGGABLE_ID } from '@/page-layout/widgets/fields/constants/NewFieldsIndicatorDraggableId';
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { getFieldsConfigurationGroupRenameDropdownId } from '@/page-layout/widgets/fields/utils/getFieldsConfigurationGroupRenameDropdownId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
@@ -16,13 +15,8 @@ import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
import {
IconEye,
IconEyeOff,
IconNewSection,
IconPlaylistAdd,
} from 'twenty-ui/display';
import { MenuItem, MenuItemDraggable } from 'twenty-ui/navigation';
import { IconNewSection } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
import { FieldsConfigurationGroupDraggableHeader } from '@/page-layout/widgets/fields/components/FieldsConfigurationGroupDraggableHeader';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -89,9 +83,6 @@ type FieldsConfigurationGroupEditorProps = {
renamingGroupValue: string;
onRenamingGroupValueChange: (value: string) => void;
onStartRename: (params: { groupId: string; groupName: string }) => void;
showNewFieldsItem: boolean;
newFieldsIsVisible: boolean;
onToggleNewFieldsVisibility: () => void;
};
export const FieldsConfigurationGroupEditor = ({
@@ -105,9 +96,6 @@ export const FieldsConfigurationGroupEditor = ({
renamingGroupValue,
onRenamingGroupValueChange,
onStartRename,
showNewFieldsItem,
newFieldsIsVisible,
onToggleNewFieldsVisibility,
}: FieldsConfigurationGroupEditorProps) => {
const { t } = useLingui();
@@ -223,33 +211,6 @@ export const FieldsConfigurationGroupEditor = ({
/>
);
})}
{showNewFieldsItem && (
<DraggableItem
key={NEW_FIELDS_INDICATOR_DRAGGABLE_ID}
draggableId={NEW_FIELDS_INDICATOR_DRAGGABLE_ID}
index={sortedFields.length}
isInsideScrollableContainer
itemComponent={
<MenuItemDraggable
LeftIcon={IconPlaylistAdd}
text={t`New fields`}
contextualText={t`Default position/visibility for fields created in the future`}
gripMode="onHover"
withIconContainer
isIconDisplayedOnHoverOnly={false}
iconButtons={[
{
Icon: newFieldsIsVisible ? IconEye : IconEyeOff,
onClick: (e) => {
e.stopPropagation();
onToggleNewFieldsVisibility();
},
},
]}
/>
}
/>
)}
{droppableProvided.placeholder}
</StyledFieldsDroppable>
)}
@@ -6,13 +6,8 @@ import { FieldsConfigurationFieldEditor } from '@/page-layout/widgets/fields/com
import { type FieldsWidgetGroupField } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import {
IconEye,
IconEyeOff,
IconNewSection,
IconPlaylistAdd,
} from 'twenty-ui/display';
import { MenuItem, MenuItemDraggable } from 'twenty-ui/navigation';
import { IconNewSection } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
const StyledFieldsDroppable = styled.div`
display: flex;
@@ -25,8 +20,6 @@ type FieldsConfigurationUngroupedEditorProps = {
onMoveField: (sourceIndex: number, destinationIndex: number) => void;
onToggleFieldVisibility: (fieldMetadataId: string) => void;
onAddGroup: () => void;
newFieldsIsVisible: boolean;
onToggleNewFieldsVisibility: () => void;
};
export const FieldsConfigurationUngroupedEditor = ({
@@ -34,8 +27,6 @@ export const FieldsConfigurationUngroupedEditor = ({
onMoveField,
onToggleFieldVisibility,
onAddGroup,
newFieldsIsVisible,
onToggleNewFieldsVisibility,
}: FieldsConfigurationUngroupedEditorProps) => {
const { t } = useLingui();
@@ -89,25 +80,6 @@ export const FieldsConfigurationUngroupedEditor = ({
))}
{provided.placeholder}
<MenuItemDraggable
LeftIcon={IconPlaylistAdd}
text={t`New fields`}
contextualText={t`Default position/visibility for fields created in the future`}
gripMode="never"
isDragDisabled
withIconContainer
isIconDisplayedOnHoverOnly={false}
iconButtons={[
{
Icon: newFieldsIsVisible ? IconEye : IconEyeOff,
onClick: (e) => {
e.stopPropagation();
onToggleNewFieldsVisibility();
},
},
]}
/>
<MenuItem
LeftIcon={IconNewSection}
withIconContainer
@@ -1 +0,0 @@
export const NEW_FIELDS_INDICATOR_DRAGGABLE_ID = 'new-fields-indicator';
@@ -1,52 +0,0 @@
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { getLastGroupId } from '@/page-layout/widgets/fields/utils/getLastGroupId';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { isDefined } from 'twenty-shared/utils';
import {
type FieldsConfiguration,
WidgetConfigurationType,
} from '~/generated-metadata/graphql';
type UseGetNewFieldDefaultConfigurationParams = {
pageLayoutId: string;
widgetId: string;
};
export const useGetNewFieldDefaultConfiguration = ({
pageLayoutId,
widgetId,
}: UseGetNewFieldDefaultConfigurationParams) => {
const pageLayoutDraft = useAtomComponentStateValue(
pageLayoutDraftComponentState,
pageLayoutId,
);
const fieldsWidgetGroupsDraft = useAtomComponentStateValue(
fieldsWidgetGroupsDraftComponentState,
pageLayoutId,
);
const draftGroups = fieldsWidgetGroupsDraft[widgetId] ?? [];
const widget = pageLayoutDraft.tabs
.flatMap((tab) => tab.widgets)
.find((w) => w.id === widgetId);
const fieldsConfiguration =
isDefined(widget?.configuration) &&
widget.configuration.configurationType === WidgetConfigurationType.FIELDS
? (widget.configuration as FieldsConfiguration)
: null;
const lastGroupId = getLastGroupId(draftGroups);
const persisted = fieldsConfiguration?.newFieldDefaultConfiguration;
const newFieldDefaultConfiguration = {
isVisible: persisted?.isVisible ?? true,
viewFieldGroupId: persisted?.viewFieldGroupId ?? lastGroupId,
};
return { newFieldDefaultConfiguration, fieldsConfiguration };
};
@@ -0,0 +1,37 @@
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { isDefined } from 'twenty-shared/utils';
import {
type FieldsConfiguration,
WidgetConfigurationType,
} from '~/generated-metadata/graphql';
type UseGetNewFieldDefaultVisibilityParams = {
pageLayoutId: string;
widgetId: string;
};
export const useGetNewFieldDefaultVisibility = ({
pageLayoutId,
widgetId,
}: UseGetNewFieldDefaultVisibilityParams) => {
const pageLayoutDraft = useAtomComponentStateValue(
pageLayoutDraftComponentState,
pageLayoutId,
);
const widget = pageLayoutDraft.tabs
.flatMap((tab) => tab.widgets)
.find((w) => w.id === widgetId);
const fieldsConfiguration =
isDefined(widget?.configuration) &&
widget.configuration.configurationType === WidgetConfigurationType.FIELDS
? (widget.configuration as FieldsConfiguration)
: null;
const newFieldDefaultVisibility =
fieldsConfiguration?.newFieldDefaultVisibility ?? true;
return { newFieldDefaultVisibility, fieldsConfiguration };
};
@@ -1,45 +0,0 @@
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { useGetNewFieldDefaultConfiguration } from '@/page-layout/widgets/fields/hooks/useGetNewFieldDefaultConfiguration';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
type UseUpdateNewFieldDefaultConfigurationParams = {
pageLayoutId: string;
widgetId: string;
};
export const useUpdateNewFieldDefaultConfiguration = ({
pageLayoutId,
widgetId,
}: UseUpdateNewFieldDefaultConfigurationParams) => {
const { newFieldDefaultConfiguration, fieldsConfiguration } =
useGetNewFieldDefaultConfiguration({ pageLayoutId, widgetId });
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
const updateNewFieldDefaultConfiguration = useCallback(
(updates: { isVisible?: boolean; viewFieldGroupId?: string | null }) => {
if (!isDefined(fieldsConfiguration)) {
return;
}
updatePageLayoutWidget(widgetId, {
configuration: {
...fieldsConfiguration,
newFieldDefaultConfiguration: {
...newFieldDefaultConfiguration,
...updates,
},
},
});
},
[
fieldsConfiguration,
newFieldDefaultConfiguration,
updatePageLayoutWidget,
widgetId,
],
);
return { updateNewFieldDefaultConfiguration };
};
@@ -0,0 +1,37 @@
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { useGetNewFieldDefaultVisibility } from '@/page-layout/widgets/fields/hooks/useGetNewFieldDefaultVisibility';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
type UseUpdateNewFieldDefaultVisibilityParams = {
pageLayoutId: string;
widgetId: string;
};
export const useUpdateNewFieldDefaultVisibility = ({
pageLayoutId,
widgetId,
}: UseUpdateNewFieldDefaultVisibilityParams) => {
const { newFieldDefaultVisibility, fieldsConfiguration } =
useGetNewFieldDefaultVisibility({ pageLayoutId, widgetId });
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
const updateNewFieldDefaultVisibility = useCallback(
(isVisible: boolean) => {
if (!isDefined(fieldsConfiguration)) {
return;
}
updatePageLayoutWidget(widgetId, {
configuration: {
...fieldsConfiguration,
newFieldDefaultVisibility: isVisible,
},
});
},
[fieldsConfiguration, updatePageLayoutWidget, widgetId],
);
return { updateNewFieldDefaultVisibility, newFieldDefaultVisibility };
};
@@ -1,46 +0,0 @@
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { getLastGroupId } from '@/page-layout/widgets/fields/utils/getLastGroupId';
const makeGroup = (
overrides: Partial<FieldsWidgetGroup> & { id: string },
): FieldsWidgetGroup => ({
name: 'Group',
position: 0,
isVisible: true,
fields: [],
...overrides,
});
describe('getLastGroupId', () => {
it('should return null for empty array', () => {
expect(getLastGroupId([])).toBeNull();
});
it('should return the id of a single group', () => {
const groups = [makeGroup({ id: 'g1', position: 0 })];
expect(getLastGroupId(groups)).toBe('g1');
});
it('should return the id of the group with the highest position', () => {
const groups = [
makeGroup({ id: 'g1', position: 0 }),
makeGroup({ id: 'g2', position: 2 }),
makeGroup({ id: 'g3', position: 1 }),
];
expect(getLastGroupId(groups)).toBe('g2');
});
it('should not mutate the original array', () => {
const groups = [
makeGroup({ id: 'g2', position: 2 }),
makeGroup({ id: 'g1', position: 0 }),
];
getLastGroupId(groups);
expect(groups[0].id).toBe('g2');
expect(groups[1].id).toBe('g1');
});
});
@@ -1,11 +0,0 @@
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
export const getLastGroupId = (groups: FieldsWidgetGroup[]): string | null => {
if (groups.length === 0) {
return null;
}
const sortedGroups = [...groups].sort((a, b) => a.position - b.position);
return sortedGroups[sortedGroups.length - 1].id;
};
@@ -2,7 +2,11 @@ import { CommandMenuContextProvider } from '@/command-menu-item/contexts/Command
import { SidePanelContainer } from '@/side-panel/components/SidePanelContainer';
import { SidePanelTopBar } from '@/side-panel/components/SidePanelTopBar';
import { SIDE_PANEL_PAGES_CONFIG } from '@/side-panel/constants/SidePanelPagesConfig';
import { SIDE_PANEL_SUB_PAGES } from '@/side-panel/constants/SidePanelSubPages';
import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory';
import { SidePanelSubPageNavigationHeader } from '@/side-panel/pages/common/components/SidePanelSubPageNavigationHeader';
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavigationStackState';
import { sidePanelPageInfoState } from '@/side-panel/states/sidePanelPageInfoState';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -20,6 +24,14 @@ const StyledSidePanelContent = styled.div`
export const SidePanelRouter = () => {
const sidePanelPage = useAtomStateValue(sidePanelPageState);
const sidePanelPageInfo = useAtomStateValue(sidePanelPageInfoState);
const sidePanelNavigationStack = useAtomStateValue(
sidePanelNavigationStackState,
);
const { goBackFromSidePanel } = useSidePanelHistory();
const isSubPage =
sidePanelNavigationStack.length > 1 &&
SIDE_PANEL_SUB_PAGES.has(sidePanelPage);
const rawPageComponent = isDefined(sidePanelPage)
? SIDE_PANEL_PAGES_CONFIG.get(sidePanelPage)
@@ -50,6 +62,12 @@ export const SidePanelRouter = () => {
>
<SidePanelTopBar />
</motion.div>
{isSubPage && isDefined(sidePanelPageInfo.title) && (
<SidePanelSubPageNavigationHeader
title={sidePanelPageInfo.title}
onBackClick={goBackFromSidePanel}
/>
)}
<StyledSidePanelContent>
<CommandMenuContextProvider
isInSidePanel={true}
@@ -2,28 +2,29 @@ import { SidePanelBackButton } from '@/side-panel/components/SidePanelBackButton
import { SidePanelPageInfo } from '@/side-panel/components/SidePanelPageInfo';
import { SidePanelTopBarInputFocusEffect } from '@/side-panel/components/SidePanelTopBarInputFocusEffect';
import { SidePanelTopBarRightCornerIcon } from '@/side-panel/components/SidePanelTopBarRightCornerIcon';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { SIDE_PANEL_SUB_PAGES } from '@/side-panel/constants/SidePanelSubPages';
import { SIDE_PANEL_TOP_BAR_HEIGHT } from '@/side-panel/constants/SidePanelTopBarHeight';
import { SIDE_PANEL_TOP_BAR_HEIGHT_MOBILE } from '@/side-panel/constants/SidePanelTopBarHeightMobile';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useSidePanelContextChips } from '@/side-panel/hooks/useSidePanelContextChips';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavigationStackState';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { motion } from 'framer-motion';
import { useContext, useRef } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
import { IconX } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { useIsMobile } from 'twenty-ui/utilities';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useIsMobile } from 'twenty-ui/utilities';
const StyledInputContainer = styled.div<{ isMobile: boolean }>`
align-items: center;
@@ -93,10 +94,6 @@ export const SidePanelTopBar = () => {
const sidePanelPage = useAtomStateValue(sidePanelPageState);
const sidePanelNavigationStack = useAtomStateValue(
sidePanelNavigationStackState,
);
const { theme } = useContext(ThemeContext);
const { contextChips } = useSidePanelContextChips();
@@ -124,45 +121,36 @@ export const SidePanelTopBar = () => {
});
};
const sidePanelNavigationStack = useAtomStateValue(
sidePanelNavigationStackState,
);
const isOnSubPage = SIDE_PANEL_SUB_PAGES.has(sidePanelPage);
const canGoBack = sidePanelNavigationStack.length > 1;
const shouldShowCloseButton =
!isMobile && sidePanelNavigationStack.length === 1;
const shouldShowBackButton = canGoBack;
const shouldShowBackButton = !isMobile && canGoBack && !isOnSubPage;
const shouldShowCloseButton = !isMobile && !shouldShowBackButton;
const lastChip = contextChips.at(-1);
return (
<StyledInputContainer isMobile={isMobile}>
<StyledContentContainer>
<AnimatePresence>
{shouldShowBackButton && (
<motion.div
exit={{ opacity: 0, width: 0 }}
transition={{
duration: theme.animation.duration.instant,
}}
>
<SidePanelBackButton />
</motion.div>
)}
{shouldShowCloseButton && (
<motion.div
exit={{ opacity: 0, width: 0 }}
transition={{
duration: theme.animation.duration.instant,
}}
>
<IconButton
Icon={IconX}
size="small"
variant="tertiary"
onClick={closeSidePanelMenu}
/>
</motion.div>
)}
</AnimatePresence>
{shouldShowBackButton && <SidePanelBackButton />}
{shouldShowCloseButton && (
<motion.div
exit={{ opacity: 0, width: 0 }}
transition={{
duration: theme.animation.duration.instant,
}}
>
<IconButton
Icon={IconX}
size="small"
variant="tertiary"
onClick={closeSidePanelMenu}
/>
</motion.div>
)}
{lastChip &&
sidePanelPage !== SidePanelPages.Root &&
sidePanelPage !== SidePanelPages.SearchRecords && (
@@ -0,0 +1,6 @@
import { SidePanelPages } from 'twenty-shared/types';
export const SIDE_PANEL_SUB_PAGES = new Set<SidePanelPages>([
SidePanelPages.PageLayoutGraphFilter,
SidePanelPages.PageLayoutFieldsLayout,
]);
@@ -1,5 +1,3 @@
import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory';
import { SidePanelSubPageNavigationHeader } from '@/side-panel/pages/common/components/SidePanelSubPageNavigationHeader';
import { ChartFiltersSettingsInitializeStateEffect } from '@/side-panel/pages/page-layout/components/ChartFiltersSettingsInitializeStateEffect';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/side-panel/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
@@ -15,9 +13,9 @@ import { RecordFiltersComponentInstanceContext } from '@/object-record/record-fi
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useStore } from 'jotai';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledChartFiltersPageContainer = styled.div`
@@ -38,8 +36,6 @@ export const ChartFiltersSettings = ({
objectMetadataItem,
widget,
}: ChartFiltersSettingsProps) => {
const { goBackFromSidePanel } = useSidePanelHistory();
const { instanceId } = getChartFiltersSettingsInstanceId({
widgetId: widget.id,
objectMetadataItemId: objectMetadataItem.id,
@@ -80,10 +76,6 @@ export const ChartFiltersSettings = ({
return (
<>
<SidePanelSubPageNavigationHeader
title={t`Filter`}
onBackClick={goBackFromSidePanel}
/>
<StyledChartFiltersPageContainer>
<div>
<InputLabel>{t`Conditions`}</InputLabel>
@@ -0,0 +1,46 @@
import { CommandMenuItemToggle } from '@/command-menu/components/CommandMenuItemToggle';
import { useGetNewFieldDefaultVisibility } from '@/page-layout/widgets/fields/hooks/useGetNewFieldDefaultVisibility';
import { useUpdateNewFieldDefaultVisibility } from '@/page-layout/widgets/fields/hooks/useUpdateNewFieldDefaultVisibility';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useLingui } from '@lingui/react/macro';
import { IconEye } from 'twenty-ui/display';
export const NewFieldDefaultVisibilityToggle = ({
pageLayoutId,
widgetId,
}: {
pageLayoutId: string;
widgetId: string;
}) => {
const { t } = useLingui();
const { newFieldDefaultVisibility } = useGetNewFieldDefaultVisibility({
pageLayoutId,
widgetId,
});
const { updateNewFieldDefaultVisibility } =
useUpdateNewFieldDefaultVisibility({
pageLayoutId,
widgetId,
});
const handleToggle = () => {
updateNewFieldDefaultVisibility(!newFieldDefaultVisibility);
};
return (
<SelectableListItem
itemId="new-field-default-visibility"
onEnter={handleToggle}
>
<CommandMenuItemToggle
LeftIcon={IconEye}
text={t`Set fields created in the future as "visible"`}
id="new-field-default-visibility"
toggled={newFieldDefaultVisibility}
onToggleChange={handleToggle}
/>
</SelectableListItem>
);
};
@@ -1,18 +1,15 @@
import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory';
import { SidePanelSubPageNavigationHeader } from '@/side-panel/pages/common/components/SidePanelSubPageNavigationHeader';
import { usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { useTemporaryFieldsConfiguration } from '@/page-layout/hooks/useTemporaryFieldsConfiguration';
import { FieldsConfigurationEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationEditor';
import { FieldsWidgetGroupsDraftInitializationEffect } from '@/page-layout/widgets/fields/components/FieldsWidgetGroupsDraftInitializationEffect';
import { usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
type FieldsConfiguration,
WidgetConfigurationType,
} from '~/generated-metadata/graphql';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledOuterContainer = styled.div`
display: flex;
@@ -30,8 +27,6 @@ const StyledContainer = styled.div`
`;
export const SidePanelPageLayoutFieldsLayout = () => {
const { goBackFromSidePanel } = useSidePanelHistory();
const { pageLayoutId } =
usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord();
@@ -52,10 +47,6 @@ export const SidePanelPageLayoutFieldsLayout = () => {
return (
<StyledOuterContainer>
<SidePanelSubPageNavigationHeader
title={t`Layout`}
onBackClick={goBackFromSidePanel}
/>
<StyledContainer>
<FieldsWidgetGroupsDraftInitializationEffect
viewId={fieldsConfiguration.viewId ?? null}
@@ -3,6 +3,7 @@ import { CommandMenuItemToggle } from '@/command-menu/components/CommandMenuItem
import { useFieldsWidgetGroups } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetGroups';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { NewFieldDefaultVisibilityToggle } from '@/side-panel/pages/page-layout/components/NewFieldDefaultVisibilityToggle';
import { WidgetSettingsFooter } from '@/side-panel/pages/page-layout/components/WidgetSettingsFooter';
import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel';
import { usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord';
@@ -78,6 +79,7 @@ export const SidePanelPageLayoutFieldsSettings = () => {
const selectableItemIds = [
'layout',
'new-field-default-visibility',
'display-more-fields-button',
'action-button',
'move-down',
@@ -119,6 +121,10 @@ export const SidePanelPageLayoutFieldsSettings = () => {
onToggleChange={handleToggleShouldAllowUserToSeeHiddenFields}
/>
</SelectableListItem>
<NewFieldDefaultVisibilityToggle
pageLayoutId={pageLayoutId}
widgetId={widgetInEditMode.id}
/>
</SidePanelGroup>
</SidePanelList>
</StyledSidePanelContainer>
@@ -12,7 +12,7 @@ export const getPageLayoutPageTitle = (page: PageLayoutSidePanelPage) => {
case SidePanelPages.PageLayoutIframeSettings:
return t`iFrame Settings`;
case SidePanelPages.PageLayoutGraphFilter:
return t`Configure filters`;
return t`Filter`;
case SidePanelPages.PageLayoutTabSettings:
return t`Tab Settings`;
case SidePanelPages.PageLayoutFieldsSettings:
@@ -1,132 +0,0 @@
import { styled } from '@linaria/react';
import { useRef } from 'react';
import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useApolloClient, useMutation } from '@apollo/client';
import { useLingui } from '@lingui/react/macro';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
import { SectionAlignment, SectionFontColor } from 'twenty-ui/layout';
import { INSTALL_APPLICATION } from '~/modules/marketplace/graphql/mutations/installApplication';
import { useUploadAppTarball } from '~/modules/marketplace/hooks/useUploadAppTarball';
import {
StyledAppModal,
StyledAppModalButton,
StyledAppModalSection,
StyledAppModalTitle,
} from '~/pages/settings/applications/components/SettingsAppModalLayout';
export const UPLOAD_TARBALL_MODAL_ID = 'upload-tarball-modal';
const StyledFileInput = styled.input`
display: none;
`;
export const SettingsUploadTarballModal = () => {
const { t: tFn } = useLingui();
const { closeModal } = useModal();
const { upload, isUploading } = useUploadAppTarball();
const [installApplication] = useMutation(INSTALL_APPLICATION);
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const apolloClient = useApolloClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSelectFile = () => {
fileInputRef.current?.click();
};
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!isDefined(file)) {
return;
}
try {
const uploadResult = await upload(file);
if (!uploadResult.success) {
return;
}
const registrationId = uploadResult.registrationId;
if (isDefined(registrationId)) {
try {
await installApplication({
variables: { appRegistrationId: registrationId },
});
enqueueSuccessSnackBar({
message: t`Application installed successfully.`,
});
} catch {
enqueueErrorSnackBar({
message: t`Tarball uploaded but installation failed.`,
});
}
}
await apolloClient.refetchQueries({
include: [FIND_MANY_APPLICATION_REGISTRATIONS],
});
closeModal(UPLOAD_TARBALL_MODAL_ID);
} finally {
if (isDefined(fileInputRef.current)) {
fileInputRef.current.value = '';
}
}
};
const handleCancel = () => {
closeModal(UPLOAD_TARBALL_MODAL_ID);
};
return (
<StyledAppModal
modalId={UPLOAD_TARBALL_MODAL_ID}
isClosable={true}
padding="large"
dataGloballyPreventClickOutside
>
<StyledAppModalTitle>
<H1Title
title={tFn`Upload tarball`}
fontColor={H1TitleFontColor.Primary}
/>
</StyledAppModalTitle>
<StyledAppModalSection
alignment={SectionAlignment.Center}
fontColor={SectionFontColor.Primary}
>
{tFn`Select a .tar.gz application package to upload and install.`}
</StyledAppModalSection>
<StyledFileInput
ref={fileInputRef}
type="file"
accept=".tar.gz,.tgz"
onChange={handleFileChange}
/>
<StyledAppModalButton
onClick={handleCancel}
variant="secondary"
title={tFn`Cancel`}
fullWidth
/>
<StyledAppModalButton
onClick={handleSelectFile}
variant="secondary"
accent="blue"
title={tFn`Choose file`}
disabled={isUploading}
fullWidth
/>
</StyledAppModal>
);
};
@@ -1,11 +1,7 @@
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { UPDATE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/mutations/updateApplicationRegistration';
import { FIND_APPLICATION_REGISTRATION_STATS } from '@/settings/application-registrations/graphql/queries/findApplicationRegistrationStats';
import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations';
import { FIND_ONE_APPLICATION_REGISTRATION } from '@/settings/application-registrations/graphql/queries/findOneApplicationRegistration';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useMutation, useQuery } from '@apollo/client';
import { useQuery } from '@apollo/client';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import {
@@ -35,7 +31,6 @@ export const SettingsApplicationRegistrationDistributionTab = ({
registration: ApplicationRegistrationData;
}) => {
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const applicationRegistrationId = registration.id;
@@ -47,32 +42,6 @@ export const SettingsApplicationRegistrationDistributionTab = ({
skip: !applicationRegistrationId,
});
const [updateRegistration] = useMutation(UPDATE_APPLICATION_REGISTRATION, {
refetchQueries: [
FIND_ONE_APPLICATION_REGISTRATION,
FIND_MANY_APPLICATION_REGISTRATIONS,
],
});
const handleToggleListed = async () => {
try {
await updateRegistration({
variables: {
input: {
id: applicationRegistrationId,
update: {
isListed: !registration.isListed,
},
},
},
});
} catch {
enqueueErrorSnackBar({
message: t`Error updating marketplace listing`,
});
}
};
const marketplacePageUrl = getSettingsPath(
SettingsPath.AvailableApplicationDetail,
{
@@ -111,41 +80,31 @@ export const SettingsApplicationRegistrationDistributionTab = ({
return (
<>
<Section>
<H2Title
title={t`Marketplace Listing`}
description={t`Control visibility on the marketplace. Unlisted apps are still accessible via direct link.`}
/>
<Card rounded>
<SettingsOptionCardContentToggle
title={t`Listed on marketplace`}
description={
isNpmSource
? t`Managed by the marketplace catalog sync for npm packages`
: t`When enabled, this app appears in the marketplace browse page`
}
checked={registration.isListed}
onChange={handleToggleListed}
disabled={isNpmSource}
divider
{isNpmSource && (
<Section>
<H2Title
title={t`Marketplace Listing`}
description={t`This app is listed on the marketplace because it is published to npm.`}
/>
<SettingsOptionCardContentToggle
title={t`Featured`}
description={t`Featured apps are curated. Open a PR to request featured status.`}
checked={registration.isFeatured}
onChange={() => {}}
disabled
/>
</Card>
<StyledButtonGroup>
<Button
Icon={IconExternalLink}
title={t`View marketplace page`}
variant="secondary"
to={marketplacePageUrl}
/>
</StyledButtonGroup>
</Section>
<Card rounded>
<SettingsOptionCardContentToggle
title={t`Featured`}
description={t`Featured apps are curated. Open a PR to request featured status.`}
checked={registration.isFeatured}
onChange={() => {}}
disabled
/>
</Card>
<StyledButtonGroup>
<Button
Icon={IconExternalLink}
title={t`View marketplace page`}
variant="secondary"
to={marketplacePageUrl}
/>
</StyledButtonGroup>
</Section>
)}
{hasStats && (
<Section>
@@ -2,12 +2,11 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations';
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useQuery } from '@apollo/client';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useQuery } from '@apollo/client';
import { useNavigate } from 'react-router-dom';
import { useContext } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
@@ -17,73 +16,128 @@ import {
IconChevronRight,
IconCopy,
IconFileInfo,
IconUpload,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useContext } from 'react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { Tag } from 'twenty-ui/components';
import {
SettingsUploadTarballModal,
UPLOAD_TARBALL_MODAL_ID,
} from '~/pages/settings/applications/components/SettingsUploadTarballModal';
type ApplicationRegistrationFragmentFragment,
ApplicationRegistrationSourceType,
} from '~/generated-metadata/graphql';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledButtonContainer = styled.div`
margin: ${themeCssVariables.spacing[2]} 0;
`;
const StyledButtonGroupContainer = styled.div`
const StyledRowRightContainer = styled.div`
align-items: center;
display: flex;
justify-content: flex-end;
margin-bottom: ${themeCssVariables.spacing[4]};
gap: ${themeCssVariables.spacing[2]};
`;
type ApplicationRegistration = {
id: string;
name: string;
description: string | null;
const SOURCE_TYPE_BADGE_CONFIG: Record<
ApplicationRegistrationSourceType,
{ label: string; color: 'gray' | 'blue' | 'green' }
> = {
[ApplicationRegistrationSourceType.LOCAL]: {
label: 'Dev',
color: 'gray',
},
[ApplicationRegistrationSourceType.NPM]: {
label: 'Npm',
color: 'blue',
},
[ApplicationRegistrationSourceType.TARBALL]: {
label: 'Internal',
color: 'green',
},
};
export const SettingsApplicationsDeveloperTab = () => {
const { theme } = useContext(ThemeContext);
const { t } = useLingui();
const navigate = useNavigate();
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { openModal } = useModal();
const { copyToClipboard } = useCopyToClipboard();
const { data, loading } = useQuery(FIND_MANY_APPLICATION_REGISTRATIONS);
const registrations: ApplicationRegistration[] =
const registrations: ApplicationRegistrationFragmentFragment[] =
data?.findManyApplicationRegistrations ?? [];
const commands = [
const createCommands = [
// oxlint-disable-next-line lingui/no-unlocalized-strings
'npx create-twenty-app@latest my-twenty-app',
// oxlint-disable-next-line lingui/no-unlocalized-strings
'cd my-twenty-app',
];
const copyButton = (
const createCopyButton = (
<Button
onClick={() => {
copyToClipboard(commands.join('\n'), t`Commands copied to clipboard`);
copyToClipboard(
createCommands.join('\n'),
t`Commands copied to clipboard`,
);
}}
ariaLabel={t`Copy commands`}
Icon={IconCopy}
/>
);
const getRegistrationLink = (
registration: ApplicationRegistrationFragmentFragment,
) =>
getSettingsPath(SettingsPath.ApplicationRegistrationDetail, {
applicationRegistrationId: registration.id,
});
const syncCommands = [
// oxlint-disable-next-line lingui/no-unlocalized-strings
'yarn twenty app:dev',
];
const syncCopyButton = (
<Button
onClick={() => {
copyToClipboard(
syncCommands.join('\n'),
t`Command copied to clipboard`,
);
}}
ariaLabel={t`Copy command`}
Icon={IconCopy}
/>
);
const RowRightWithBadge = ({
item,
}: {
item: ApplicationRegistrationFragmentFragment;
}) => {
const badgeConfig = SOURCE_TYPE_BADGE_CONFIG[item.sourceType];
return (
<StyledRowRightContainer>
<Tag text={badgeConfig.label} color={badgeConfig.color} preventShrink />
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</StyledRowRightContainer>
);
};
return (
<>
<Section>
<H2Title
title={t`Create an application`}
description={t`You can either create a private app or share it to others`}
title={t`Create & Develop`}
description={t`Scaffold a new app, then use the CLI to develop, publish, and distribute`}
/>
<CommandBlock commands={commands} button={copyButton} />
<CommandBlock commands={createCommands} button={createCopyButton} />
<StyledButtonContainer>
<Button
Icon={IconFileInfo}
@@ -100,44 +154,27 @@ export const SettingsApplicationsDeveloperTab = () => {
/>
</StyledButtonContainer>
</Section>
<Section>
<H2Title
title={t`My Apps`}
description={t`Apps you've created, registered, or published`}
title={t`Your Apps`}
description={t`All applications registered on this workspace`}
/>
{registrations.length > 0 && (
{registrations.length > 0 ? (
<SettingsListCard
items={registrations}
getItemLabel={(registration) => registration.name}
isLoading={loading}
RowIcon={IconApps}
onRowClick={(registration) => {
navigate(
getSettingsPath(SettingsPath.ApplicationRegistrationDetail, {
applicationRegistrationId: registration.id,
}),
);
}}
RowRightComponent={() => (
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)}
to={getRegistrationLink}
RowRightComponent={RowRightWithBadge}
/>
) : (
!loading && (
<CommandBlock commands={syncCommands} button={syncCopyButton} />
)
)}
</Section>
<StyledButtonGroupContainer>
<Button
Icon={IconUpload}
title={t`Upload tarball`}
size="small"
variant="secondary"
onClick={() => openModal(UPLOAD_TARBALL_MODAL_ID)}
/>
</StyledButtonGroupContainer>
<SettingsUploadTarballModal />
</>
);
};
@@ -874,11 +874,6 @@ type RatioAggregateConfig {
optionValue: String!
}
type NewFieldDefaultConfiguration {
isVisible: Boolean!
viewFieldGroupId: String
}
type RichTextV2Body {
blocknote: String
markdown: String
@@ -1168,7 +1163,7 @@ type FieldRichTextConfiguration {
type FieldsConfiguration {
configurationType: WidgetConfigurationType!
viewId: String
newFieldDefaultConfiguration: NewFieldDefaultConfiguration
newFieldDefaultVisibility: Boolean
shouldAllowUserToSeeHiddenFields: Boolean
}
@@ -644,12 +644,6 @@ export interface RatioAggregateConfig {
__typename: 'RatioAggregateConfig'
}
export interface NewFieldDefaultConfiguration {
isVisible: Scalars['Boolean']
viewFieldGroupId?: Scalars['String']
__typename: 'NewFieldDefaultConfiguration'
}
export interface RichTextV2Body {
blocknote?: Scalars['String']
markdown?: Scalars['String']
@@ -887,7 +881,7 @@ export interface FieldRichTextConfiguration {
export interface FieldsConfiguration {
configurationType: WidgetConfigurationType
viewId?: Scalars['String']
newFieldDefaultConfiguration?: NewFieldDefaultConfiguration
newFieldDefaultVisibility?: Scalars['Boolean']
shouldAllowUserToSeeHiddenFields?: Scalars['Boolean']
__typename: 'FieldsConfiguration'
}
@@ -3488,13 +3482,6 @@ export interface RatioAggregateConfigGenqlSelection{
__scalar?: boolean | number
}
export interface NewFieldDefaultConfigurationGenqlSelection{
isVisible?: boolean | number
viewFieldGroupId?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface RichTextV2BodyGenqlSelection{
blocknote?: boolean | number
markdown?: boolean | number
@@ -3752,7 +3739,7 @@ export interface FieldRichTextConfigurationGenqlSelection{
export interface FieldsConfigurationGenqlSelection{
configurationType?: boolean | number
viewId?: boolean | number
newFieldDefaultConfiguration?: NewFieldDefaultConfigurationGenqlSelection
newFieldDefaultVisibility?: boolean | number
shouldAllowUserToSeeHiddenFields?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
@@ -6452,14 +6439,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const NewFieldDefaultConfiguration_possibleTypes: string[] = ['NewFieldDefaultConfiguration']
export const isNewFieldDefaultConfiguration = (obj?: { __typename?: any } | null): obj is NewFieldDefaultConfiguration => {
if (!obj?.__typename) throw new Error('__typename is missing in "isNewFieldDefaultConfiguration"')
return NewFieldDefaultConfiguration_possibleTypes.includes(obj.__typename)
}
const RichTextV2Body_possibleTypes: string[] = ['RichTextV2Body']
export const isRichTextV2Body = (obj?: { __typename?: any } | null): obj is RichTextV2Body => {
if (!obj?.__typename) throw new Error('__typename is missing in "isRichTextV2Body"')
File diff suppressed because it is too large Load Diff
@@ -273,7 +273,7 @@ export const fromPageLayoutWidgetConfigurationToUniversalConfiguration = ({
}
case WidgetConfigurationType.FIELDS: {
const { viewId, newFieldDefaultConfiguration, ...rest } = configuration;
const { viewId, newFieldDefaultVisibility, ...rest } = configuration;
let viewUniversalIdentifier: string | null = null;
@@ -291,40 +291,10 @@ export const fromPageLayoutWidgetConfigurationToUniversalConfiguration = ({
}
}
if (!isDefined(newFieldDefaultConfiguration)) {
return {
...rest,
newFieldDefaultConfiguration,
viewId: viewUniversalIdentifier,
};
}
let viewFieldGroupUniversalIdentifier: string | null = null;
if (isDefined(newFieldDefaultConfiguration.viewFieldGroupId)) {
viewFieldGroupUniversalIdentifier =
viewFieldGroupUniversalIdentifierById[
newFieldDefaultConfiguration.viewFieldGroupId
] ?? null;
if (
!isDefined(viewFieldGroupUniversalIdentifier) &&
shouldThrowOnMissingIdentifier
) {
throw new FlatEntityMapsException(
`View field group universal identifier not found for id: ${newFieldDefaultConfiguration.viewFieldGroupId}`,
FlatEntityMapsExceptionCode.RELATION_UNIVERSAL_IDENTIFIER_NOT_FOUND,
);
}
}
return {
...rest,
newFieldDefaultVisibility,
viewId: viewUniversalIdentifier,
newFieldDefaultConfiguration: {
isVisible: newFieldDefaultConfiguration.isVisible,
viewFieldGroupId: viewFieldGroupUniversalIdentifier,
},
};
}
@@ -1,8 +1,8 @@
import { type FlatPageLayoutWidgetMaps } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget-maps.type';
import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/metadata-modules/flat-view-field/constants/default-view-field-size.constant';
import { type FlatViewFieldMaps } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field-maps.type';
import { computeFlatViewFieldsFromFieldsWidgets } from 'src/engine/metadata-modules/flat-view-field/utils/compute-flat-view-fields-from-fields-widgets.util';
import { type FlatPageLayoutWidgetMaps } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget-maps.type';
import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type';
import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
@@ -37,13 +37,24 @@ const buildFlatViewMaps = (
}) as unknown as FlatViewMaps;
const buildFlatViewFieldGroupMaps = (
entries: { id: string; universalIdentifier: string }[] = [],
entries: {
id: string;
universalIdentifier: string;
viewId?: string;
position?: number;
}[] = [],
): FlatViewFieldGroupMaps =>
({
byUniversalIdentifier: Object.fromEntries(
entries.map((entry) => [
entry.universalIdentifier,
{ universalIdentifier: entry.universalIdentifier, id: entry.id },
{
universalIdentifier: entry.universalIdentifier,
id: entry.id,
viewId: entry.viewId ?? VIEW_ID,
position: entry.position ?? 0,
deletedAt: null,
},
]),
),
universalIdentifierById: Object.fromEntries(
@@ -74,14 +85,12 @@ const buildFieldsWidget = ({
objectMetadataUniversalIdentifier = OBJECT_METADATA_UNIVERSAL_IDENTIFIER,
viewId = VIEW_ID,
isVisible = true,
viewFieldGroupId = null as string | null,
deletedAt = null as string | null,
}: {
widgetUniversalIdentifier?: string;
objectMetadataUniversalIdentifier?: string;
viewId?: string | null;
isVisible?: boolean;
viewFieldGroupId?: string | null;
deletedAt?: string | null;
} = {}) => ({
universalIdentifier: widgetUniversalIdentifier,
@@ -91,10 +100,7 @@ const buildFieldsWidget = ({
configuration: {
configurationType: WidgetConfigurationType.FIELDS,
viewId,
newFieldDefaultConfiguration: {
isVisible,
viewFieldGroupId,
},
newFieldDefaultVisibility: isVisible,
},
universalConfiguration: null,
overrides: null,
@@ -478,7 +484,7 @@ describe('computeFlatViewFieldsFromFieldsWidgets', () => {
});
describe('view field group handling', () => {
it('should resolve viewFieldGroupUniversalIdentifier when viewFieldGroupId is set', () => {
it('should resolve to the last view field group when groups exist', () => {
const result = computeFlatViewFieldsFromFieldsWidgets({
fieldsToCreate: [
{
@@ -488,7 +494,7 @@ describe('computeFlatViewFieldsFromFieldsWidgets', () => {
},
],
flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([
buildFieldsWidget({ viewFieldGroupId: VIEW_FIELD_GROUP_ID }),
buildFieldsWidget(),
]),
flatViewFieldMaps: buildFlatViewFieldMaps(),
flatViewMaps: buildFlatViewMaps([
@@ -509,7 +515,7 @@ describe('computeFlatViewFieldsFromFieldsWidgets', () => {
);
});
it('should set viewFieldGroupUniversalIdentifier to null when viewFieldGroupId is null', () => {
it('should set viewFieldGroupUniversalIdentifier to null when no groups exist', () => {
const result = computeFlatViewFieldsFromFieldsWidgets({
fieldsToCreate: [
{
@@ -519,7 +525,7 @@ describe('computeFlatViewFieldsFromFieldsWidgets', () => {
},
],
flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([
buildFieldsWidget({ viewFieldGroupId: null }),
buildFieldsWidget(),
]),
flatViewFieldMaps: buildFlatViewFieldMaps(),
flatViewMaps: buildFlatViewMaps([
@@ -532,7 +538,7 @@ describe('computeFlatViewFieldsFromFieldsWidgets', () => {
expect(result[0].viewFieldGroupUniversalIdentifier).toBeNull();
});
it('should compute position only from view fields in the same group', () => {
it('should compute position only from view fields in the last group', () => {
const result = computeFlatViewFieldsFromFieldsWidgets({
fieldsToCreate: [
{
@@ -542,7 +548,7 @@ describe('computeFlatViewFieldsFromFieldsWidgets', () => {
},
],
flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([
buildFieldsWidget({ viewFieldGroupId: VIEW_FIELD_GROUP_ID }),
buildFieldsWidget(),
]),
flatViewFieldMaps: buildFlatViewFieldMaps([
{
@@ -582,7 +588,7 @@ describe('computeFlatViewFieldsFromFieldsWidgets', () => {
expect(result[0].position).toBe(3);
});
it('should compute position only from ungrouped view fields when viewFieldGroupId is null', () => {
it('should compute position only from ungrouped view fields when no groups exist', () => {
const result = computeFlatViewFieldsFromFieldsWidgets({
fieldsToCreate: [
{
@@ -592,7 +598,7 @@ describe('computeFlatViewFieldsFromFieldsWidgets', () => {
},
],
flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([
buildFieldsWidget({ viewFieldGroupId: null }),
buildFieldsWidget(),
]),
flatViewFieldMaps: buildFlatViewFieldMaps([
{
@@ -3,10 +3,10 @@ import { v4 } from 'uuid';
import { type FlatPageLayoutWidgetMaps } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget-maps.type';
import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/metadata-modules/flat-view-field/constants/default-view-field-size.constant';
import { type FlatViewFieldMaps } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field-maps.type';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type';
import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type';
import { type FieldsConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/fields-configuration.dto';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
@@ -45,9 +45,33 @@ const getMatchingFieldsWidgets = ({
objectMetadataUniversalIdentifier &&
isFieldsWidgetConfiguration(widget.configuration) &&
isDefined(widget.configuration.viewId) &&
isDefined(widget.configuration.newFieldDefaultConfiguration),
isDefined(widget.configuration.newFieldDefaultVisibility),
);
const findLastViewFieldGroupId = ({
viewId,
flatViewFieldGroupMaps,
}: {
viewId: string;
flatViewFieldGroupMaps: FlatViewFieldGroupMaps;
}): string | null => {
const groupsForView = Object.values(
flatViewFieldGroupMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((group) => !isDefined(group.deletedAt) && group.viewId === viewId);
if (groupsForView.length === 0) {
return null;
}
const lastGroup = groupsForView.reduce((maxGroup, group) =>
group.position > maxGroup.position ? group : maxGroup,
);
return lastGroup.id;
};
const computeNextPosition = ({
viewId,
viewFieldGroupId,
@@ -127,8 +151,7 @@ export const computeFlatViewFieldsFromFieldsWidgets = ({
const configuration = widget.configuration;
const viewId = configuration.viewId!;
const { isVisible, viewFieldGroupId } =
configuration.newFieldDefaultConfiguration!;
const isVisible = configuration.newFieldDefaultVisibility!;
const viewUniversalIdentifier =
flatViewMaps.universalIdentifierById[viewId] ?? null;
@@ -137,6 +160,11 @@ export const computeFlatViewFieldsFromFieldsWidgets = ({
continue;
}
const viewFieldGroupId = findLastViewFieldGroupId({
viewId,
flatViewFieldGroupMaps,
});
const viewFieldGroupUniversalIdentifier = isDefined(viewFieldGroupId)
? (flatViewFieldGroupMaps.universalIdentifierById[viewFieldGroupId] ??
null)
@@ -1,30 +1,16 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
IsUUID,
ValidateNested,
} from 'class-validator';
import { type FieldsConfiguration } from 'twenty-shared/types';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
@ObjectType('NewFieldDefaultConfiguration')
export class NewFieldDefaultConfigurationDTO {
@Field(() => Boolean)
@IsBoolean()
isVisible: boolean;
@Field(() => String, { nullable: true })
@IsOptional()
@IsUUID()
viewFieldGroupId: string | null;
}
@ObjectType('FieldsConfiguration')
export class FieldsConfigurationDTO implements FieldsConfiguration {
@Field(() => WidgetConfigurationType)
@@ -37,11 +23,10 @@ export class FieldsConfigurationDTO implements FieldsConfiguration {
@IsUUID()
viewId: string | null;
@Field(() => NewFieldDefaultConfigurationDTO, { nullable: true })
@Field(() => Boolean, { nullable: true })
@IsOptional()
@ValidateNested()
@Type(() => NewFieldDefaultConfigurationDTO)
newFieldDefaultConfiguration: NewFieldDefaultConfigurationDTO | null;
@IsBoolean()
newFieldDefaultVisibility: boolean | null;
@Field(() => Boolean, { nullable: true })
@IsOptional()
@@ -128,18 +128,12 @@ const buildFieldsWidgetConfiguration = ({
configuration: {
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
newFieldDefaultConfiguration: {
isVisible: true,
viewFieldGroupId: null,
},
newFieldDefaultVisibility: true,
},
universalConfiguration: {
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
newFieldDefaultConfiguration: {
isVisible: true,
viewFieldGroupId: null,
},
newFieldDefaultVisibility: true,
},
};
}
@@ -167,32 +161,16 @@ const buildFieldsWidgetConfiguration = ({
const viewUniversalIdentifier = viewDefinition?.universalIdentifier ?? null;
const otherViewFieldGroupId =
views[recordPageFieldsViewName]?.viewFieldGroups?.other?.id ?? null;
const otherViewFieldGroupUniversalIdentifier =
viewDefinition?.viewFieldGroups?.other?.universalIdentifier ?? null;
const newFieldDefaultConfiguration = {
isVisible: true,
viewFieldGroupId: otherViewFieldGroupId,
};
const universalNewFieldDefaultConfiguration = {
isVisible: true,
viewFieldGroupId: otherViewFieldGroupUniversalIdentifier,
};
return {
configuration: {
configurationType: WidgetConfigurationType.FIELDS,
viewId,
newFieldDefaultConfiguration,
newFieldDefaultVisibility: true,
},
universalConfiguration: {
configurationType: WidgetConfigurationType.FIELDS,
viewId: viewUniversalIdentifier,
newFieldDefaultConfiguration: universalNewFieldDefaultConfiguration,
newFieldDefaultVisibility: true,
},
};
};
@@ -257,7 +257,7 @@ export const fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration = ({
case WidgetConfigurationType.FIELDS: {
const {
viewId: viewUniversalIdentifier,
newFieldDefaultConfiguration: universalNewFieldDefaultConfiguration,
newFieldDefaultVisibility,
...rest
} = universalConfiguration;
@@ -279,37 +279,7 @@ export const fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration = ({
viewId = flatView.id;
}
let newFieldDefaultConfiguration:
| { isVisible: boolean; viewFieldGroupId: string | null }
| null
| undefined = universalNewFieldDefaultConfiguration;
if (
isDefined(universalNewFieldDefaultConfiguration) &&
isDefined(universalNewFieldDefaultConfiguration.viewFieldGroupId)
) {
const viewFieldGroupUniversalIdentifier =
universalNewFieldDefaultConfiguration.viewFieldGroupId;
const flatViewFieldGroup = findFlatEntityByUniversalIdentifier({
flatEntityMaps: flatViewFieldGroupMaps,
universalIdentifier: viewFieldGroupUniversalIdentifier,
});
if (!isDefined(flatViewFieldGroup)) {
throw new FlatEntityMapsException(
`View field group not found for universal identifier: ${viewFieldGroupUniversalIdentifier}`,
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
newFieldDefaultConfiguration = {
isVisible: universalNewFieldDefaultConfiguration.isVisible,
viewFieldGroupId: flatViewFieldGroup.id,
};
}
return { ...rest, viewId, newFieldDefaultConfiguration };
return { ...rest, viewId, newFieldDefaultVisibility };
}
case WidgetConfigurationType.FRONT_COMPONENT: {
@@ -95,15 +95,10 @@ export type FieldConfiguration = {
configurationType: 'FIELD';
};
type NewFieldDefaultConfiguration = {
isVisible: boolean;
viewFieldGroupId: string | null;
};
export type FieldsConfiguration = {
configurationType: 'FIELDS';
viewId?: string | null;
newFieldDefaultConfiguration?: NewFieldDefaultConfiguration | null;
newFieldDefaultVisibility?: boolean | null;
shouldAllowUserToSeeHiddenFields?: boolean;
};