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:
@@ -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,
|
||||
};
|
||||
};
|
||||
+7
-2
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
+13
-1
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+4
@@ -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}
|
||||
/>
|
||||
}
|
||||
|
||||
+10
-6
@@ -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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
+9
-5
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user