diff --git a/packages/twenty-front/public/images/placeholders/background/not_shared_bg.png b/packages/twenty-front/public/images/placeholders/background/not_shared_bg.png new file mode 100644 index 00000000000..6c3d400c7e0 Binary files /dev/null and b/packages/twenty-front/public/images/placeholders/background/not_shared_bg.png differ diff --git a/packages/twenty-front/public/images/placeholders/dark-background/not_shared_bg.png b/packages/twenty-front/public/images/placeholders/dark-background/not_shared_bg.png new file mode 100644 index 00000000000..de52d1a895f Binary files /dev/null and b/packages/twenty-front/public/images/placeholders/dark-background/not_shared_bg.png differ diff --git a/packages/twenty-front/public/images/placeholders/dark-moving-image/not_shared.png b/packages/twenty-front/public/images/placeholders/dark-moving-image/not_shared.png new file mode 100644 index 00000000000..ac90c966e1f Binary files /dev/null and b/packages/twenty-front/public/images/placeholders/dark-moving-image/not_shared.png differ diff --git a/packages/twenty-front/public/images/placeholders/moving-image/not_shared.png b/packages/twenty-front/public/images/placeholders/moving-image/not_shared.png new file mode 100644 index 00000000000..26607bb78a2 Binary files /dev/null and b/packages/twenty-front/public/images/placeholders/moving-image/not_shared.png differ diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx index 23700f0f79f..d69c3733741 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx @@ -12,7 +12,9 @@ import { useRecordIndexContextOrThrow } from '@/object-record/record-index/conte import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider'; import { RecordIndexCalendarContainer } from '@/object-record/record-index/components/RecordIndexCalendarContainer'; +import { RecordIndexEmptyStateNotShared } from '@/object-record/record-index/components/RecordIndexEmptyStateNotShared'; import { RecordIndexFiltersToContextStoreEffect } from '@/object-record/record-index/components/RecordIndexFiltersToContextStoreEffect'; +import { useHasCurrentViewNonReadableFields } from '@/object-record/record-index/hooks/useHasCurrentViewNonReadableFields'; import { ViewBar } from '@/views/components/ViewBar'; import { ViewType } from '@/views/types/ViewType'; import { themeCssVariables } from 'twenty-ui/theme-constants'; @@ -43,12 +45,16 @@ export const RecordIndexContainer = () => { objectNameSingular, } = useRecordIndexContextOrThrow(); + const { hasCurrentViewNonReadableFields, nonReadableViewFieldInfo } = + useHasCurrentViewNonReadableFields(objectMetadataItem); + return ( <> { viewBarId={recordIndexId} /> - - {recordIndexViewType === ViewType.TABLE && ( + {hasCurrentViewNonReadableFields ? ( + + ) : ( <> - + + {recordIndexViewType === ViewType.TABLE && ( + + )} + {recordIndexViewType === ViewType.KANBAN && ( + + + + )} + {recordIndexViewType === ViewType.CALENDAR && ( + + + + )} )} - {recordIndexViewType === ViewType.KANBAN && ( - - - - )} - {recordIndexViewType === ViewType.CALENDAR && ( - - - - )} ); diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainerGater.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainerGater.tsx index 05876aab0b2..9c6956ab67c 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainerGater.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainerGater.tsx @@ -1,7 +1,7 @@ import { RecordIndexContextProvider } from '@/object-record/record-index/contexts/RecordIndexContext'; -import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext'; import { getCommandMenuIdFromRecordIndexId } from '@/command-menu-item/utils/getCommandMenuIdFromRecordIndexId'; +import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext'; import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject'; import { MainContainerLayoutWithSidePanel } from '@/object-record/components/MainContainerLayoutWithSidePanel'; import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper'; @@ -9,6 +9,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions import { lastShowPageRecordIdState } from '@/object-record/record-field/ui/states/lastShowPageRecordId'; import { RecordIndexContainer } from '@/object-record/record-index/components/RecordIndexContainer'; import { RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect } from '@/object-record/record-index/components/RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect'; +import { RecordIndexEmptyStateNotShared } from '@/object-record/record-index/components/RecordIndexEmptyStateNotShared'; import { RecordIndexLoadBaseOnContextStoreEffect } from '@/object-record/record-index/components/RecordIndexLoadBaseOnContextStoreEffect'; import { RecordIndexPageHeader } from '@/object-record/record-index/components/RecordIndexPageHeader'; import { useHandleIndexIdentifierClick } from '@/object-record/record-index/hooks/useHandleIndexIdentifierClick'; @@ -18,9 +19,8 @@ import { RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS } from '@/ui/utilities/drag-sel import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle'; import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; import { styled } from '@linaria/react'; -import { useCallback } from 'react'; -import { NotFound } from '~/pages/not-found/NotFound'; import { useStore } from 'jotai'; +import { useCallback } from 'react'; const StyledIndexContainer = styled.div` display: flex; @@ -60,10 +60,6 @@ export const RecordIndexContainerGater = () => { recordIndexId, ); - if (!hasObjectReadPermissions) { - return ; - } - return ( <> { - - + {hasObjectReadPermissions ? ( + <> + + + + ) : ( + + )} diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexEmptyStateNotShared.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexEmptyStateNotShared.tsx new file mode 100644 index 00000000000..3f03e07c2f3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexEmptyStateNotShared.tsx @@ -0,0 +1,45 @@ +import { type NonReadableViewFieldInfo } from '@/object-record/record-index/hooks/useHasCurrentViewNonReadableFields'; +import { getNonReadableViewFieldSubTitle } from '@/object-record/record-index/utils/getNonReadableViewFieldSubTitle'; +import { styled } from '@linaria/react'; +import { t } from '@lingui/core/macro'; +import { isDefined } from 'twenty-shared/utils'; +import { + AnimatedPlaceholder, + AnimatedPlaceholderEmptyContainer, + AnimatedPlaceholderEmptySubTitle, + AnimatedPlaceholderEmptyTextContainer, + AnimatedPlaceholderEmptyTitle, +} from 'twenty-ui/layout'; + +const StyledEmptyPlaceholderOuterContainer = styled.div` + height: 100%; + width: 100%; +`; + +type RecordIndexEmptyStateNotSharedProps = { + nonReadableViewFieldInfo?: NonReadableViewFieldInfo; +}; + +export const RecordIndexEmptyStateNotShared = ({ + nonReadableViewFieldInfo, +}: RecordIndexEmptyStateNotSharedProps) => { + return ( + + + + + + {isDefined(nonReadableViewFieldInfo) + ? t`View not shared` + : t`Object not shared`} + + + {isDefined(nonReadableViewFieldInfo) + ? getNonReadableViewFieldSubTitle(nonReadableViewFieldInfo) + : t`You don't have access to this object.`} + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHasCurrentViewNonReadableFields.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHasCurrentViewNonReadableFields.ts new file mode 100644 index 00000000000..11dd3ae43db --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHasCurrentViewNonReadableFields.ts @@ -0,0 +1,86 @@ +import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { getNonReadableFieldInfoForViewField } from '@/object-record/record-index/utils/getNonReadableInfoForViewField'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue'; +import { viewFromViewIdFamilySelector } from '@/views/states/selectors/viewFromViewIdFamilySelector'; +import { useMemo } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +export type NonReadableViewFieldUsage = 'filter' | 'sort'; + +export type NonReadableViewFieldInfo = { + fieldLabel?: string; + objectLabel: string; + usage: NonReadableViewFieldUsage; +}; + +export const useHasCurrentViewNonReadableFields = ( + objectMetadataItem: EnrichedObjectMetadataItem, +) => { + const contextStoreCurrentViewId = useAtomComponentStateValue( + contextStoreCurrentViewIdComponentState, + ); + + const view = useAtomFamilySelectorValue(viewFromViewIdFamilySelector, { + viewId: contextStoreCurrentViewId ?? '', + }); + + const { objectMetadataItems } = useObjectMetadataItems(); + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + + const nonReadableViewFieldInfo = useMemo((): + | NonReadableViewFieldInfo + | undefined => { + if (!isDefined(view)) { + return undefined; + } + + const readableFieldIds = new Set( + objectMetadataItem.readableFields.map((field) => field.id), + ); + + const params = { + objectMetadataItem, + objectMetadataItems, + readableFieldIds, + objectPermissionsByObjectMetadataId, + }; + + for (const sort of view.viewSorts) { + const info = getNonReadableFieldInfoForViewField({ + fieldMetadataId: sort.fieldMetadataId, + ...params, + }); + + if (isDefined(info)) { + return { ...info, usage: 'sort' }; + } + } + + for (const filter of view.viewFilters) { + const info = getNonReadableFieldInfoForViewField({ + fieldMetadataId: filter.fieldMetadataId, + ...params, + }); + + if (isDefined(info)) { + return { ...info, usage: 'filter' }; + } + } + + return undefined; + }, [ + view, + objectMetadataItem, + objectMetadataItems, + objectPermissionsByObjectMetadataId, + ]); + + return { + hasCurrentViewNonReadableFields: isDefined(nonReadableViewFieldInfo), + nonReadableViewFieldInfo, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/utils/getNonReadableInfoForViewField.ts b/packages/twenty-front/src/modules/object-record/record-index/utils/getNonReadableInfoForViewField.ts new file mode 100644 index 00000000000..8397c32d38e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/utils/getNonReadableInfoForViewField.ts @@ -0,0 +1,83 @@ +import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; +import { getFieldMetadataItemById } from '@/object-metadata/utils/getFieldMetadataItemById'; +import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject'; +import { type ObjectPermissions } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const getNonReadableFieldInfoForViewField = ({ + fieldMetadataId, + objectMetadataItem, + objectMetadataItems, + readableFieldIds, + objectPermissionsByObjectMetadataId, +}: { + fieldMetadataId: string; + objectMetadataItem: EnrichedObjectMetadataItem; + objectMetadataItems: EnrichedObjectMetadataItem[]; + readableFieldIds: Set; + objectPermissionsByObjectMetadataId: Record< + string, + ObjectPermissions & { objectMetadataId: string } + >; +}): { fieldLabel?: string; objectLabel: string } | undefined => { + const { fieldMetadataItem } = getFieldMetadataItemById({ + fieldMetadataId, + objectMetadataItems, + }); + + if (!isDefined(fieldMetadataItem)) { + return; + } + + if (!readableFieldIds.has(fieldMetadataId)) { + return { + fieldLabel: fieldMetadataItem.label, + objectLabel: objectMetadataItem.labelSingular, + }; + } + + if (fieldMetadataItem.type !== FieldMetadataType.RELATION) { + return; + } + + const targetObjectMetadataItem = objectMetadataItems.find( + (item) => + item.nameSingular === + fieldMetadataItem.relation?.targetObjectMetadata.nameSingular, + ); + + if (!isDefined(targetObjectMetadataItem)) { + return; + } + + const targetObjectPermissions = getObjectPermissionsForObject( + objectPermissionsByObjectMetadataId, + targetObjectMetadataItem.id, + ); + + if (!targetObjectPermissions.canReadObjectRecords) { + return { + objectLabel: targetObjectMetadataItem.labelSingular, + }; + } + + const labelIdentifierFieldId = + targetObjectMetadataItem.labelIdentifierFieldMetadataId; + + const labelIdentifierField = targetObjectMetadataItem.fields.find( + (field) => field.id === labelIdentifierFieldId, + ); + + const isLabelIdentifierReadable = + targetObjectMetadataItem.readableFields.some( + (field) => field.id === labelIdentifierFieldId, + ); + + if (!isLabelIdentifierReadable) { + return { + fieldLabel: labelIdentifierField?.label, + objectLabel: targetObjectMetadataItem.labelSingular, + }; + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/utils/getNonReadableViewFieldSubTitle.ts b/packages/twenty-front/src/modules/object-record/record-index/utils/getNonReadableViewFieldSubTitle.ts new file mode 100644 index 00000000000..73f230c9a05 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/utils/getNonReadableViewFieldSubTitle.ts @@ -0,0 +1,16 @@ +import { type NonReadableViewFieldInfo } from '@/object-record/record-index/hooks/useHasCurrentViewNonReadableFields'; +import { t } from '@lingui/core/macro'; +import { isDefined } from 'twenty-shared/utils'; + +export const getNonReadableViewFieldSubTitle = ( + nonReadableViewFieldInfo: NonReadableViewFieldInfo, +): string => { + const usageLabel = + nonReadableViewFieldInfo.usage === 'sort' ? t`sorting` : t`filtering`; + + if (isDefined(nonReadableViewFieldInfo.fieldLabel)) { + return t`This view uses ${usageLabel} on field "${nonReadableViewFieldInfo.fieldLabel}" on "${nonReadableViewFieldInfo.objectLabel}" which is not accessible.`; + } + + return t`This view uses ${usageLabel} on object "${nonReadableViewFieldInfo.objectLabel}" which is not accessible.`; +}; diff --git a/packages/twenty-front/src/modules/views/components/ViewBar.tsx b/packages/twenty-front/src/modules/views/components/ViewBar.tsx index f7625a8b469..1d970fd5ead 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBar.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBar.tsx @@ -1,7 +1,7 @@ import { type ReactNode } from 'react'; -import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { ObjectSortDropdownButton } from '@/object-record/object-sort-dropdown/components/ObjectSortDropdownButton'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { TopBar } from '@/ui/layout/top-bar/components/TopBar'; import { QueryParamsFiltersEffect } from '@/views/components/QueryParamsFiltersEffect'; import { QueryParamsSortsEffect } from '@/views/components/QueryParamsSortsEffect'; diff --git a/packages/twenty-ui/src/layout/animated-placeholder/constants/Background.ts b/packages/twenty-ui/src/layout/animated-placeholder/constants/Background.ts index 215388b6d0e..d485b8f9672 100644 --- a/packages/twenty-ui/src/layout/animated-placeholder/constants/Background.ts +++ b/packages/twenty-ui/src/layout/animated-placeholder/constants/Background.ts @@ -14,4 +14,5 @@ export const BACKGROUND: Record = { error500: '/images/placeholders/background/500_bg.png', noDeletedRecord: '/images/placeholders/background/no_deleted_record_bg.png', noWidgets: '/images/placeholders/background/no_widgets_bg.png', + notShared: '/images/placeholders/background/not_shared_bg.png', }; diff --git a/packages/twenty-ui/src/layout/animated-placeholder/constants/DarkBackground.ts b/packages/twenty-ui/src/layout/animated-placeholder/constants/DarkBackground.ts index 534bc4cd866..3371f4990a1 100644 --- a/packages/twenty-ui/src/layout/animated-placeholder/constants/DarkBackground.ts +++ b/packages/twenty-ui/src/layout/animated-placeholder/constants/DarkBackground.ts @@ -15,4 +15,5 @@ export const DARK_BACKGROUND: Record = { noDeletedRecord: '/images/placeholders/dark-background/no_deleted_record_bg.png', noWidgets: '/images/placeholders/dark-background/no_widgets_bg.png', + notShared: '/images/placeholders/dark-background/not_shared_bg.png', }; diff --git a/packages/twenty-ui/src/layout/animated-placeholder/constants/DarkMovingImage.ts b/packages/twenty-ui/src/layout/animated-placeholder/constants/DarkMovingImage.ts index 6949166672f..63633ada6f8 100644 --- a/packages/twenty-ui/src/layout/animated-placeholder/constants/DarkMovingImage.ts +++ b/packages/twenty-ui/src/layout/animated-placeholder/constants/DarkMovingImage.ts @@ -15,4 +15,5 @@ export const DARK_MOVING_IMAGE: Record = { noDeletedRecord: '/images/placeholders/dark-moving-image/no_deleted_record.png', noWidgets: '/images/placeholders/dark-moving-image/no_widgets.png', + notShared: '/images/placeholders/dark-moving-image/not_shared.png', }; diff --git a/packages/twenty-ui/src/layout/animated-placeholder/constants/MovingImage.ts b/packages/twenty-ui/src/layout/animated-placeholder/constants/MovingImage.ts index 6d591b5bb1f..afedead50f2 100644 --- a/packages/twenty-ui/src/layout/animated-placeholder/constants/MovingImage.ts +++ b/packages/twenty-ui/src/layout/animated-placeholder/constants/MovingImage.ts @@ -14,4 +14,5 @@ export const MOVING_IMAGE: Record = { error500: '/images/placeholders/moving-image/500.png', noDeletedRecord: '/images/placeholders/moving-image/no_deleted_record.png', noWidgets: '/images/placeholders/moving-image/no_widgets.png', + notShared: '/images/placeholders/moving-image/not_shared.png', };