diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionFieldSeeAll.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionFieldSeeAll.tsx new file mode 100644 index 0000000000..d99973742c --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionFieldSeeAll.tsx @@ -0,0 +1,132 @@ +import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition'; +import { type FieldRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation'; +import { useResolveFieldMetadataIdFromNameOrId } from '@/page-layout/hooks/useResolveFieldMetadataIdFromNameOrId'; +import { isFieldWidget } from '@/page-layout/widgets/field/utils/isFieldWidget'; +import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget'; +import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord'; +import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; +import { AppPath, ViewFilterOperand } from 'twenty-shared/types'; +import { getAppPath, isDefined } from 'twenty-shared/utils'; +import { IconArrowUpRight } from 'twenty-ui/display'; +import { LightIconButton } from 'twenty-ui/input'; +import { RelationType } from '~/generated-metadata/graphql'; +import { coreIndexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreIndexViewIdFromObjectMetadataItemFamilySelector'; +import { Link } from 'react-router-dom'; + +const StyledLink = styled(Link)` + display: flex; + text-decoration: none; +`; + +const StyledSeeAllButton = styled(LightIconButton)<{ isMobile: boolean }>` + ${({ theme, isMobile }) => css` + opacity: ${isMobile ? 1 : 0}; + pointer-events: none; + transition: opacity ${theme.animation.duration.instant}s ease; + `} + + .widget:hover & { + opacity: 1; + pointer-events: auto; + } +`; + +export const WidgetActionFieldSeeAll = () => { + const widget = useCurrentWidget(); + const targetRecord = useTargetRecord(); + const isMobile = useIsMobile(); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: targetRecord.targetObjectNameSingular, + }); + + const fieldMetadataId = isFieldWidget(widget) + ? widget.configuration.fieldMetadataId + : undefined; + + const resolvedFieldMetadataId = useResolveFieldMetadataIdFromNameOrId( + fieldMetadataId ?? '', + ); + + const { fieldMetadataItem } = useFieldMetadataItemById( + resolvedFieldMetadataId ?? '', + ); + + const fieldDefinition = isDefined(fieldMetadataItem) + ? formatFieldMetadataItemAsColumnDefinition({ + field: fieldMetadataItem, + position: 0, + objectMetadataItem, + showLabel: true, + labelWidth: 90, + }) + : null; + + const relationMetadata = + isDefined(fieldDefinition) && isFieldRelation(fieldDefinition) + ? (fieldDefinition.metadata as FieldRelationMetadata) + : null; + + const { objectMetadataItems } = useObjectMetadataItems(); + + const relationObjectMetadataItem = objectMetadataItems.find( + (item) => + item.nameSingular === + relationMetadata?.relationObjectMetadataNameSingular, + ); + + const relationFieldMetadataItem = relationObjectMetadataItem?.fields.find( + ({ id }) => id === relationMetadata?.relationFieldMetadataId, + ); + + const indexViewId = useRecoilValue( + coreIndexViewIdFromObjectMetadataItemFamilySelector({ + objectMetadataItemId: relationObjectMetadataItem?.id ?? '', + }), + ); + + if ( + !isDefined(relationMetadata) || + relationMetadata.relationType !== RelationType.ONE_TO_MANY || + !isDefined(relationFieldMetadataItem) || + !isDefined(relationObjectMetadataItem) + ) { + return null; + } + + const filterQueryParams = { + filter: { + [relationFieldMetadataItem.name]: { + [ViewFilterOperand.IS]: { + selectedRecordIds: [targetRecord.id], + }, + }, + }, + viewId: indexViewId, + }; + + const filterLinkHref = getAppPath( + AppPath.RecordIndexPage, + { + objectNamePlural: relationObjectMetadataItem.namePlural, + }, + filterQueryParams, + ); + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionRenderer.tsx index 2d4e16f82a..929ed12437 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionRenderer.tsx @@ -3,6 +3,7 @@ import { CustomError } from 'twenty-shared/utils'; import { WidgetType } from '~/generated/graphql'; import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget'; import { WidgetActionFieldEdit } from './WidgetActionFieldEdit'; +import { WidgetActionFieldSeeAll } from './WidgetActionFieldSeeAll'; type WidgetActionRendererProps = { action: WidgetAction; @@ -15,6 +16,10 @@ export const WidgetActionRenderer = ({ action }: WidgetActionRendererProps) => { return ; } + if (action.id === 'see-all' && widget.type === WidgetType.FIELD) { + return ; + } + throw new CustomError( `Unsupported action renderer for action id: ${action.id}`, 'UNSUPPORTED_WIDGET_ACTION_RENDERER', diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/__stories__/WidgetRenderer.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/__stories__/WidgetRenderer.stories.tsx index 76b764ca65..3817568b4a 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/components/__stories__/WidgetRenderer.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/components/__stories__/WidgetRenderer.stories.tsx @@ -7,6 +7,7 @@ import { type MockedResponse } from '@apollo/client/testing'; import { type Meta, type StoryObj } from '@storybook/react-vite'; import { MemoryRouter } from 'react-router-dom'; import { type MutableSnapshot } from 'recoil'; +import { expect, within } from 'storybook/test'; import { CatalogDecorator, type CatalogStory } from 'twenty-ui/testing'; import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; @@ -1197,6 +1198,135 @@ export const WithOneToManyRelationFieldWidget: Story = { }, }; +export const OneToManyRelationFieldWidgetWithSeeAllButton: Story = { + parameters: { + docs: { + description: { + story: + 'A ONE_TO_MANY relation field widget with visible "See all" button and a well-formed link.', + }, + }, + }, + render: () => { + const widget: PageLayoutWidget = { + __typename: 'PageLayoutWidget', + id: WIDGET_ID_ONE_TO_MANY_RELATION, + pageLayoutTabId: TAB_ID_OVERVIEW, + type: WidgetType.FIELD, + title: 'People', + objectMetadataId: companyObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 0, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { + __typename: 'FieldConfiguration', + configurationType: WidgetConfigurationType.FIELD, + fieldMetadataId: companyPeopleField.id, + layout: 'FIELD', + }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + }; + + const initializeState = (snapshot: MutableSnapshot) => { + snapshot.set(objectMetadataItemsState, generatedMockObjectMetadataItems); + snapshot.set(shouldAppBeLoadingState, false); + const pageLayoutData = createPageLayoutWithWidget( + widget, + PageLayoutType.RECORD_PAGE, + ); + snapshot.set( + pageLayoutPersistedComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + pageLayoutData, + ); + snapshot.set( + pageLayoutDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + pageLayoutData, + ); + snapshot.set( + isPageLayoutInEditModeComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + false, + ); + snapshot.set(recordStoreFamilyState(TEST_RECORD_ID), mockCompanyRecord); + snapshot.set( + recordStoreFamilyState(TEST_PERSON_RECORD_ID), + mockPersonRecord, + ); + // Set hover state to make the "See all" button visible + snapshot.set( + widgetCardHoveredComponentFamilyState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + familyKey: WIDGET_ID_ONE_TO_MANY_RELATION, + }), + true, + ); + }; + + return ( +
+ + + + + + + + + + + + + +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Find the "See all" link button by its test id + const seeAllLink = await canvas.findByTestId('widget-see-all-link'); + + // Verify the button is visible + expect(seeAllLink).toBeVisible(); + + // Verify it has a well-formed link (should contain the filter query params) + expect(seeAllLink).toHaveAttribute('href'); + const href = seeAllLink.getAttribute('href'); + expect(href).toContain('/objects/people'); + expect(href).toContain('filter'); + }, +}; + export const OnMobile: Story = { parameters: { viewport: { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/hooks/useWidgetActions.ts b/packages/twenty-front/src/modules/page-layout/widgets/hooks/useWidgetActions.ts index b9926b316a..c2b9bea664 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/hooks/useWidgetActions.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/hooks/useWidgetActions.ts @@ -1,8 +1,10 @@ import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly'; import { isRecordFieldReadOnly } from '@/object-record/read-only/utils/isRecordFieldReadOnly'; +import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation'; import { useResolveFieldMetadataIdFromNameOrId } from '@/page-layout/hooks/useResolveFieldMetadataIdFromNameOrId'; import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; import { isFieldWidget } from '@/page-layout/widgets/field/utils/isFieldWidget'; @@ -10,6 +12,7 @@ import { type WidgetAction } from '@/page-layout/widgets/types/WidgetAction'; import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId'; import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord'; import { isDefined } from 'twenty-shared/utils'; +import { RelationType } from '~/generated-metadata/graphql'; type UseWidgetActionsParams = { widget: PageLayoutWidget; @@ -53,6 +56,25 @@ export const useWidgetActions = ({ return actions; } + const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({ + field: fieldMetadataItem, + position: 0, + objectMetadataItem, + showLabel: true, + labelWidth: 90, + }); + + const isOneToManyRelation = + isFieldRelation(fieldDefinition) && + fieldDefinition.metadata.relationType === RelationType.ONE_TO_MANY; + + if (isOneToManyRelation) { + actions.push({ + id: 'see-all', + position: 0, + }); + } + const isFieldReadOnly = isRecordFieldReadOnly({ isRecordReadOnly, objectPermissions: getObjectPermissionsFromMapByObjectMetadataId({ @@ -68,7 +90,7 @@ export const useWidgetActions = ({ if (!isFieldReadOnly) { actions.push({ id: 'edit', - position: 0, + position: 1, }); } diff --git a/packages/twenty-front/src/modules/page-layout/widgets/types/WidgetAction.ts b/packages/twenty-front/src/modules/page-layout/widgets/types/WidgetAction.ts index 09c5a54fa1..1a0de8335d 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/types/WidgetAction.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/types/WidgetAction.ts @@ -1,4 +1,4 @@ -export type WidgetActionId = 'edit'; +export type WidgetActionId = 'edit' | 'see-all'; export type WidgetAction = { id: WidgetActionId;