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;