Add system view fallback (#18536)

## Context
The goal is to add a "See records" button in all objects that would
redirect to that view (this will be done in a later PR). See screenshot
below.
<img width="665" height="312" alt="Screenshot 2026-03-10 at 15 54 36"
src="https://github.com/user-attachments/assets/6e23a75b-cff0-4d93-bce8-b5481b05c6f6"
/>

## Implementation
- If a view does not exist on an object, there is a **temporary**
fallback where the frontend creates the missing view as a custom view
when going over the object index page
- System objects are now surfaced but we don't want their records to be
editable, they will be readonly (mostly, all fields will be non-editable
except for their custom fields).
- We can't create a new record of a system object, some actions are also
hidden.
- The backend now rejects if you are trying to delete the last view of
an object
This commit is contained in:
Weiko
2026-03-11 13:48:02 +01:00
committed by GitHub
parent 99f885306e
commit 00c3cd1051
31 changed files with 343 additions and 37 deletions
@@ -157,10 +157,12 @@ export const CalendarEventDetails = ({
const renderField = (fieldMetadataItem: FieldMetadataItem) => {
const isReadOnly = isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions,
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
});
@@ -116,8 +116,13 @@ export const DEFAULT_RECORD_COMMAND_MENU_ITEMS_CONFIG: Record<
position: 2,
isPinned: true,
Icon: IconPlus,
shouldBeRegistered: ({ objectPermissions, hasAnySoftDeleteFilterOnView }) =>
(objectPermissions.canUpdateObjectRecords &&
shouldBeRegistered: ({
objectMetadataItem,
objectPermissions,
hasAnySoftDeleteFilterOnView,
}) =>
(!objectMetadataItem?.isSystem &&
objectPermissions.canUpdateObjectRecords &&
!hasAnySoftDeleteFilterOnView) ??
false,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION],
@@ -440,8 +445,10 @@ export const DEFAULT_RECORD_COMMAND_MENU_ITEMS_CONFIG: Record<
Icon: IconFileImport,
accent: 'default',
isPinned: false,
shouldBeRegistered: ({ hasAnySoftDeleteFilterOnView }) =>
!hasAnySoftDeleteFilterOnView,
shouldBeRegistered: ({
objectMetadataItem,
hasAnySoftDeleteFilterOnView,
}) => !objectMetadataItem?.isSystem && !hasAnySoftDeleteFilterOnView,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION],
component: <ImportRecordsNoSelectionRecordCommand />,
requiredPermissionFlag: PermissionFlagType.IMPORT_CSV,
@@ -10,13 +10,14 @@ import { coreViewsState } from '@/views/states/coreViewState';
import { useLocation, useParams, useSearchParams } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ViewKey } from '~/generated-metadata/graphql';
import { ViewKey, ViewType } from '~/generated-metadata/graphql';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
const getViewId = (
viewIdFromQueryParams: string | null,
indexViewId?: string,
lastVisitedViewId?: string,
firstAvailableViewId?: string,
) => {
if (isDefined(viewIdFromQueryParams)) {
return viewIdFromQueryParams;
@@ -30,6 +31,10 @@ const getViewId = (
return indexViewId;
}
if (isDefined(firstAvailableViewId)) {
return firstAvailableViewId;
}
return undefined;
};
@@ -46,7 +51,7 @@ export const MainContextStoreProvider = () => {
const objectNameSingular = useParams().objectNameSingular ?? '';
const [searchParams] = useSearchParams();
const viewIdQueryParam = searchParams.get('viewId');
const viewIdQueryParamRaw = searchParams.get('viewId');
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const metadataStore = useAtomFamilyStateValue(metadataStoreState, 'views');
@@ -60,17 +65,49 @@ export const MainContextStoreProvider = () => {
const { getLastVisitedViewIdFromObjectNamePlural } = useLastVisitedView();
const lastVisitedViewId = getLastVisitedViewIdFromObjectNamePlural(
const viewIdQueryParamView = coreViews.find(
(view) => view.id === viewIdQueryParamRaw,
);
const viewIdQueryParam =
isDefined(viewIdQueryParamView) &&
viewIdQueryParamView.type !== ViewType.FIELDS_WIDGET
? viewIdQueryParamRaw
: null;
const lastVisitedViewIdRaw = getLastVisitedViewIdFromObjectNamePlural(
objectMetadataItem?.namePlural ?? '',
);
const lastVisitedView = coreViews.find(
(view) => view.id === lastVisitedViewIdRaw,
);
const lastVisitedViewId =
isDefined(lastVisitedView) &&
lastVisitedView.type !== ViewType.FIELDS_WIDGET
? lastVisitedViewIdRaw
: undefined;
const indexViewId = coreViews.find(
(view) =>
view.objectMetadataId === objectMetadataItem?.id &&
view.key === ViewKey.INDEX,
)?.id;
const viewId = getViewId(viewIdQueryParam, indexViewId, lastVisitedViewId);
const firstAvailableViewId = coreViews.find(
(view) =>
view.objectMetadataId === objectMetadataItem?.id &&
view.type !== ViewType.FIELDS_WIDGET,
)?.id;
const viewId = getViewId(
viewIdQueryParam,
indexViewId,
lastVisitedViewId,
firstAvailableViewId,
);
const showAuthModal = useShowAuthModal();
const shouldComputeContextStore =
@@ -14,11 +14,13 @@ import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { coreViewsFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreViewsFromObjectMetadataItemFamilySelector';
import { ViewKey } from '@/views/types/ViewKey';
import { ViewType, viewTypeIconMapping } from '@/views/types/ViewType';
import { useDestroyViewFromCurrentState } from '@/views/view-picker/hooks/useDestroyViewFromCurrentState';
import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useLingui } from '@lingui/react/macro';
import { capitalize, isDefined } from 'twenty-shared/utils';
import {
@@ -64,7 +66,13 @@ export const ObjectOptionsDropdownCustomView = ({
)
: undefined;
const viewsOnCurrentObject = useAtomFamilySelectorValue(
coreViewsFromObjectMetadataItemFamilySelector,
{ objectMetadataItemId: objectMetadataItem.id },
);
const isDefaultView = currentView?.key === ViewKey.Index;
const isLastView = viewsOnCurrentObject.length <= 1;
const recordIndexCalendarLayout = useAtomStateValue(
recordIndexCalendarLayoutState,
@@ -272,14 +280,18 @@ export const ObjectOptionsDropdownCustomView = ({
onClick={() => handleDelete()}
LeftIcon={IconTrash}
text={t`Delete view`}
disabled={isDefaultView}
disabled={isDefaultView || isLastView}
/>
</SelectableListItem>
</div>
{isDefaultView && (
{(isDefaultView || isLastView) && (
<AppTooltip
anchorSelect={`#delete-view-menu-item`}
content={t`Not available on Default View`}
content={
isDefaultView
? t`Not available on Default View`
: t`Cannot delete the only view`
}
noArrow
place="bottom"
width="100%"
@@ -41,6 +41,7 @@ export const useIsRecordFieldReadOnly = ({
return isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions,
fieldMetadataItem,
});
@@ -23,6 +23,7 @@ describe('isRecordFieldReadOnly', () => {
fieldMetadataItem: {
id: 'field-123',
isUIReadOnly: false,
isCustom: false,
},
});
@@ -39,6 +40,7 @@ describe('isRecordFieldReadOnly', () => {
fieldMetadataItem: {
id: 'field-123',
isUIReadOnly: false,
isCustom: false,
},
});
@@ -57,6 +59,7 @@ describe('isRecordFieldReadOnly', () => {
fieldMetadataItem: {
id: 'field-123',
isUIReadOnly: false,
isCustom: false,
},
});
@@ -69,6 +72,7 @@ describe('isRecordFieldReadOnly', () => {
fieldMetadataItem: {
id: 'field-123',
isUIReadOnly: true,
isCustom: false,
},
});
@@ -81,6 +85,48 @@ describe('isRecordFieldReadOnly', () => {
fieldMetadataItem: {
id: 'field-123',
isUIReadOnly: false,
isCustom: false,
},
});
expect(result).toBe(false);
});
it('should return true when field is non-custom on a system object', () => {
const result = isRecordFieldReadOnly({
...mockParams,
isSystemObject: true,
fieldMetadataItem: {
id: 'field-123',
isUIReadOnly: false,
isCustom: false,
},
});
expect(result).toBe(true);
});
it('should return false when field is custom on a system object', () => {
const result = isRecordFieldReadOnly({
...mockParams,
isSystemObject: true,
fieldMetadataItem: {
id: 'field-123',
isUIReadOnly: false,
isCustom: true,
},
});
expect(result).toBe(false);
});
it('should return false when isSystemObject is not provided', () => {
const result = isRecordFieldReadOnly({
...mockParams,
fieldMetadataItem: {
id: 'field-123',
isUIReadOnly: false,
isCustom: false,
},
});
@@ -4,13 +4,18 @@ import { type ObjectPermission } from '~/generated-metadata/graphql';
type IsRecordFieldReadOnlyParams = {
isRecordReadOnly: boolean;
fieldMetadataItem: Pick<FieldMetadataItem, 'id' | 'isUIReadOnly'>;
isSystemObject?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'id' | 'isUIReadOnly' | 'isCustom'
>;
objectPermissions: ObjectPermission;
};
export const isRecordFieldReadOnly = ({
objectPermissions,
isRecordReadOnly,
isSystemObject,
fieldMetadataItem,
}: IsRecordFieldReadOnlyParams) => {
const fieldReadOnlyByPermissions = isFieldMetadataReadOnlyByPermissions({
@@ -20,6 +25,7 @@ export const isRecordFieldReadOnly = ({
return (
isRecordReadOnly ||
(isSystemObject === true && fieldMetadataItem.isCustom !== true) ||
fieldMetadataItem.isUIReadOnly ||
fieldReadOnlyByPermissions
);
@@ -22,7 +22,8 @@ import { useContext } from 'react';
export const RecordBoardCardBody = () => {
const { recordId, isRecordReadOnly } = useContext(RecordBoardCardContext);
const { updateOneRecord, objectPermissions } = useContext(RecordBoardContext);
const { updateOneRecord, objectPermissions, objectMetadataItem } =
useContext(RecordBoardContext);
const {
labelIdentifierFieldMetadataItem,
@@ -72,12 +73,15 @@ export const RecordBoardCardBody = () => {
isLabelIdentifier: false,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions,
fieldMetadataItem: {
id: recordField.fieldMetadataItemId,
isUIReadOnly:
correspondingFieldDefinition.metadata.isUIReadOnly ??
false,
isCustom:
correspondingFieldDefinition.metadata.isCustom ?? false,
},
}),
fieldDefinition: correspondingFieldDefinition,
@@ -52,6 +52,10 @@ export const RecordBoardColumnNewRecordButton = () => {
return null;
}
if (objectMetadataItem.isSystem) {
return null;
}
if (hasAnySoftDeleteFilterOnView) {
return null;
}
@@ -62,6 +62,7 @@ export const RecordCalendarAddNew = ({
if (
hasAnySoftDeleteFilterOnView === true ||
hasObjectUpdatePermissions === false ||
objectMetadataItem.isSystem === true ||
calendarFieldMetadataItem === undefined ||
isCalendarFieldReadOnly === true
) {
@@ -82,12 +82,15 @@ export const RecordCalendarCardBody = ({
isLabelIdentifier: false,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions,
fieldMetadataItem: {
id: recordField.fieldMetadataItemId,
isUIReadOnly:
correspondingFieldDefinition.metadata.isUIReadOnly ??
false,
isCustom:
correspondingFieldDefinition.metadata.isCustom ?? false,
},
}),
fieldDefinition: correspondingFieldDefinition,
@@ -124,6 +124,7 @@ export const RecordFieldList = ({
})}`,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions:
getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
@@ -132,6 +133,7 @@ export const RecordFieldList = ({
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
}),
}}
@@ -172,6 +174,7 @@ export const RecordFieldList = ({
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions:
getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
@@ -180,6 +183,7 @@ export const RecordFieldList = ({
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
}),
onMouseEnter: () =>
@@ -246,6 +250,7 @@ export const RecordFieldList = ({
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions:
getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
@@ -254,6 +259,7 @@ export const RecordFieldList = ({
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
}),
}}
@@ -3,6 +3,7 @@ import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/
import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useCreateDefaultViewForObject } from '@/views/hooks/useCreateDefaultViewForObject';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -23,8 +24,13 @@ export const RecordIndexLoadBaseOnContextStoreEffect = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { createDefaultViewForObject } = useCreateDefaultViewForObject();
useEffect(() => {
if (loadedViewId === contextStoreCurrentViewId) {
if (
isDefined(contextStoreCurrentViewId) &&
loadedViewId === contextStoreCurrentViewId
) {
return;
}
@@ -35,6 +41,8 @@ export const RecordIndexLoadBaseOnContextStoreEffect = () => {
if (isDefined(view)) {
loadRecordIndexStates(view, objectMetadataItem);
setLoadedViewId(contextStoreCurrentViewId);
} else {
createDefaultViewForObject(objectMetadataItem);
}
}, [
contextStoreCurrentViewId,
@@ -42,6 +50,7 @@ export const RecordIndexLoadBaseOnContextStoreEffect = () => {
loadedViewId,
objectMetadataItem,
view,
createDefaultViewForObject,
]);
return <></>;
@@ -66,7 +66,7 @@ export const RecordTableNoRecordGroupAddNew = () => {
return null;
}
if (isRecordTableCreateDisabled(objectMetadataItem.nameSingular)) {
if (isRecordTableCreateDisabled(objectMetadataItem)) {
return null;
}
@@ -85,7 +85,7 @@ export const RecordTableEmptyStateDisplay = (
{'buttonTitle' in props &&
!isReadOnly &&
!hasAnySoftDeleteFilterOnView &&
!isRecordTableCreateDisabled(objectMetadataItem.nameSingular) && (
!isRecordTableCreateDisabled(objectMetadataItem) && (
<Button
Icon={props.ButtonIcon}
title={props.buttonTitle}
@@ -101,10 +101,12 @@ export const RecordTableCellFieldContextGeneric = ({
displayedMaxRows: 1,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly: isRecordReadOnly ?? false,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions,
fieldMetadataItem: {
id: fieldDefinition.fieldMetadataId,
isUIReadOnly: fieldDefinition.metadata.isUIReadOnly ?? false,
isCustom: fieldDefinition.metadata.isCustom ?? false,
},
}),
isForbidden: !hasObjectReadPermissions,
@@ -59,10 +59,12 @@ export const RecordTableCellFieldContextLabelIdentifier = ({
displayedMaxRows: 1,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly: isRecordReadOnly ?? false,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions,
fieldMetadataItem: {
id: recordField.fieldMetadataItemId,
isUIReadOnly: fieldDefinition.metadata.isUIReadOnly ?? false,
isCustom: fieldDefinition.metadata.isCustom ?? false,
},
}),
maxWidth: recordField.size,
@@ -54,7 +54,7 @@ export const RecordTableHeaderLabelIdentifierCellPlusButton = () => {
!isReadOnly &&
hasObjectUpdatePermissions &&
!hasAnySoftDeleteFilterOnView &&
!isRecordTableCreateDisabled(objectMetadataItem.nameSingular) && (
!isRecordTableCreateDisabled(objectMetadataItem) && (
<StyledHeaderIcon>
<LightIconButton
Icon={IconPlus}
@@ -43,7 +43,7 @@ export const RecordTableRecordGroupSectionAddNew = () => {
return null;
}
if (isRecordTableCreateDisabled(objectMetadataItem.nameSingular)) {
if (isRecordTableCreateDisabled(objectMetadataItem)) {
return null;
}
@@ -1,14 +1,17 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { CoreObjectNameSingular } from 'twenty-shared/types';
const OBJECTS_WITHOUT_MANUAL_RECORD_CREATION: readonly CoreObjectNameSingular[] =
[CoreObjectNameSingular.WorkflowRun, CoreObjectNameSingular.WorkflowVersion];
export const isRecordTableCreateDisabled = (
objectNameSingular: string,
objectMetadataItem: Pick<ObjectMetadataItem, 'nameSingular' | 'isSystem'>,
): boolean => {
const isDisabled = OBJECTS_WITHOUT_MANUAL_RECORD_CREATION.includes(
objectNameSingular as CoreObjectNameSingular,
);
if (objectMetadataItem.isSystem) {
return true;
}
return isDisabled;
return OBJECTS_WITHOUT_MANUAL_RECORD_CREATION.includes(
objectMetadataItem.nameSingular as CoreObjectNameSingular,
);
};
@@ -108,6 +108,7 @@ export const WidgetActionFieldEdit = () => {
isDisplayModeFixHeight: false,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions: getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: objectMetadataItem.id,
@@ -115,6 +116,7 @@ export const WidgetActionFieldEdit = () => {
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
}),
anchorId: recordFieldInputInstanceId,
@@ -89,6 +89,7 @@ export const FieldWidgetDisplay = ({
isDisplayModeFixHeight: false,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions:
getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
@@ -97,6 +98,7 @@ export const FieldWidgetDisplay = ({
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
}),
onMouseEnter: handleMouseEnter,
@@ -73,6 +73,7 @@ export const FieldsWidgetFieldItem = ({
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions: getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: objectMetadataItem.id,
@@ -80,6 +81,7 @@ export const FieldsWidgetFieldItem = ({
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
}),
onMouseEnter,
@@ -77,6 +77,7 @@ export const useWidgetActions = ({
const isFieldReadOnly = isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions: getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: objectMetadataItem.id,
@@ -84,6 +85,7 @@ export const useWidgetActions = ({
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
});
@@ -0,0 +1,95 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist';
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId';
import { useCallback } from 'react';
import { v4 } from 'uuid';
import { ViewType } from '~/generated-metadata/graphql';
const DEFAULT_VIEW_FIELD_SIZE = 180;
const pendingViewCreations = new Set<string>();
// TODO: This runtime fallback logic is temporary
// System views will later be created declaratively in the database during twenty standard app installation.
export const useCreateDefaultViewForObject = () => {
const { performViewAPICreate } = usePerformViewAPIPersist();
const { performViewFieldAPICreate } = usePerformViewFieldAPIPersist();
const { refreshCoreViewsByObjectMetadataId } =
useRefreshCoreViewsByObjectMetadataId();
const createDefaultViewForObject = useCallback(
async (objectMetadataItem: ObjectMetadataItem) => {
if (pendingViewCreations.has(objectMetadataItem.id)) {
return;
}
pendingViewCreations.add(objectMetadataItem.id);
try {
const newViewId = v4();
const viewResult = await performViewAPICreate(
{
input: {
id: newViewId,
name: `All ${objectMetadataItem.labelPlural}`,
icon: objectMetadataItem.icon ?? 'IconList',
objectMetadataId: objectMetadataItem.id,
type: ViewType.TABLE,
},
},
objectMetadataItem.id,
);
if (viewResult.status !== 'successful') {
return;
}
const eligibleFields = objectMetadataItem.fields.filter(
(field) =>
field.isActive &&
!isHiddenSystemField(field) &&
field.name !== 'deletedAt',
);
const sortedFields = eligibleFields.toSorted((fieldA, fieldB) => {
const isFieldALabelIdentifier =
fieldA.id === objectMetadataItem.labelIdentifierFieldMetadataId;
const isFieldBLabelIdentifier =
fieldB.id === objectMetadataItem.labelIdentifierFieldMetadataId;
if (isFieldALabelIdentifier) return -1;
if (isFieldBLabelIdentifier) return 1;
return 0;
});
const viewFieldInputs = sortedFields.map((field, index) => ({
id: v4(),
viewId: newViewId,
fieldMetadataId: field.id,
position: index,
size: DEFAULT_VIEW_FIELD_SIZE,
isVisible: true,
}));
await performViewFieldAPICreate({ inputs: viewFieldInputs });
await refreshCoreViewsByObjectMetadataId(objectMetadataItem.id);
} finally {
pendingViewCreations.delete(objectMetadataItem.id);
}
},
[
performViewAPICreate,
performViewFieldAPICreate,
refreshCoreViewsByObjectMetadataId,
],
);
return {
createDefaultViewForObject,
};
};
@@ -1,7 +1,8 @@
import { createAtomFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomFamilySelector';
import { coreViewsState } from '@/views/states/coreViewState';
import { type View } from '@/views/types/View';
import { ViewType } from '@/views/types/ViewType';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { createAtomFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomFamilySelector';
export const coreViewsFromObjectMetadataItemFamilySelector =
createAtomFamilySelector<View[], { objectMetadataItemId: string }>({
@@ -12,7 +13,11 @@ export const coreViewsFromObjectMetadataItemFamilySelector =
const coreViews = get(coreViewsState);
const views = coreViews.map(convertCoreViewToView);
return views
.filter((view) => view.objectMetadataId === objectMetadataItemId)
.filter(
(view) =>
view.objectMetadataId === objectMetadataItemId &&
view.type !== ViewType.FieldsWidget,
)
.sort((a, b) => a.position - b.position);
},
});
@@ -1,4 +1,7 @@
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { coreViewsFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreViewsFromObjectMetadataItemFamilySelector';
import { ViewType } from '@/views/types/ViewType';
import { useCreateViewFromCurrentState } from '@/views/view-picker/hooks/useCreateViewFromCurrentState';
import { useDestroyViewFromCurrentState } from '@/views/view-picker/hooks/useDestroyViewFromCurrentState';
@@ -14,6 +17,15 @@ export const ViewPickerEditButton = () => {
const { availableFieldsForGrouping, navigateToSelectSettings } =
useGetAvailableFieldsToGroupRecordsBy();
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const viewsOnCurrentObject = useAtomFamilySelectorValue(
coreViewsFromObjectMetadataItemFamilySelector,
{ objectMetadataItemId: objectMetadataItem.id },
);
const isLastView = viewsOnCurrentObject.length <= 1;
const { viewPickerMode } = useViewPickerMode();
const viewPickerType = useAtomComponentStateValue(
viewPickerTypeComponentState,
@@ -39,7 +51,7 @@ export const ViewPickerEditButton = () => {
justify="center"
focus={false}
variant="secondary"
disabled={viewPickerIsPersisting}
disabled={viewPickerIsPersisting || isLastView}
/>
);
}
@@ -50,6 +50,8 @@ export const ViewPickerListContent = () => {
(view) => view.visibility === ViewVisibility.UNLISTED,
);
const isLastView = viewsOnCurrentObject.length <= 1;
const shouldShowSectionLabels =
workspaceViews.length > 0 && unlistedViews.length > 0;
@@ -155,6 +157,7 @@ export const ViewPickerListContent = () => {
view={{ ...view, __typename: 'View' }}
handleViewSelect={handleViewSelect}
isIndexView={isIndexView}
isLastView={isLastView}
onEdit={handleEditViewButtonClick}
/>
}
@@ -187,6 +190,7 @@ export const ViewPickerListContent = () => {
view={{ ...view, __typename: 'View' }}
handleViewSelect={handleViewSelect}
isIndexView={isIndexView}
isLastView={isLastView}
onEdit={handleEditViewButtonClick}
/>
}
@@ -30,6 +30,7 @@ import {
type ViewPickerOptionDropdownProps = {
isIndexView: boolean;
isLastView: boolean;
view: Pick<
View,
| 'id'
@@ -45,6 +46,7 @@ type ViewPickerOptionDropdownProps = {
export const ViewPickerOptionDropdown = ({
isIndexView,
isLastView,
onEdit,
view,
handleViewSelect,
@@ -149,12 +151,14 @@ export const ViewPickerOptionDropdown = ({
closeDropdown(dropdownId);
}}
/>
<MenuItem
LeftIcon={IconTrash}
text={t`Delete`}
onClick={handleDelete}
accent="danger"
/>
{!isLastView && (
<MenuItem
LeftIcon={IconTrash}
text={t`Delete`}
onClick={handleDelete}
accent="danger"
/>
)}
</>
)}
</>
@@ -60,12 +60,16 @@ export const useDestroyViewFromCurrentState = (viewBarInstanceId?: string) => {
const shouldChangeView = viewPickerReferenceViewId === currentView?.id;
const remainingViews = viewsOnCurrentObject.filter(
(view) => view.id !== viewPickerReferenceViewId,
);
if (remainingViews.length === 0) {
return;
}
if (shouldChangeView) {
changeView(
viewsOnCurrentObject.filter(
(view) => view.id !== viewPickerReferenceViewId,
)[0].id,
);
changeView(remainingViews[0].id);
}
store.set(
@@ -149,6 +149,7 @@ export class FlatViewValidatorService {
flatEntityToValidate,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatViewMaps: optimisticFlatViewMaps,
flatObjectMetadataMaps: optimisticFlatObjectMetadataMaps,
},
}: UniversalFlatEntityValidationArgs<
typeof ALL_METADATA_NAME.view
@@ -172,6 +173,34 @@ export class FlatViewValidatorService {
message: t`View not found`,
userFriendlyMessage: msg`View not found`,
});
return validationResult;
}
const parentObjectStillExists = isDefined(
findFlatEntityByUniversalIdentifier({
universalIdentifier: existingFlatView.objectMetadataUniversalIdentifier,
flatEntityMaps: optimisticFlatObjectMetadataMaps,
}),
);
if (parentObjectStillExists) {
const viewsForSameObject = Object.values(
optimisticFlatViewMaps.byUniversalIdentifier,
).filter(
(view) =>
isDefined(view) &&
view.objectMetadataUniversalIdentifier ===
existingFlatView.objectMetadataUniversalIdentifier,
);
if (viewsForSameObject.length <= 1) {
validationResult.errors.push({
code: ViewExceptionCode.INVALID_VIEW_DATA,
message: t`Cannot delete the only view for this object`,
userFriendlyMessage: msg`Cannot delete the only view for this object`,
});
}
}
return validationResult;