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
@@ -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(