Add support for standalone pages: a new `PageLayout` type (`STANDALONE_PAGE`) that can be rendered independently at `/page/:pageLayoutId`, not tied to any record or object context. - New `STANDALONE_PAGE` page layout type - New `PAGE_LAYOUT` navigation menu item type: adds a `pageLayoutId` foreign key to `NavigationMenuItemEntity`, allowing sidebar items to link directly to standalone pages - New `GLOBAL_OBJECT_CONTEXT` command menu availability type: separates object-context-dependent commands (Create Record, Import, Export, See Deleted, Create View, Hide Deleted) from truly global ones, so standalone pages only show relevant commands - Frontend routing & rendering: adds a `/page/:pageLayoutId` route with its own page component, header, and command menu - Widget rendering refactor - Instance commands: two fast 1.22 migrations: `pageLayoutId` column + `STANDALONE_PAGE` enum, and `GLOBAL_OBJECT_CONTEXT` availability type enum - Workspace command: backfills existing command menu items from `GLOBAL` to `GLOBAL_OBJECT_CONTEXT` where appropriate - Dev seeds: adds a sample "Star History" standalone page with an iframe widget for local development
346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
import { useCommandMenuContextApi } from '@/command-menu-item/hooks/useCommandMenuContextApi';
|
|
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
|
|
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
|
|
import { groupCommandMenuItems } from '@/command-menu-item/utils/groupCommandMenuItems';
|
|
import { CommandMenuItemEditRecordSelectionDropdown } from '@/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown';
|
|
import { CommandMenuItemOptionsDropdown } from '@/command-menu-item/edit/components/CommandMenuItemOptionsDropdown';
|
|
import { useReorderCommandMenuItemsInDraft } from '@/command-menu-item/edit/hooks/useReorderCommandMenuItemsInDraft';
|
|
import { useResetCommandMenuItemsDraft } from '@/command-menu-item/edit/hooks/useResetCommandMenuItemsDraft';
|
|
import { useUpdateCommandMenuItemInDraft } from '@/command-menu-item/edit/hooks/useUpdateCommandMenuItemInDraft';
|
|
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
|
|
import { mainContextStoreHasSelectedRecordsSelector } from '@/context-store/states/selectors/mainContextStoreHasSelectedRecordsSelector';
|
|
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
|
|
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
|
|
import { SidePanelList } from '@/side-panel/components/SidePanelList';
|
|
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
|
|
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
|
|
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
|
|
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
|
import { SidePanelFooter } from '@/ui/layout/side-panel/components/SidePanelFooter';
|
|
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
|
import { type DropResult } from '@hello-pangea/dnd';
|
|
import { styled } from '@linaria/react';
|
|
import { useLingui } from '@lingui/react/macro';
|
|
import { ContextStorePageType } from 'twenty-shared/types';
|
|
import {
|
|
interpolateCommandMenuItemTemplate,
|
|
isDefined,
|
|
} from 'twenty-shared/utils';
|
|
import {
|
|
IconDotsVertical,
|
|
IconPin,
|
|
IconPinnedOff,
|
|
IconRefresh,
|
|
useIcons,
|
|
} from 'twenty-ui/display';
|
|
import { Button } from 'twenty-ui/input';
|
|
import { MenuItem, MenuItemDraggable } from 'twenty-ui/navigation';
|
|
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
|
import {
|
|
CommandMenuItemAvailabilityType,
|
|
type CommandMenuItemFieldsFragment,
|
|
} from '~/generated-metadata/graphql';
|
|
import { normalizeSearchText } from '~/utils/normalizeSearchText';
|
|
|
|
const StyledContainer = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
`;
|
|
|
|
const StyledViewbar = styled.div`
|
|
align-items: center;
|
|
backdrop-filter: blur(5px);
|
|
border-bottom: 1px solid ${themeCssVariables.border.color.light};
|
|
display: flex;
|
|
flex-shrink: 0;
|
|
height: 40px;
|
|
padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]};
|
|
`;
|
|
|
|
const StyledContent = styled.div`
|
|
flex: 1;
|
|
overflow: auto;
|
|
`;
|
|
|
|
export const SidePanelCommandMenuItemEditPage = () => {
|
|
const { t } = useLingui();
|
|
const { getIcon } = useIcons();
|
|
const commandMenuContextApi = useCommandMenuContextApi();
|
|
|
|
const currentObjectMetadataItemId =
|
|
commandMenuContextApi.objectMetadataItem.id;
|
|
|
|
const hasObjectContext = isDefined(currentObjectMetadataItemId);
|
|
|
|
const isRecordPage =
|
|
commandMenuContextApi.pageType === ContextStorePageType.Record;
|
|
|
|
const isIndexPage =
|
|
commandMenuContextApi.pageType === ContextStorePageType.Index;
|
|
|
|
const mainContextStoreHasSelectedRecords = useAtomStateValue(
|
|
mainContextStoreHasSelectedRecordsSelector,
|
|
);
|
|
|
|
const sidePanelSearch = useAtomStateValue(sidePanelSearchState);
|
|
|
|
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
|
|
const serverItemsById = new Map(
|
|
commandMenuItems.map((item) => [item.id, item]),
|
|
);
|
|
const commandMenuItemsDraft =
|
|
useAtomStateValue(commandMenuItemsDraftState) ?? [];
|
|
const { updateCommandMenuItemInDraft } = useUpdateCommandMenuItemInDraft();
|
|
const { reorderCommandMenuItemInDraft } = useReorderCommandMenuItemsInDraft();
|
|
const { resetCommandMenuItemsDraft } = useResetCommandMenuItemsDraft();
|
|
|
|
const allowedAvailabilityTypes = new Set<CommandMenuItemAvailabilityType>([
|
|
CommandMenuItemAvailabilityType.GLOBAL,
|
|
...(isIndexPage || isRecordPage
|
|
? [CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT]
|
|
: []),
|
|
mainContextStoreHasSelectedRecords
|
|
? CommandMenuItemAvailabilityType.RECORD_SELECTION
|
|
: CommandMenuItemAvailabilityType.FALLBACK,
|
|
]);
|
|
|
|
const filteredCommandMenuItems = commandMenuItemsDraft
|
|
.filter(
|
|
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
|
|
)
|
|
.filter((item) => allowedAvailabilityTypes.has(item.availabilityType))
|
|
.sort((firstItem, secondItem) => firstItem.position - secondItem.position);
|
|
|
|
const filteredCommandMenuItemIds = new Set(
|
|
filteredCommandMenuItems.map((item) => item.id),
|
|
);
|
|
|
|
const getDisplayLabel = (item: CommandMenuItemFieldsFragment) =>
|
|
interpolateCommandMenuItemTemplate({
|
|
label: item.label,
|
|
context: commandMenuContextApi,
|
|
}) ?? item.label;
|
|
|
|
const { pinned: allPinnedItems, other: allOtherItems } =
|
|
groupCommandMenuItems(filteredCommandMenuItems);
|
|
|
|
const normalizedSearch =
|
|
sidePanelSearch.length > 0
|
|
? normalizeSearchText(sidePanelSearch)
|
|
: undefined;
|
|
|
|
const matchesSearch = (item: CommandMenuItemFieldsFragment) =>
|
|
normalizedSearch === undefined ||
|
|
normalizeSearchText(getDisplayLabel(item)).includes(normalizedSearch);
|
|
|
|
const displayedPinnedItems = allPinnedItems.filter(matchesSearch);
|
|
const displayedOtherItems = allOtherItems.filter(matchesSearch);
|
|
|
|
const selectableItemIds = [
|
|
...displayedPinnedItems.map((item) => item.id),
|
|
...displayedOtherItems.map((item) => item.id),
|
|
];
|
|
|
|
const handleTogglePin = (itemId: string, currentlyPinned: boolean) => {
|
|
if (currentlyPinned) {
|
|
const nextOtherPosition =
|
|
allOtherItems.length === 0
|
|
? 0
|
|
: allOtherItems[allOtherItems.length - 1].position + 1;
|
|
|
|
updateCommandMenuItemInDraft(itemId, {
|
|
isPinned: false,
|
|
position: nextOtherPosition,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
const nextPinnedPosition =
|
|
allPinnedItems.length === 0
|
|
? 0
|
|
: allPinnedItems[allPinnedItems.length - 1].position + 1;
|
|
|
|
updateCommandMenuItemInDraft(itemId, {
|
|
isPinned: true,
|
|
position: nextPinnedPosition,
|
|
});
|
|
};
|
|
|
|
const makeOptionsDropdownWrapper =
|
|
(item: Pick<CommandMenuItemFieldsFragment, 'id' | 'shortLabel'>) =>
|
|
({ iconButton }: { iconButton: React.ReactElement }) => (
|
|
<CommandMenuItemOptionsDropdown
|
|
itemId={item.id}
|
|
shortLabel={item.shortLabel}
|
|
serverShortLabel={serverItemsById.get(item.id)?.shortLabel ?? null}
|
|
iconButton={iconButton}
|
|
/>
|
|
);
|
|
|
|
const handlePinnedDragEnd = (result: DropResult) => {
|
|
const { source, destination, draggableId } = result;
|
|
|
|
if (!isDefined(destination)) {
|
|
return;
|
|
}
|
|
|
|
if (source.index === destination.index) {
|
|
return;
|
|
}
|
|
|
|
const displayedPinnedItemIdsWithoutSource = displayedPinnedItems
|
|
.map((item) => item.id)
|
|
.filter((itemId) => itemId !== draggableId);
|
|
|
|
const allPinnedItemsWithoutSource = allPinnedItems.filter(
|
|
(item) => item.id !== draggableId,
|
|
);
|
|
|
|
const nextDisplayedPinnedItemId =
|
|
displayedPinnedItemIdsWithoutSource[destination.index];
|
|
const previousDisplayedPinnedItemId =
|
|
displayedPinnedItemIdsWithoutSource[destination.index - 1];
|
|
|
|
let destinationIndexInAllPinnedItemsWithoutSource: number;
|
|
|
|
if (isDefined(nextDisplayedPinnedItemId)) {
|
|
destinationIndexInAllPinnedItemsWithoutSource =
|
|
allPinnedItemsWithoutSource.findIndex(
|
|
(item) => item.id === nextDisplayedPinnedItemId,
|
|
);
|
|
} else if (isDefined(previousDisplayedPinnedItemId)) {
|
|
const previousIndex = allPinnedItemsWithoutSource.findIndex(
|
|
(item) => item.id === previousDisplayedPinnedItemId,
|
|
);
|
|
|
|
if (previousIndex === -1) {
|
|
return;
|
|
}
|
|
|
|
destinationIndexInAllPinnedItemsWithoutSource = previousIndex + 1;
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
if (destinationIndexInAllPinnedItemsWithoutSource === -1) {
|
|
return;
|
|
}
|
|
|
|
reorderCommandMenuItemInDraft(
|
|
draggableId,
|
|
destinationIndexInAllPinnedItemsWithoutSource,
|
|
'pinned',
|
|
filteredCommandMenuItemIds,
|
|
);
|
|
};
|
|
|
|
return (
|
|
<StyledContainer data-click-outside-id={COMMAND_MENU_CLICK_OUTSIDE_ID}>
|
|
{hasObjectContext && (
|
|
<StyledViewbar>
|
|
<CommandMenuItemEditRecordSelectionDropdown
|
|
isRecordPage={isRecordPage}
|
|
/>
|
|
</StyledViewbar>
|
|
)}
|
|
<StyledContent>
|
|
<SidePanelList selectableItemIds={selectableItemIds}>
|
|
<SidePanelGroup heading={t`Pinned`}>
|
|
<DraggableList
|
|
onDragEnd={handlePinnedDragEnd}
|
|
draggableItems={displayedPinnedItems.map((item, index) => {
|
|
const ItemIcon = isDefined(item.icon)
|
|
? getIcon(item.icon)
|
|
: undefined;
|
|
|
|
return (
|
|
<DraggableItem
|
|
key={item.id}
|
|
draggableId={item.id}
|
|
index={index}
|
|
itemComponent={
|
|
<SelectableListItem
|
|
itemId={item.id}
|
|
onEnter={() => handleTogglePin(item.id, true)}
|
|
>
|
|
<MenuItemDraggable
|
|
withIconContainer
|
|
LeftIcon={ItemIcon}
|
|
text={getDisplayLabel(item)}
|
|
gripMode="onHover"
|
|
isIconDisplayedOnHoverOnly={false}
|
|
iconButtons={[
|
|
{
|
|
Icon: IconPinnedOff,
|
|
onClick: (event) => {
|
|
event.stopPropagation();
|
|
handleTogglePin(item.id, true);
|
|
},
|
|
},
|
|
{
|
|
Icon: IconDotsVertical,
|
|
Wrapper: makeOptionsDropdownWrapper(item),
|
|
onClick: () => {},
|
|
},
|
|
]}
|
|
/>
|
|
</SelectableListItem>
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
/>
|
|
</SidePanelGroup>
|
|
|
|
<SidePanelGroup heading={t`Other`}>
|
|
{displayedOtherItems.map((item) => {
|
|
const ItemIcon = isDefined(item.icon)
|
|
? getIcon(item.icon)
|
|
: undefined;
|
|
|
|
return (
|
|
<SelectableListItem
|
|
key={item.id}
|
|
itemId={item.id}
|
|
onEnter={() => handleTogglePin(item.id, false)}
|
|
>
|
|
<MenuItem
|
|
withIconContainer
|
|
LeftIcon={ItemIcon}
|
|
text={getDisplayLabel(item)}
|
|
isIconDisplayedOnHoverOnly={false}
|
|
iconButtons={[
|
|
{
|
|
Icon: IconPin,
|
|
onClick: (event) => {
|
|
event.stopPropagation();
|
|
handleTogglePin(item.id, false);
|
|
},
|
|
},
|
|
]}
|
|
/>
|
|
</SelectableListItem>
|
|
);
|
|
})}
|
|
</SidePanelGroup>
|
|
</SidePanelList>
|
|
</StyledContent>
|
|
<SidePanelFooter
|
|
actions={[
|
|
<Button
|
|
key="reset"
|
|
Icon={IconRefresh}
|
|
title={t`Reset to default`}
|
|
variant="secondary"
|
|
accent="default"
|
|
size="small"
|
|
onClick={resetCommandMenuItemsDraft}
|
|
/>,
|
|
]}
|
|
/>
|
|
</StyledContainer>
|
|
);
|
|
};
|