diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx index e91cc6a7378a..3bcd47afa325 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx @@ -1,5 +1,6 @@ import { Key } from 'ts-key-enum'; import { + AppTooltip, IconFileExport, IconFileImport, IconLayout, @@ -55,6 +56,10 @@ export const ObjectOptionsDropdownMenuContent = () => { recordGroupFieldMetadataComponentState, ); + const isGroupByEnabled = + (isDefined(currentView?.viewGroups) && currentView.viewGroups.length > 0) || + currentView?.key !== 'INDEX'; + useScopedHotkeys( [Key.Escape], () => { @@ -115,20 +120,34 @@ export const ObjectOptionsDropdownMenuContent = () => { contextualText={`${visibleBoardFields.length} shown`} hasSubMenu /> - {viewType === ViewType.Kanban || - (currentView?.key !== 'INDEX' && ( - - isDefined(recordGroupFieldMetadata) - ? onContentChange('recordGroups') - : onContentChange('recordGroupFields') - } - LeftIcon={IconLayoutList} - text="Group by" - contextualText={recordGroupFieldMetadata?.label} - hasSubMenu - /> - ))} + +
+ + isDefined(recordGroupFieldMetadata) + ? onContentChange('recordGroups') + : onContentChange('recordGroupFields') + } + LeftIcon={IconLayoutList} + text="Group by" + contextualText={ + !isGroupByEnabled + ? 'Not available on Default View' + : recordGroupFieldMetadata?.label + } + hasSubMenu + disabled={!isGroupByEnabled} + /> +
+ {!isGroupByEnabled && ( + + )} diff --git a/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts b/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts index 934a9dd870e6..fadcd08715d7 100644 --- a/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts +++ b/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts @@ -45,7 +45,9 @@ import { SURVEY_RESULTS_METADATA_SEEDS } from 'src/engine/seeder/metadata-seeds/ import { SeederService } from 'src/engine/seeder/seeder.service'; import { shouldSeedWorkspaceFavorite } from 'src/engine/utils/should-seed-workspace-favorite'; import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; +import { createWorkspaceViews } from 'src/engine/workspace-manager/standard-objects-prefill-data/create-workspace-views'; import { seedViewWithDemoData } from 'src/engine/workspace-manager/standard-objects-prefill-data/seed-view-with-demo-data'; +import { opportunitiesTableByStageView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/opportunity-table-by-stage.view'; import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; import { WorkspaceSyncMetadataService } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.service'; @@ -230,6 +232,14 @@ export class DataSeedWorkspaceCommand extends CommandRunner { isWorkflowEnabled, ); + const devViewDefinitionsWithId = await createWorkspaceViews( + entityManager, + dataSourceMetadata.schema, + [opportunitiesTableByStageView(objectMetadataStandardIdToIdMap)], + ); + + viewDefinitionsWithId.push(...devViewDefinitionsWithId); + await seedWorkspaceFavorites( viewDefinitionsWithId .filter( diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/create-workspace-views.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/create-workspace-views.ts new file mode 100644 index 000000000000..51b3060059cd --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/create-workspace-views.ts @@ -0,0 +1,131 @@ +import { EntityManager } from 'typeorm'; +import { v4 } from 'uuid'; + +import { ViewDefinition } from 'src/engine/workspace-manager/standard-objects-prefill-data/types/view-definition.interface'; + +export const createWorkspaceViews = async ( + entityManager: EntityManager, + schemaName: string, + viewDefinitions: ViewDefinition[], +) => { + const viewDefinitionsWithId = viewDefinitions.map((viewDefinition) => ({ + ...viewDefinition, + id: v4(), + })); + + await entityManager + .createQueryBuilder() + .insert() + .into(`${schemaName}.view`, [ + 'id', + 'name', + 'objectMetadataId', + 'type', + 'key', + 'position', + 'icon', + 'kanbanFieldMetadataId', + ]) + .values( + viewDefinitionsWithId.map( + ({ + id, + name, + objectMetadataId, + type, + key, + position, + icon, + kanbanFieldMetadataId, + }) => ({ + id, + name, + objectMetadataId, + type, + key, + position, + icon, + kanbanFieldMetadataId, + }), + ), + ) + .returning('*') + .execute(); + + for (const viewDefinition of viewDefinitionsWithId) { + if (viewDefinition.fields && viewDefinition.fields.length > 0) { + await entityManager + .createQueryBuilder() + .insert() + .into(`${schemaName}.viewField`, [ + 'fieldMetadataId', + 'position', + 'isVisible', + 'size', + 'viewId', + ]) + .values( + viewDefinition.fields.map((field) => ({ + fieldMetadataId: field.fieldMetadataId, + position: field.position, + isVisible: field.isVisible, + size: field.size, + viewId: viewDefinition.id, + })), + ) + .execute(); + } + + if (viewDefinition.filters && viewDefinition.filters.length > 0) { + await entityManager + .createQueryBuilder() + .insert() + .into(`${schemaName}.viewFilter`, [ + 'fieldMetadataId', + 'displayValue', + 'operand', + 'value', + 'viewId', + ]) + .values( + viewDefinition.filters.map((filter: any) => ({ + fieldMetadataId: filter.fieldMetadataId, + displayValue: filter.displayValue, + operand: filter.operand, + value: filter.value, + viewId: viewDefinition.id, + })), + ) + .execute(); + } + + if ( + 'groups' in viewDefinition && + viewDefinition.groups && + viewDefinition.groups.length > 0 + ) { + await entityManager + .createQueryBuilder() + .insert() + .into(`${schemaName}.viewGroup`, [ + 'fieldMetadataId', + 'isVisible', + 'fieldValue', + 'position', + 'viewId', + ]) + .values( + viewDefinition.groups.map((group: any) => ({ + fieldMetadataId: group.fieldMetadataId, + isVisible: group.isVisible, + fieldValue: group.fieldValue, + position: group.position, + viewId: viewDefinition.id, + })), + ) + .execute(); + } + } + + return viewDefinitionsWithId; +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/seed-view-with-demo-data.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/seed-view-with-demo-data.ts index ed8bdbab1c09..2136e0a8e001 100644 --- a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/seed-view-with-demo-data.ts +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/seed-view-with-demo-data.ts @@ -1,8 +1,8 @@ import { EntityManager } from 'typeorm'; -import { v4 } from 'uuid'; import { ObjectMetadataStandardIdToIdMap } from 'src/engine/metadata-modules/object-metadata/interfaces/object-metadata-standard-id-to-id-map'; +import { createWorkspaceViews } from 'src/engine/workspace-manager/standard-objects-prefill-data/create-workspace-views'; import { seedCompaniesAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/companies-all.view'; import { notesAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/notes-all.view'; import { opportunitiesAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/opportunities-all.view'; @@ -37,124 +37,5 @@ export const seedViewWithDemoData = async ( : []), ]; - const viewDefinitionsWithId = viewDefinitions.map((viewDefinition) => ({ - ...viewDefinition, - id: v4(), - })); - - await entityManager - .createQueryBuilder() - .insert() - .into(`${schemaName}.view`, [ - 'id', - 'name', - 'objectMetadataId', - 'type', - 'key', - 'position', - 'icon', - 'kanbanFieldMetadataId', - ]) - .values( - viewDefinitionsWithId.map( - ({ - id, - name, - objectMetadataId, - type, - key, - position, - icon, - kanbanFieldMetadataId, - }) => ({ - id, - name, - objectMetadataId, - type, - key, - position, - icon, - kanbanFieldMetadataId, - }), - ), - ) - .returning('*') - .execute(); - - for (const viewDefinition of viewDefinitionsWithId) { - if (viewDefinition.fields && viewDefinition.fields.length > 0) { - await entityManager - .createQueryBuilder() - .insert() - .into(`${schemaName}.viewField`, [ - 'fieldMetadataId', - 'position', - 'isVisible', - 'size', - 'viewId', - ]) - .values( - viewDefinition.fields.map((field) => ({ - fieldMetadataId: field.fieldMetadataId, - position: field.position, - isVisible: field.isVisible, - size: field.size, - viewId: viewDefinition.id, - })), - ) - .execute(); - } - - if (viewDefinition.filters && viewDefinition.filters.length > 0) { - await entityManager - .createQueryBuilder() - .insert() - .into(`${schemaName}.viewFilter`, [ - 'fieldMetadataId', - 'displayValue', - 'operand', - 'value', - 'viewId', - ]) - .values( - viewDefinition.filters.map((filter: any) => ({ - fieldMetadataId: filter.fieldMetadataId, - displayValue: filter.displayValue, - operand: filter.operand, - value: filter.value, - viewId: viewDefinition.id, - })), - ) - .execute(); - } - - if ( - 'groups' in viewDefinition && - viewDefinition.groups && - viewDefinition.groups.length > 0 - ) { - await entityManager - .createQueryBuilder() - .insert() - .into(`${schemaName}.viewGroup`, [ - 'fieldMetadataId', - 'isVisible', - 'fieldValue', - 'position', - 'viewId', - ]) - .values( - viewDefinition.groups.map((group: any) => ({ - fieldMetadataId: group.fieldMetadataId, - isVisible: group.isVisible, - fieldValue: group.fieldValue, - position: group.position, - viewId: viewDefinition.id, - })), - ) - .execute(); - } - } - - return viewDefinitionsWithId; + return createWorkspaceViews(entityManager, schemaName, viewDefinitions); }; diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/types/view-definition.interface.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/types/view-definition.interface.ts new file mode 100644 index 000000000000..27422a539840 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/types/view-definition.interface.ts @@ -0,0 +1,28 @@ +export interface ViewDefinition { + id?: string; + name: string; + objectMetadataId: string; + type: string; + key: string | null; + position: number; + icon?: string; + kanbanFieldMetadataId?: string; + fields?: { + fieldMetadataId: string; + position: number; + isVisible: boolean; + size: number; + }[]; + filters?: { + fieldMetadataId: string; + displayValue: string; + operand: string; + value: string; + }[]; + groups?: { + fieldMetadataId: string; + isVisible: boolean; + fieldValue: string; + position: number; + }[]; +} diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/opportunity-by-stage.view.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/opportunity-by-stage.view.ts index 7dae97c1b5fd..b3ccbf859cf8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/opportunity-by-stage.view.ts +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/opportunity-by-stage.view.ts @@ -12,7 +12,7 @@ export const opportunitiesByStageView = ( objectMetadataStandardIdToIdMap[STANDARD_OBJECT_IDS.opportunity].id, type: 'kanban', key: null, - position: 1, + position: 2, icon: 'IconLayoutKanban', kanbanFieldMetadataId: objectMetadataStandardIdToIdMap[STANDARD_OBJECT_IDS.opportunity].fields[ diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/opportunity-table-by-stage.view.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/opportunity-table-by-stage.view.ts new file mode 100644 index 000000000000..32b4021bb18d --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/opportunity-table-by-stage.view.ts @@ -0,0 +1,115 @@ +import { ObjectMetadataStandardIdToIdMap } from 'src/engine/metadata-modules/object-metadata/interfaces/object-metadata-standard-id-to-id-map'; + +import { OPPORTUNITY_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; +import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; + +export const opportunitiesTableByStageView = ( + objectMetadataStandardIdToIdMap: ObjectMetadataStandardIdToIdMap, +) => { + return { + name: 'By Stage', + objectMetadataId: + objectMetadataStandardIdToIdMap[STANDARD_OBJECT_IDS.opportunity].id, + type: 'table', + key: null, + position: 1, + icon: 'IconList', + kanbanFieldMetadataId: + objectMetadataStandardIdToIdMap[STANDARD_OBJECT_IDS.opportunity].fields[ + OPPORTUNITY_STANDARD_FIELD_IDS.stage + ], + filters: [], + fields: [ + { + fieldMetadataId: + objectMetadataStandardIdToIdMap[STANDARD_OBJECT_IDS.opportunity] + .fields[OPPORTUNITY_STANDARD_FIELD_IDS.name], + position: 0, + isVisible: true, + size: 150, + }, + { + fieldMetadataId: + objectMetadataStandardIdToIdMap[STANDARD_OBJECT_IDS.opportunity] + .fields[OPPORTUNITY_STANDARD_FIELD_IDS.amount], + position: 1, + isVisible: true, + size: 150, + }, + { + fieldMetadataId: + objectMetadataStandardIdToIdMap[STANDARD_OBJECT_IDS.opportunity] + .fields[OPPORTUNITY_STANDARD_FIELD_IDS.createdBy], + position: 2, + isVisible: true, + size: 150, + }, + { + fieldMetadataId: + objectMetadataStandardIdToIdMap[STANDARD_OBJECT_IDS.opportunity] + .fields[OPPORTUNITY_STANDARD_FIELD_IDS.closeDate], + position: 3, + isVisible: true, + size: 150, + }, + { + fieldMetadataId: + objectMetadataStandardIdToIdMap[STANDARD_OBJECT_IDS.opportunity] + .fields[OPPORTUNITY_STANDARD_FIELD_IDS.company], + position: 4, + isVisible: true, + size: 150, + }, + { + fieldMetadataId: + objectMetadataStandardIdToIdMap[STANDARD_OBJECT_IDS.opportunity] + .fields[OPPORTUNITY_STANDARD_FIELD_IDS.pointOfContact], + position: 5, + isVisible: true, + size: 150, + }, + ], + groups: [ + { + fieldMetadataId: + objectMetadataStandardIdToIdMap[STANDARD_OBJECT_IDS.opportunity] + .fields[OPPORTUNITY_STANDARD_FIELD_IDS.stage], + isVisible: true, + fieldValue: 'NEW', + position: 0, + }, + { + fieldMetadataId: + objectMetadataStandardIdToIdMap[STANDARD_OBJECT_IDS.opportunity] + .fields[OPPORTUNITY_STANDARD_FIELD_IDS.stage], + isVisible: true, + fieldValue: 'SCREENING', + position: 1, + }, + { + fieldMetadataId: + objectMetadataStandardIdToIdMap[STANDARD_OBJECT_IDS.opportunity] + .fields[OPPORTUNITY_STANDARD_FIELD_IDS.stage], + isVisible: true, + fieldValue: 'MEETING', + position: 2, + }, + { + fieldMetadataId: + objectMetadataStandardIdToIdMap[STANDARD_OBJECT_IDS.opportunity] + .fields[OPPORTUNITY_STANDARD_FIELD_IDS.stage], + isVisible: true, + fieldValue: 'PROPOSAL', + position: 3, + }, + { + fieldMetadataId: + objectMetadataStandardIdToIdMap[STANDARD_OBJECT_IDS.opportunity] + .fields[OPPORTUNITY_STANDARD_FIELD_IDS.stage], + isVisible: true, + fieldValue: 'CUSTOMER', + position: 4, + }, + ], + }; +}; diff --git a/packages/twenty-ui/src/display/tooltip/AppTooltip.tsx b/packages/twenty-ui/src/display/tooltip/AppTooltip.tsx index 96db35121f65..e6377957f4b1 100644 --- a/packages/twenty-ui/src/display/tooltip/AppTooltip.tsx +++ b/packages/twenty-ui/src/display/tooltip/AppTooltip.tsx @@ -16,7 +16,7 @@ export enum TooltipDelay { mediumDelay = '500ms', } -const StyledAppTooltip = styled(Tooltip)` +const StyledAppTooltip = styled(Tooltip)<{ width?: string }>` backdrop-filter: ${({ theme }) => theme.blur.strong}; background-color: ${({ theme }) => RGBA(theme.color.gray80, 0.8)}; border-radius: ${({ theme }) => theme.border.radius.sm}; @@ -27,7 +27,7 @@ const StyledAppTooltip = styled(Tooltip)` font-size: ${({ theme }) => theme.font.size.sm}; font-weight: ${({ theme }) => theme.font.weight.regular}; - max-width: 40%; + max-width: ${({ width }) => width || '40%'}; overflow: visible; padding: ${({ theme }) => theme.spacing(2)}; @@ -49,6 +49,7 @@ export type AppTooltipProps = { delay?: TooltipDelay; positionStrategy?: PositionStrategy; clickable?: boolean; + width?: string; }; export const AppTooltip = ({ @@ -63,6 +64,7 @@ export const AppTooltip = ({ positionStrategy, children, clickable, + width, }: AppTooltipProps) => { const delayInMs = delay === TooltipDelay.noDelay @@ -86,6 +88,7 @@ export const AppTooltip = ({ positionStrategy, children, clickable, + width, }} /> ); diff --git a/packages/twenty-ui/src/display/tooltip/__stories__/Tooltip.stories.tsx b/packages/twenty-ui/src/display/tooltip/__stories__/Tooltip.stories.tsx index 1341cdbb6dfc..d3419c134656 100644 --- a/packages/twenty-ui/src/display/tooltip/__stories__/Tooltip.stories.tsx +++ b/packages/twenty-ui/src/display/tooltip/__stories__/Tooltip.stories.tsx @@ -102,6 +102,48 @@ export const Hoverable: Story = { ), }; +export const WithWidth: Story = { + args: { + place: TooltipPosition.Top, + delay: TooltipDelay.mediumDelay, + content: 'Tooltip with custom width', + hidden: false, + anchorSelect: '#width-text', + width: '200px', + }, + decorators: [ComponentDecorator], + render: ({ + anchorSelect, + className, + content, + delay, + noArrow, + offset, + place, + positionStrategy, + width, + }) => ( + <> +

+ Hover me to see custom width! +

+ + + ), +}; + export const Catalog: CatalogStory = { args: { hidden: false, content: 'Tooltip Test' }, play: async ({ canvasElement }) => { diff --git a/packages/twenty-ui/src/navigation/menu-item/components/MenuItem.tsx b/packages/twenty-ui/src/navigation/menu-item/components/MenuItem.tsx index b123886983d3..b7ceaa2706d5 100644 --- a/packages/twenty-ui/src/navigation/menu-item/components/MenuItem.tsx +++ b/packages/twenty-ui/src/navigation/menu-item/components/MenuItem.tsx @@ -30,6 +30,7 @@ export type MenuItemProps = { onMouseEnter?: (event: MouseEvent) => void; onMouseLeave?: (event: MouseEvent) => void; testId?: string; + disabled?: boolean; text: ReactNode; contextualText?: ReactNode; hasSubMenu?: boolean; @@ -49,6 +50,7 @@ export const MenuItem = ({ text, contextualText, hasSubMenu = false, + disabled = false, }: MenuItemProps) => { const theme = useTheme(); const showIconButtons = Array.isArray(iconButtons) && iconButtons.length > 0; @@ -64,7 +66,8 @@ export const MenuItem = ({ return ( = { } }, }, + { + name: 'disabled', + values: [true, false], + props: (disabled: boolean) => ({ disabled }), + }, ], options: { elementContainer: { diff --git a/packages/twenty-ui/src/navigation/menu-item/internals/components/StyledMenuItemBase.tsx b/packages/twenty-ui/src/navigation/menu-item/internals/components/StyledMenuItemBase.tsx index 143fb11329b3..a07be743a9e9 100644 --- a/packages/twenty-ui/src/navigation/menu-item/internals/components/StyledMenuItemBase.tsx +++ b/packages/twenty-ui/src/navigation/menu-item/internals/components/StyledMenuItemBase.tsx @@ -1,6 +1,7 @@ import { css } from '@emotion/react'; import styled from '@emotion/styled'; +import { isUndefined } from '@sniptt/guards'; import { HOVER_BACKGROUND } from '@ui/theme'; import { MenuItemAccent } from '../../types/MenuItemAccent'; @@ -9,6 +10,7 @@ export type MenuItemBaseProps = { isKeySelected?: boolean; isHoverBackgroundDisabled?: boolean; hovered?: boolean; + disabled?: boolean; }; export const StyledMenuItemBase = styled.div` @@ -35,10 +37,16 @@ export const StyledMenuItemBase = styled.div` ${({ theme, isKeySelected }) => isKeySelected ? `background: ${theme.background.transparent.light};` : ''} - ${({ isHoverBackgroundDisabled }) => - isHoverBackgroundDisabled ?? HOVER_BACKGROUND}; + ${({ isHoverBackgroundDisabled, disabled }) => + (disabled || isHoverBackgroundDisabled) ?? HOVER_BACKGROUND}; + + ${({ theme, accent, disabled }) => { + if (isUndefined(disabled) && disabled !== false) { + return css` + opacity: 0.4; + `; + } - ${({ theme, accent }) => { switch (accent) { case 'danger': { return css` @@ -112,6 +120,7 @@ export const StyledDraggableItem = styled.div` `; export const StyledHoverableMenuItemBase = styled(StyledMenuItemBase)<{ + disabled?: boolean; isIconDisplayedOnHoverOnly?: boolean; cursor?: 'drag' | 'default' | 'not-allowed'; }>` @@ -136,7 +145,11 @@ export const StyledHoverableMenuItemBase = styled(StyledMenuItemBase)<{ transition: opacity ${({ theme }) => theme.animation.duration.instant}s ease; } - cursor: ${({ cursor }) => { + cursor: ${({ cursor, disabled }) => { + if (!isUndefined(disabled) && disabled !== false) { + return 'not-allowed'; + } + switch (cursor) { case 'drag': return 'grab';