add UpsertViewWidget resolver (#20053)

This commit is contained in:
Abdul Rahman
2026-04-29 15:11:02 +05:30
committed by GitHub
parent b92617d46e
commit 19ee9444ed
51 changed files with 3274 additions and 1114 deletions
@@ -3193,6 +3193,7 @@ type Mutation {
updateView(id: String!, input: UpdateViewInput!): View!
deleteView(id: String!): Boolean!
destroyView(id: String!): Boolean!
upsertViewWidget(input: UpsertViewWidgetInput!): View!
createViewSort(input: CreateViewSortInput!): ViewSort!
updateViewSort(input: UpdateViewSortInput!): ViewSort!
deleteViewSort(input: DeleteViewSortInput!): Boolean!
@@ -3510,6 +3511,59 @@ input UpdateViewInput {
shouldHideEmptyGroups: Boolean
}
input UpsertViewWidgetInput {
"""The id of the view widget (page layout widget)."""
widgetId: UUID!
"""The view fields to upsert."""
viewFields: [UpsertViewWidgetViewFieldInput!]
"""The view filters to upsert."""
viewFilters: [UpsertViewWidgetViewFilterInput!]
"""The view filter groups to upsert."""
viewFilterGroups: [UpsertViewWidgetViewFilterGroupInput!]
"""The view sorts to upsert."""
viewSorts: [UpsertViewWidgetViewSortInput!]
}
input UpsertViewWidgetViewFieldInput {
"""The id of an existing view field to update."""
viewFieldId: UUID
"""
The field metadata id. Used to create a new view field when viewFieldId is not provided.
"""
fieldMetadataId: UUID
isVisible: Boolean!
position: Float!
size: Float
}
input UpsertViewWidgetViewFilterInput {
id: UUID
fieldMetadataId: UUID!
operand: ViewFilterOperand = CONTAINS
value: JSON!
viewFilterGroupId: UUID
positionInViewFilterGroup: Float
subFieldName: String
}
input UpsertViewWidgetViewFilterGroupInput {
id: UUID
parentViewFilterGroupId: UUID
logicalOperator: ViewFilterGroupLogicalOperator = AND
positionInViewFilterGroup: Float
}
input UpsertViewWidgetViewSortInput {
id: UUID
fieldMetadataId: UUID!
direction: ViewSortDirection = ASC
}
input CreateViewSortInput {
id: UUID
fieldMetadataId: UUID!
@@ -2675,6 +2675,7 @@ export interface Mutation {
updateView: View
deleteView: Scalars['Boolean']
destroyView: Scalars['Boolean']
upsertViewWidget: View
createViewSort: ViewSort
updateViewSort: ViewSort
deleteViewSort: Scalars['Boolean']
@@ -5725,6 +5726,7 @@ export interface MutationGenqlSelection{
updateView?: (ViewGenqlSelection & { __args: {id: Scalars['String'], input: UpdateViewInput} })
deleteView?: { __args: {id: Scalars['String']} }
destroyView?: { __args: {id: Scalars['String']} }
upsertViewWidget?: (ViewGenqlSelection & { __args: {input: UpsertViewWidgetInput} })
createViewSort?: (ViewSortGenqlSelection & { __args: {input: CreateViewSortInput} })
updateViewSort?: (ViewSortGenqlSelection & { __args: {input: UpdateViewSortInput} })
deleteViewSort?: { __args: {input: DeleteViewSortInput} }
@@ -5944,6 +5946,30 @@ export interface CreateViewInput {id?: (Scalars['UUID'] | null),name: Scalars['S
export interface UpdateViewInput {id?: (Scalars['UUID'] | null),name?: (Scalars['String'] | null),type?: (ViewType | null),icon?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isCompact?: (Scalars['Boolean'] | null),openRecordIn?: (ViewOpenRecordIn | null),kanbanAggregateOperation?: (AggregateOperations | null),kanbanAggregateOperationFieldMetadataId?: (Scalars['UUID'] | null),anyFieldFilterValue?: (Scalars['String'] | null),calendarLayout?: (ViewCalendarLayout | null),calendarFieldMetadataId?: (Scalars['UUID'] | null),visibility?: (ViewVisibility | null),mainGroupByFieldMetadataId?: (Scalars['UUID'] | null),shouldHideEmptyGroups?: (Scalars['Boolean'] | null)}
export interface UpsertViewWidgetInput {
/** The id of the view widget (page layout widget). */
widgetId: Scalars['UUID'],
/** The view fields to upsert. */
viewFields?: (UpsertViewWidgetViewFieldInput[] | null),
/** The view filters to upsert. */
viewFilters?: (UpsertViewWidgetViewFilterInput[] | null),
/** The view filter groups to upsert. */
viewFilterGroups?: (UpsertViewWidgetViewFilterGroupInput[] | null),
/** The view sorts to upsert. */
viewSorts?: (UpsertViewWidgetViewSortInput[] | null)}
export interface UpsertViewWidgetViewFieldInput {
/** The id of an existing view field to update. */
viewFieldId?: (Scalars['UUID'] | null),
/** The field metadata id. Used to create a new view field when viewFieldId is not provided. */
fieldMetadataId?: (Scalars['UUID'] | null),isVisible: Scalars['Boolean'],position: Scalars['Float'],size?: (Scalars['Float'] | null)}
export interface UpsertViewWidgetViewFilterInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],operand?: (ViewFilterOperand | null),value: Scalars['JSON'],viewFilterGroupId?: (Scalars['UUID'] | null),positionInViewFilterGroup?: (Scalars['Float'] | null),subFieldName?: (Scalars['String'] | null)}
export interface UpsertViewWidgetViewFilterGroupInput {id?: (Scalars['UUID'] | null),parentViewFilterGroupId?: (Scalars['UUID'] | null),logicalOperator?: (ViewFilterGroupLogicalOperator | null),positionInViewFilterGroup?: (Scalars['Float'] | null)}
export interface UpsertViewWidgetViewSortInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],direction?: (ViewSortDirection | null)}
export interface CreateViewSortInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],direction?: (ViewSortDirection | null),viewId: Scalars['UUID']}
export interface UpdateViewSortInput {
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates';
import { lastLoadedRecordTableWidgetViewIdComponentState } from '@/object-record/record-table-widget/states/lastLoadedRecordTableWidgetViewIdComponentState';
import { computeRecordTableWidgetViewLoadContentSignature } from '@/object-record/record-table-widget/utils/computeRecordTableWidgetViewLoadContentSignature';
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { recordTableWidgetViewDraftByWidgetIdComponentFamilySelector } from '@/page-layout/states/selectors/recordTableWidgetViewDraftByWidgetIdComponentFamilySelector';
import { constructViewFromRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/constructViewFromRecordTableWidgetViewSnapshot';
@@ -60,11 +61,17 @@ export const RecordTableWidgetViewLoadEffect = ({
return;
}
if (
const contentSignature =
computeRecordTableWidgetViewLoadContentSignature(currentView);
const lastLoadedMatches =
viewId === lastLoadedRecordTableWidgetViewId?.viewId &&
objectMetadataItem.updatedAt ===
lastLoadedRecordTableWidgetViewId?.objectMetadataItemUpdatedAt
) {
lastLoadedRecordTableWidgetViewId?.objectMetadataItemUpdatedAt &&
contentSignature ===
lastLoadedRecordTableWidgetViewId?.loadedViewContentSignature;
if (lastLoadedMatches) {
return;
}
@@ -73,6 +80,7 @@ export const RecordTableWidgetViewLoadEffect = ({
setLastLoadedRecordTableWidgetViewId({
viewId,
objectMetadataItemUpdatedAt: objectMetadataItem.updatedAt,
loadedViewContentSignature: contentSignature,
});
}, [
viewId,
@@ -5,6 +5,7 @@ export const lastLoadedRecordTableWidgetViewIdComponentState =
createAtomComponentState<{
viewId: string;
objectMetadataItemUpdatedAt: string;
loadedViewContentSignature: string;
} | null>({
key: 'lastLoadedRecordTableWidgetViewIdComponentState',
defaultValue: null,
@@ -0,0 +1,34 @@
import { type View } from '@/views/types/View';
export const computeRecordTableWidgetViewLoadContentSignature = (
view: View,
): string =>
JSON.stringify({
fields: [...view.viewFields]
.sort((fieldA, fieldB) => fieldA.position - fieldB.position)
.map((field) => ({
id: field.id,
fieldMetadataId: field.fieldMetadataId,
isVisible: field.isVisible,
position: field.position,
})),
filters: [...view.viewFilters]
.sort((filterA, filterB) => filterA.id.localeCompare(filterB.id))
.map((filter) => ({
id: filter.id,
fieldMetadataId: filter.fieldMetadataId,
operand: filter.operand,
value: filter.value,
viewFilterGroupId: filter.viewFilterGroupId,
})),
filterGroups: (view.viewFilterGroups ?? []).map((group) => ({
id: group.id,
logicalOperator: group.logicalOperator,
parentViewFilterGroupId: group.parentViewFilterGroupId,
})),
sorts: view.viewSorts.map((sort) => ({
id: sort.id,
fieldMetadataId: sort.fieldMetadataId,
direction: sort.direction,
})),
});
@@ -0,0 +1,11 @@
import { VIEW_FRAGMENT } from '@/views/graphql/fragments/viewFragment';
import { gql } from '@apollo/client';
export const UPSERT_VIEW_WIDGET = gql`
${VIEW_FRAGMENT}
mutation UpsertViewWidget($input: UpsertViewWidgetInput!) {
upsertViewWidget(input: $input) {
...ViewFragment
}
}
`;
@@ -1,10 +1,8 @@
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId';
import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist';
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -13,7 +11,6 @@ import { WidgetType } from '~/generated-metadata/graphql';
export const useCreatePendingRecordTableWidgetViews = () => {
const { performViewAPICreate, performViewAPIDestroy } =
usePerformViewAPIPersist();
const { performViewFieldAPICreate } = usePerformViewFieldAPIPersist();
const store = useStore();
const createPendingRecordTableWidgetViews = useCallback(
@@ -101,19 +98,6 @@ export const useCreatePendingRecordTableWidgetViews = () => {
`Failed to create view for RECORD_TABLE widget ${widget.id}`,
);
}
const viewFieldInputs = widgetViewDraft.viewFields.map((field) => ({
id: field.id,
viewId: field.viewId,
fieldMetadataId: field.fieldMetadataId,
position: field.position,
size: field.size,
isVisible: field.isVisible,
}));
if (viewFieldInputs.length > 0) {
await performViewFieldAPICreate({ inputs: viewFieldInputs });
}
}
for (const [widgetId, viewId] of persistedRecordTableWidgets) {
@@ -121,20 +105,8 @@ export const useCreatePendingRecordTableWidgetViews = () => {
await performViewAPIDestroy({ id: viewId });
}
}
store.set(
recordTableWidgetViewPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
recordTableWidgetViewDraft,
);
},
[
performViewAPICreate,
performViewAPIDestroy,
performViewFieldAPICreate,
store,
],
[performViewAPICreate, performViewAPIDestroy, store],
);
return { createPendingRecordTableWidgetViews };
@@ -0,0 +1,32 @@
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useHasRecordTableWidgetViewChanges = () => {
const store = useStore();
const hasRecordTableWidgetViewChanges = useCallback(
(pageLayoutId: string): boolean => {
const recordTableWidgetViewDraft = store.get(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const recordTableWidgetViewPersisted = store.get(
recordTableWidgetViewPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
return !isDeeplyEqual(
recordTableWidgetViewDraft,
recordTableWidgetViewPersisted,
);
},
[store],
);
return { hasRecordTableWidgetViewChanges };
};
@@ -1,29 +0,0 @@
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
export const useHasRecordTableWidgets = () => {
const store = useStore();
const hasRecordTableWidgets = useCallback(
(pageLayoutId: string): boolean => {
const pageLayoutDraft = store.get(
pageLayoutDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
return pageLayoutDraft.tabs.some((tab) =>
tab.widgets.some(
(widget) =>
widget.configuration.configurationType ===
WidgetConfigurationType.RECORD_TABLE,
),
);
},
[store],
);
return { hasRecordTableWidgets };
};
@@ -9,6 +9,8 @@ import { fieldsWidgetUngroupedFieldsPersistedComponentState } from '@/page-layou
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
@@ -83,6 +85,17 @@ export const useResetDraftPageLayoutToPersistedPageLayout = ({
componentInstanceId,
);
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
componentInstanceId,
);
const recordTableWidgetViewPersistedState =
useAtomComponentStateCallbackState(
recordTableWidgetViewPersistedComponentState,
componentInstanceId,
);
const resetDraftPageLayoutToPersistedPageLayout = useCallback(() => {
const pageLayoutPersisted = store.get(pageLayoutPersistedState);
@@ -132,6 +145,14 @@ export const useResetDraftPageLayoutToPersistedPageLayout = ({
fieldsWidgetEditorModeDraftState,
fieldsWidgetEditorModePersisted,
);
const recordTableWidgetViewPersisted = store.get(
recordTableWidgetViewPersistedState,
);
store.set(
recordTableWidgetViewDraftState,
recordTableWidgetViewPersisted,
);
}
}, [
pageLayoutDraftState,
@@ -143,6 +164,8 @@ export const useResetDraftPageLayoutToPersistedPageLayout = ({
fieldsWidgetUngroupedFieldsPersistedState,
fieldsWidgetEditorModeDraftState,
fieldsWidgetEditorModePersistedState,
recordTableWidgetViewDraftState,
recordTableWidgetViewPersistedState,
activeTabId,
store,
]);
@@ -1,18 +1,18 @@
import { useHasFieldsWidgetChanges } from '@/page-layout/hooks/useHasFieldsWidgetChanges';
import { useHasRecordTableWidgets } from '@/page-layout/hooks/useHasRecordTableWidgets';
import { useHasRecordTableWidgetViewChanges } from '@/page-layout/hooks/useHasRecordTableWidgetViewChanges';
import { useSaveFieldsWidgetGroups } from '@/page-layout/hooks/useSaveFieldsWidgetGroups';
import { useSaveRecordTableWidgetsViewDataOnDashboardSave } from '@/page-layout/widgets/record-table/hooks/useSaveRecordTableWidgetsViewDataOnDashboardSave';
import { useSaveRecordTableWidgetViews } from '@/page-layout/hooks/useSaveRecordTableWidgetViews';
import { useCallback } from 'react';
export const useSavePageLayoutWidgetsData = () => {
const { hasFieldsWidgetChanges } = useHasFieldsWidgetChanges();
const { hasRecordTableWidgets } = useHasRecordTableWidgets();
const { hasRecordTableWidgetViewChanges } =
useHasRecordTableWidgetViewChanges();
const { saveFieldsWidgetGroups } = useSaveFieldsWidgetGroups();
const { saveRecordTableWidgetsViewDataOnDashboardSave } =
useSaveRecordTableWidgetsViewDataOnDashboardSave();
const { saveRecordTableWidgetViews } = useSaveRecordTableWidgetViews();
const savePageLayoutWidgetsData = useCallback(
async (pageLayoutId: string) => {
@@ -20,15 +20,15 @@ export const useSavePageLayoutWidgetsData = () => {
await saveFieldsWidgetGroups(pageLayoutId);
}
if (hasRecordTableWidgets(pageLayoutId)) {
await saveRecordTableWidgetsViewDataOnDashboardSave(pageLayoutId);
if (hasRecordTableWidgetViewChanges(pageLayoutId)) {
await saveRecordTableWidgetViews(pageLayoutId);
}
},
[
hasFieldsWidgetChanges,
hasRecordTableWidgetViewChanges,
saveFieldsWidgetGroups,
saveRecordTableWidgetsViewDataOnDashboardSave,
hasRecordTableWidgets,
saveRecordTableWidgetViews,
],
);
@@ -0,0 +1,115 @@
import { UPSERT_VIEW_WIDGET } from '@/page-layout/graphql/mutations/upsertViewWidget';
import { useHasRecordTableWidgetViewChanges } from '@/page-layout/hooks/useHasRecordTableWidgetViewChanges';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId';
import { useMutation } from '@apollo/client/react';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
type UpsertViewWidgetInput,
type ViewFragmentFragment,
WidgetType,
} from '~/generated-metadata/graphql';
export const useSaveRecordTableWidgetViews = () => {
const [upsertViewWidgetMutation] = useMutation<
{ upsertViewWidget: ViewFragmentFragment },
{ input: UpsertViewWidgetInput }
>(UPSERT_VIEW_WIDGET);
const { hasRecordTableWidgetViewChanges } =
useHasRecordTableWidgetViewChanges();
const store = useStore();
const saveRecordTableWidgetViews = useCallback(
async (pageLayoutId: string) => {
if (!hasRecordTableWidgetViewChanges(pageLayoutId)) {
return;
}
const draft = store.get(
pageLayoutDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const recordTableWidgetViewDraft = store.get(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const draftRecordTableWidgets = draft.tabs
.flatMap((tab) => tab.widgets)
.filter((widget) => widget.type === WidgetType.RECORD_TABLE);
for (const widget of draftRecordTableWidgets) {
const viewId = getWidgetConfigurationViewId(widget.configuration);
if (!isDefined(viewId)) {
continue;
}
const widgetViewDraft = recordTableWidgetViewDraft[widget.id];
if (!isDefined(widgetViewDraft)) {
continue;
}
await upsertViewWidgetMutation({
variables: {
input: {
widgetId: widget.id,
viewFields: widgetViewDraft.viewFields.map((field) => ({
...(isDefined(field.id) ? { viewFieldId: field.id } : {}),
fieldMetadataId: field.fieldMetadataId,
isVisible: field.isVisible,
position: field.position,
size: field.size,
})),
viewFilters: widgetViewDraft.viewFilters.map((filter) => ({
id: filter.id,
fieldMetadataId: filter.fieldMetadataId,
operand: filter.operand,
value: filter.value,
viewFilterGroupId: filter.viewFilterGroupId ?? undefined,
positionInViewFilterGroup:
filter.positionInViewFilterGroup ?? undefined,
subFieldName: filter.subFieldName ?? undefined,
})),
viewFilterGroups: widgetViewDraft.viewFilterGroups.map(
(group) => ({
id: group.id,
parentViewFilterGroupId:
group.parentViewFilterGroupId ?? undefined,
logicalOperator: group.logicalOperator,
positionInViewFilterGroup:
group.positionInViewFilterGroup ?? undefined,
}),
),
viewSorts: widgetViewDraft.viewSorts.map((sort) => ({
id: sort.id,
fieldMetadataId: sort.fieldMetadataId,
direction: sort.direction,
})),
},
},
});
}
store.set(
recordTableWidgetViewPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
recordTableWidgetViewDraft,
);
},
[hasRecordTableWidgetViewChanges, store, upsertViewWidgetMutation],
);
return { saveRecordTableWidgetViews };
};
@@ -70,7 +70,13 @@ export const useAddDraftViewForRecordTableWidget = (pageLayoutId: string) => {
store.set(recordTableWidgetViewDraftState, (prev) => ({
...prev,
[widgetId]: { view: flatView, viewFields: flatViewFields },
[widgetId]: {
view: flatView,
viewFields: flatViewFields,
viewFilters: [],
viewFilterGroups: [],
viewSorts: [],
},
}));
requestAnimationFrame(() => {
@@ -1,4 +1,7 @@
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
import { type FlatViewFilter } from '@/metadata-store/types/FlatViewFilter';
import { type FlatViewFilterGroup } from '@/metadata-store/types/FlatViewFilterGroup';
import { type FlatViewSort } from '@/metadata-store/types/FlatViewSort';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
@@ -38,16 +41,43 @@ export const useInitializeRecordTableWidgetViewDraft = ({
return;
}
const { viewFields, ...viewProps } = view;
const {
viewFields,
viewFilters,
viewFilterGroups,
viewSorts,
viewGroups: _viewGroups,
...viewProps
} = view;
const flatViewFields: FlatViewField[] = viewFields.map((field) => ({
...field,
viewId: view.id,
}));
const flatViewFilters: FlatViewFilter[] = viewFilters.map((filter) => ({
...filter,
viewId: view.id,
}));
const flatViewFilterGroups: FlatViewFilterGroup[] = (
viewFilterGroups ?? []
).map((group) => ({
...group,
viewId: view.id,
}));
const flatViewSorts: FlatViewSort[] = viewSorts.map((sort) => ({
...sort,
viewId: view.id,
}));
const snapshot: RecordTableWidgetViewSnapshot = {
view: viewProps,
viewFields: flatViewFields,
viewFilters: flatViewFilters,
viewFilterGroups: flatViewFilterGroups,
viewSorts: flatViewSorts,
};
store.set(recordTableWidgetViewDraftState, (prev) => ({
@@ -1,36 +0,0 @@
import { type RecordField } from '@/object-record/record-field/types/RecordField';
import { viewFieldAggregateOperationState } from '@/object-record/record-table/record-table-footer/states/viewFieldAggregateOperationState';
import { convertExtendedAggregateOperationToAggregateOperation } from '@/object-record/utils/convertExtendedAggregateOperationToAggregateOperation';
import { type ViewField } from '@/views/types/ViewField';
import { mapRecordFieldToViewField } from '@/views/utils/mapRecordFieldToViewField';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useMapRecordFieldToViewFieldWithCurrentAggregateOperation = () => {
const store = useStore();
const mapRecordFieldToViewFieldWithCurrentAggregateOperation = useCallback(
(recordField: RecordField): Omit<ViewField, 'definition'> => {
const viewField = mapRecordFieldToViewField(recordField);
const extendedAggregateOperation = store.get(
viewFieldAggregateOperationState.atomFamily({
viewFieldId: recordField.id,
}),
);
return {
...viewField,
aggregateOperation: isDefined(extendedAggregateOperation)
? convertExtendedAggregateOperationToAggregateOperation(
extendedAggregateOperation,
)
: viewField.aggregateOperation,
};
},
[store],
);
return { mapRecordFieldToViewFieldWithCurrentAggregateOperation };
};
@@ -0,0 +1,81 @@
import { type RecordField } from '@/object-record/record-field/types/RecordField';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
type UseRecordTableWidgetFieldCallbacksParams = {
pageLayoutId: string;
widgetId: string;
viewId: string;
};
export const useRecordTableWidgetFieldCallbacks = ({
pageLayoutId,
widgetId,
viewId,
}: UseRecordTableWidgetFieldCallbacksParams) => {
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
pageLayoutId,
);
const store = useStore();
const handleFieldUpdated = (
viewFieldId: string,
update: Partial<{ position: number; isVisible: boolean }>,
) => {
store.set(recordTableWidgetViewDraftState, (prev) => {
const widgetViewDraft = prev[widgetId];
if (!isDefined(widgetViewDraft)) {
return prev;
}
return {
...prev,
[widgetId]: {
...widgetViewDraft,
viewFields: widgetViewDraft.viewFields.map((field) =>
field.id === viewFieldId ||
field.clientRecordFieldId === viewFieldId
? { ...field, ...update }
: field,
),
},
};
});
};
const handleFieldCreated = (recordField: RecordField) => {
store.set(recordTableWidgetViewDraftState, (prev) => {
const widgetViewDraft = prev[widgetId];
if (!isDefined(widgetViewDraft)) {
return prev;
}
return {
...prev,
[widgetId]: {
...widgetViewDraft,
viewFields: [
...widgetViewDraft.viewFields,
{
fieldMetadataId: recordField.fieldMetadataItemId,
position: recordField.position,
isVisible: recordField.isVisible,
size: recordField.size,
isActive: true,
viewId,
clientRecordFieldId: recordField.id,
},
],
},
};
});
};
return { handleFieldUpdated, handleFieldCreated };
};
@@ -0,0 +1,77 @@
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { mapRecordFilterGroupToViewFilterGroup } from '@/views/utils/mapRecordFilterGroupToViewFilterGroup';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
type UseRecordTableWidgetFilterCallbacksParams = {
pageLayoutId: string;
widgetId: string;
viewId: string;
recordIndexId: string;
};
export const useRecordTableWidgetFilterCallbacks = ({
pageLayoutId,
widgetId,
viewId,
recordIndexId,
}: UseRecordTableWidgetFilterCallbacksParams) => {
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
pageLayoutId,
);
const currentRecordFiltersState = useAtomComponentStateCallbackState(
currentRecordFiltersComponentState,
recordIndexId,
);
const currentRecordFilterGroupsState = useAtomComponentStateCallbackState(
currentRecordFilterGroupsComponentState,
recordIndexId,
);
const store = useStore();
const handleFilterUpdate = () => {
const currentRecordFilters = store.get(currentRecordFiltersState);
const currentRecordFilterGroups = store.get(currentRecordFilterGroupsState);
store.set(recordTableWidgetViewDraftState, (prev) => {
const widgetViewDraft = prev[widgetId];
if (!isDefined(widgetViewDraft)) {
return prev;
}
return {
...prev,
[widgetId]: {
...widgetViewDraft,
viewFilters: currentRecordFilters.map((recordFilter) => ({
id: recordFilter.id,
fieldMetadataId: recordFilter.fieldMetadataId,
operand: recordFilter.operand,
value: recordFilter.value,
viewId,
viewFilterGroupId: recordFilter.recordFilterGroupId ?? null,
positionInViewFilterGroup:
recordFilter.positionInRecordFilterGroup ?? null,
subFieldName: recordFilter.subFieldName ?? null,
})),
viewFilterGroups: currentRecordFilterGroups.map((recordFilterGroup) =>
mapRecordFilterGroupToViewFilterGroup({
recordFilterGroup,
view: { id: viewId },
}),
),
},
};
});
};
return { handleFilterUpdate };
};
@@ -0,0 +1,58 @@
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
type UseRecordTableWidgetSortCallbacksParams = {
pageLayoutId: string;
widgetId: string;
viewId: string;
recordIndexId: string;
};
export const useRecordTableWidgetSortCallbacks = ({
pageLayoutId,
widgetId,
viewId,
recordIndexId,
}: UseRecordTableWidgetSortCallbacksParams) => {
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
pageLayoutId,
);
const currentRecordSortsState = useAtomComponentStateCallbackState(
currentRecordSortsComponentState,
recordIndexId,
);
const store = useStore();
const handleSortUpdate = () => {
const currentRecordSorts = store.get(currentRecordSortsState);
store.set(recordTableWidgetViewDraftState, (prev) => {
const widgetViewDraft = prev[widgetId];
if (!isDefined(widgetViewDraft)) {
return prev;
}
return {
...prev,
[widgetId]: {
...widgetViewDraft,
viewSorts: currentRecordSorts.map((recordSort) => ({
id: recordSort.id,
fieldMetadataId: recordSort.fieldMetadataId,
direction: recordSort.direction,
viewId,
})),
},
};
});
};
return { handleSortUpdate };
};
@@ -1,5 +1,6 @@
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { type RecordTableWidgetViewFieldItem } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewFieldItem';
import { getRecordTableWidgetDraftViewFieldClientId } from '@/page-layout/widgets/record-table/utils/getRecordTableWidgetDraftViewFieldClientId';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
@@ -54,7 +55,10 @@ export const useReorderRecordTableWidgetFields = ({
[widgetId]: {
...widgetViewDraft,
viewFields: widgetViewDraft.viewFields.map((field) => {
const newPosition = updatedPositions.get(field.id);
const newPosition = updatedPositions.get(
getRecordTableWidgetDraftViewFieldClientId(field),
);
return newPosition !== undefined
? { ...field, position: newPosition }
: field;
@@ -1,162 +0,0 @@
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { usePerformViewFilterAPIPersist } from '@/views/hooks/internal/usePerformViewFilterAPIPersist';
import { usePerformViewFilterGroupAPIPersist } from '@/views/hooks/internal/usePerformViewFilterGroupAPIPersist';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { getViewFilterGroupsToCreate } from '@/views/utils/getViewFilterGroupsToCreate';
import { getViewFilterGroupsToDelete } from '@/views/utils/getViewFilterGroupsToDelete';
import { getViewFilterGroupsToUpdate } from '@/views/utils/getViewFilterGroupsToUpdate';
import { getViewFiltersToCreate } from '@/views/utils/getViewFiltersToCreate';
import { getViewFiltersToDelete } from '@/views/utils/getViewFiltersToDelete';
import { getViewFiltersToUpdate } from '@/views/utils/getViewFiltersToUpdate';
import { mapRecordFilterGroupToViewFilterGroup } from '@/views/utils/mapRecordFilterGroupToViewFilterGroup';
import { mapRecordFilterToViewFilter } from '@/views/utils/mapRecordFilterToViewFilter';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useSaveRecordTableWidgetFiltersToView = (
viewId: string,
instanceId: string,
) => {
const {
performViewFilterAPICreate,
performViewFilterAPIUpdate,
performViewFilterAPIDelete,
} = usePerformViewFilterAPIPersist();
const {
performViewFilterGroupAPICreate,
performViewFilterGroupAPIUpdate,
performViewFilterGroupAPIDestroy,
} = usePerformViewFilterGroupAPIPersist();
const currentRecordFiltersCallbackState = useAtomComponentStateCallbackState(
currentRecordFiltersComponentState,
instanceId,
);
const currentRecordFilterGroupsCallbackState =
useAtomComponentStateCallbackState(
currentRecordFilterGroupsComponentState,
instanceId,
);
const store = useStore();
const saveRecordTableWidgetFiltersToView = useCallback(async () => {
const views = store.get(viewsSelector.atom);
const currentView = views.find((view) => view.id === viewId);
if (!isDefined(currentView)) {
return;
}
const currentRecordFilters = store.get(currentRecordFiltersCallbackState);
const currentRecordFilterGroups = store.get(
currentRecordFilterGroupsCallbackState,
);
const newViewFilterGroups = currentRecordFilterGroups.map(
(recordFilterGroup) =>
mapRecordFilterGroupToViewFilterGroup({
recordFilterGroup,
view: currentView,
}),
);
const currentViewFilterGroups = currentView.viewFilterGroups ?? [];
const viewFilterGroupsToCreate = getViewFilterGroupsToCreate(
currentViewFilterGroups,
newViewFilterGroups,
);
const viewFilterGroupsToUpdate = getViewFilterGroupsToUpdate(
currentViewFilterGroups,
newViewFilterGroups,
);
const viewFilterGroupsToDelete = getViewFilterGroupsToDelete(
currentViewFilterGroups,
newViewFilterGroups,
);
await performViewFilterGroupAPICreate(
viewFilterGroupsToCreate,
currentView,
);
await performViewFilterGroupAPIUpdate(viewFilterGroupsToUpdate);
const newViewFilters = currentRecordFilters.map(
mapRecordFilterToViewFilter,
);
const currentViewFilters = currentView.viewFilters ?? [];
const viewFiltersToCreate = getViewFiltersToCreate(
currentViewFilters,
newViewFilters,
);
const viewFiltersToUpdate = getViewFiltersToUpdate(
currentViewFilters,
newViewFilters,
);
const viewFiltersToDelete = getViewFiltersToDelete(
currentViewFilters,
newViewFilters,
);
await performViewFilterAPIDelete(
viewFiltersToDelete.map((viewFilter) => ({
input: { id: viewFilter.id },
})),
);
await performViewFilterAPIUpdate(
viewFiltersToUpdate.map((viewFilter) => ({
input: {
id: viewFilter.id,
update: {
value: viewFilter.value,
operand: viewFilter.operand,
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
viewFilterGroupId: viewFilter.viewFilterGroupId,
subFieldName: viewFilter.subFieldName ?? null,
},
},
})),
);
await performViewFilterAPICreate(
viewFiltersToCreate.map((viewFilter) => ({
input: {
id: viewFilter.id,
fieldMetadataId: viewFilter.fieldMetadataId,
viewId: currentView.id,
value: viewFilter.value,
operand: viewFilter.operand,
viewFilterGroupId: viewFilter.viewFilterGroupId,
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
subFieldName: viewFilter.subFieldName ?? null,
},
})),
);
await performViewFilterGroupAPIDestroy(
viewFilterGroupsToDelete.map((viewFilterGroup) => viewFilterGroup.id),
);
}, [
store,
viewId,
currentRecordFiltersCallbackState,
currentRecordFilterGroupsCallbackState,
performViewFilterAPICreate,
performViewFilterAPIUpdate,
performViewFilterAPIDelete,
performViewFilterGroupAPICreate,
performViewFilterGroupAPIUpdate,
performViewFilterGroupAPIDestroy,
]);
return { saveRecordTableWidgetFiltersToView };
};
@@ -1,86 +0,0 @@
import { type RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { usePerformViewSortAPIPersist } from '@/views/hooks/internal/usePerformViewSortAPIPersist';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { getViewSortsToCreate } from '@/views/utils/getViewSortsToCreate';
import { getViewSortsToDelete } from '@/views/utils/getViewSortsToDelete';
import { getViewSortsToUpdate } from '@/views/utils/getViewSortsToUpdate';
import { mapRecordSortToViewSort } from '@/views/utils/mapRecordSortToViewSort';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useSaveRecordTableWidgetSortsToView = (viewId: string) => {
const {
performViewSortAPICreate,
performViewSortAPIUpdate,
performViewSortAPIDelete,
} = usePerformViewSortAPIPersist();
const store = useStore();
const saveRecordTableWidgetSortsToView = useCallback(
async (recordSorts: RecordSort[]) => {
const views = store.get(viewsSelector.atom);
const currentView = views.find((view) => view.id === viewId);
if (!isDefined(currentView)) {
return;
}
const currentViewSorts = currentView.viewSorts ?? [];
const newViewSorts = recordSorts.map(mapRecordSortToViewSort);
const viewSortsToCreate = getViewSortsToCreate(
currentViewSorts,
newViewSorts,
);
const viewSortsToUpdate = getViewSortsToUpdate(
currentViewSorts,
newViewSorts,
);
const viewSortsToDelete = getViewSortsToDelete(
currentViewSorts,
newViewSorts,
);
await performViewSortAPICreate(
viewSortsToCreate.map((viewSort) => ({
input: {
id: viewSort.id,
fieldMetadataId: viewSort.fieldMetadataId,
viewId: currentView.id,
direction: viewSort.direction,
},
})),
);
await performViewSortAPIUpdate(
viewSortsToUpdate.map((viewSort) => ({
input: {
id: viewSort.id,
update: {
direction: viewSort.direction,
},
},
})),
);
await performViewSortAPIDelete(
viewSortsToDelete.map((viewSort) => ({
input: {
id: viewSort.id,
},
})),
);
},
[
store,
viewId,
performViewSortAPICreate,
performViewSortAPIUpdate,
performViewSortAPIDelete,
],
);
return { saveRecordTableWidgetSortsToView };
};
@@ -1,319 +0,0 @@
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { currentRecordFieldsComponentState } from '@/object-record/record-field/states/currentRecordFieldsComponentState';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { useMapRecordFieldToViewFieldWithCurrentAggregateOperation } from '@/page-layout/widgets/record-table/hooks/useMapRecordFieldToViewFieldWithCurrentAggregateOperation';
import { computeViewFieldsToCreateAndUpdate } from '@/page-layout/widgets/record-table/utils/computeViewFieldsToCreateAndUpdate';
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
import { usePerformViewFilterAPIPersist } from '@/views/hooks/internal/usePerformViewFilterAPIPersist';
import { usePerformViewFilterGroupAPIPersist } from '@/views/hooks/internal/usePerformViewFilterGroupAPIPersist';
import { usePerformViewSortAPIPersist } from '@/views/hooks/internal/usePerformViewSortAPIPersist';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { getViewFilterGroupsToCreate } from '@/views/utils/getViewFilterGroupsToCreate';
import { getViewFilterGroupsToDelete } from '@/views/utils/getViewFilterGroupsToDelete';
import { getViewFilterGroupsToUpdate } from '@/views/utils/getViewFilterGroupsToUpdate';
import { getViewFiltersToCreate } from '@/views/utils/getViewFiltersToCreate';
import { getViewFiltersToDelete } from '@/views/utils/getViewFiltersToDelete';
import { getViewFiltersToUpdate } from '@/views/utils/getViewFiltersToUpdate';
import { getViewSortsToCreate } from '@/views/utils/getViewSortsToCreate';
import { getViewSortsToDelete } from '@/views/utils/getViewSortsToDelete';
import { getViewSortsToUpdate } from '@/views/utils/getViewSortsToUpdate';
import { mapRecordFilterGroupToViewFilterGroup } from '@/views/utils/mapRecordFilterGroupToViewFilterGroup';
import { mapRecordFilterToViewFilter } from '@/views/utils/mapRecordFilterToViewFilter';
import { mapRecordSortToViewSort } from '@/views/utils/mapRecordSortToViewSort';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
export const useSaveRecordTableWidgetsViewDataOnDashboardSave = () => {
const {
performViewFilterAPICreate,
performViewFilterAPIUpdate,
performViewFilterAPIDelete,
} = usePerformViewFilterAPIPersist();
const {
performViewFilterGroupAPICreate,
performViewFilterGroupAPIUpdate,
performViewFilterGroupAPIDestroy,
} = usePerformViewFilterGroupAPIPersist();
const {
performViewSortAPICreate,
performViewSortAPIUpdate,
performViewSortAPIDelete,
} = usePerformViewSortAPIPersist();
const { performViewFieldAPICreate, performViewFieldAPIUpdate } =
usePerformViewFieldAPIPersist();
const { mapRecordFieldToViewFieldWithCurrentAggregateOperation } =
useMapRecordFieldToViewFieldWithCurrentAggregateOperation();
const store = useStore();
const saveRecordTableWidgetsViewDataOnDashboardSave = useCallback(
async (pageLayoutId: string) => {
const pageLayoutDraft = store.get(
pageLayoutDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const views = store.get(viewsSelector.atom);
const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);
const recordTableWidgetViewDraft = store.get(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const recordTableWidgets = pageLayoutDraft.tabs.flatMap((tab) =>
tab.widgets.filter(
(widget) =>
widget.configuration.configurationType ===
WidgetConfigurationType.RECORD_TABLE,
),
);
for (const widget of recordTableWidgets) {
const configuration = widget.configuration;
if (!('viewId' in configuration) || !isDefined(configuration.viewId)) {
continue;
}
const viewId = configuration.viewId as string;
const currentView = views.find((view) => view.id === viewId);
if (!isDefined(currentView) || !isDefined(widget.objectMetadataId)) {
continue;
}
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === widget.objectMetadataId,
);
if (!isDefined(objectMetadataItem)) {
continue;
}
const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId(
objectMetadataItem.namePlural,
viewId,
);
const currentRecordFilters = store.get(
currentRecordFiltersComponentState.atomFamily({
instanceId: recordIndexId,
}),
);
const currentRecordFilterGroups = store.get(
currentRecordFilterGroupsComponentState.atomFamily({
instanceId: recordIndexId,
}),
);
const currentRecordSorts = store.get(
currentRecordSortsComponentState.atomFamily({
instanceId: recordIndexId,
}),
);
const newViewFilterGroups = currentRecordFilterGroups.map(
(recordFilterGroup) =>
mapRecordFilterGroupToViewFilterGroup({
recordFilterGroup,
view: currentView,
}),
);
const existingViewFilterGroups = currentView.viewFilterGroups ?? [];
const viewFilterGroupsToCreate = getViewFilterGroupsToCreate(
existingViewFilterGroups,
newViewFilterGroups,
);
const viewFilterGroupsToUpdate = getViewFilterGroupsToUpdate(
existingViewFilterGroups,
newViewFilterGroups,
);
const viewFilterGroupsToDelete = getViewFilterGroupsToDelete(
existingViewFilterGroups,
newViewFilterGroups,
);
const newViewFilters = currentRecordFilters.map(
mapRecordFilterToViewFilter,
);
const existingViewFilters = currentView.viewFilters ?? [];
const viewFiltersToCreate = getViewFiltersToCreate(
existingViewFilters,
newViewFilters,
);
const viewFiltersToUpdate = getViewFiltersToUpdate(
existingViewFilters,
newViewFilters,
);
const viewFiltersToDelete = getViewFiltersToDelete(
existingViewFilters,
newViewFilters,
);
const newViewSorts = currentRecordSorts.map(mapRecordSortToViewSort);
const existingViewSorts = currentView.viewSorts ?? [];
const viewSortsToCreate = getViewSortsToCreate(
existingViewSorts,
newViewSorts,
);
const viewSortsToUpdate = getViewSortsToUpdate(
existingViewSorts,
newViewSorts,
);
const viewSortsToDelete = getViewSortsToDelete(
existingViewSorts,
newViewSorts,
);
const currentRecordFields = store.get(
currentRecordFieldsComponentState.atomFamily({
instanceId: recordIndexId,
}),
);
const recordIndexViewFields = currentRecordFields.map(
mapRecordFieldToViewFieldWithCurrentAggregateOperation,
);
const draftSnapshot = recordTableWidgetViewDraft[widget.id];
const draftViewFields = draftSnapshot?.viewFields ?? [];
const metadataStoreViewFields =
draftViewFields.length > 0
? draftViewFields
: (currentView.viewFields ?? []);
const { viewFieldsToCreate, viewFieldsToUpdate } =
computeViewFieldsToCreateAndUpdate({
newViewFields: metadataStoreViewFields,
existingViewFields: recordIndexViewFields,
viewId,
});
const hasNoRecordTableWidgetChanges =
viewFilterGroupsToCreate.length === 0 &&
viewFilterGroupsToUpdate.length === 0 &&
viewFilterGroupsToDelete.length === 0 &&
viewFiltersToCreate.length === 0 &&
viewFiltersToUpdate.length === 0 &&
viewFiltersToDelete.length === 0 &&
viewSortsToCreate.length === 0 &&
viewSortsToUpdate.length === 0 &&
viewSortsToDelete.length === 0 &&
viewFieldsToCreate.length === 0 &&
viewFieldsToUpdate.length === 0;
if (hasNoRecordTableWidgetChanges) {
continue;
}
await performViewFilterGroupAPICreate(
viewFilterGroupsToCreate,
currentView,
);
await performViewFilterGroupAPIUpdate(viewFilterGroupsToUpdate);
await performViewFilterAPIDelete(
viewFiltersToDelete.map((viewFilter) => ({
input: { id: viewFilter.id },
})),
);
await performViewFilterAPIUpdate(
viewFiltersToUpdate.map((viewFilter) => ({
input: {
id: viewFilter.id,
update: {
value: viewFilter.value,
operand: viewFilter.operand,
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
viewFilterGroupId: viewFilter.viewFilterGroupId,
subFieldName: viewFilter.subFieldName ?? null,
},
},
})),
);
await performViewFilterAPICreate(
viewFiltersToCreate.map((viewFilter) => ({
input: {
id: viewFilter.id,
fieldMetadataId: viewFilter.fieldMetadataId,
viewId: currentView.id,
value: viewFilter.value,
operand: viewFilter.operand,
viewFilterGroupId: viewFilter.viewFilterGroupId,
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
subFieldName: viewFilter.subFieldName ?? null,
},
})),
);
await performViewFilterGroupAPIDestroy(
viewFilterGroupsToDelete.map((viewFilterGroup) => viewFilterGroup.id),
);
await performViewSortAPICreate(
viewSortsToCreate.map((viewSort) => ({
input: {
id: viewSort.id,
fieldMetadataId: viewSort.fieldMetadataId,
viewId: currentView.id,
direction: viewSort.direction,
},
})),
);
await performViewSortAPIUpdate(
viewSortsToUpdate.map((viewSort) => ({
input: {
id: viewSort.id,
update: {
direction: viewSort.direction,
},
},
})),
);
await performViewSortAPIDelete(
viewSortsToDelete.map((viewSort) => ({
input: { id: viewSort.id },
})),
);
await Promise.all([
performViewFieldAPICreate({ inputs: viewFieldsToCreate }),
performViewFieldAPIUpdate(viewFieldsToUpdate),
]);
}
},
[
store,
mapRecordFieldToViewFieldWithCurrentAggregateOperation,
performViewFieldAPICreate,
performViewFieldAPIUpdate,
performViewFilterAPICreate,
performViewFilterAPIUpdate,
performViewFilterAPIDelete,
performViewFilterGroupAPICreate,
performViewFilterGroupAPIUpdate,
performViewFilterGroupAPIDestroy,
performViewSortAPICreate,
performViewSortAPIUpdate,
performViewSortAPIDelete,
],
);
return { saveRecordTableWidgetsViewDataOnDashboardSave };
};
@@ -34,7 +34,10 @@ export const useToggleRecordTableWidgetFieldVisibility = ({
[widgetId]: {
...widgetViewDraft,
viewFields: widgetViewDraft.viewFields.map((field) =>
field.id === viewFieldId ? { ...field, isVisible } : field,
field.id === viewFieldId ||
field.clientRecordFieldId === viewFieldId
? { ...field, isVisible }
: field,
),
},
};
@@ -1,7 +1,18 @@
import { type FlatView } from '@/metadata-store/types/FlatView';
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
import { type FlatViewFilter } from '@/metadata-store/types/FlatViewFilter';
import { type FlatViewFilterGroup } from '@/metadata-store/types/FlatViewFilterGroup';
import { type FlatViewSort } from '@/metadata-store/types/FlatViewSort';
export type RecordTableWidgetDraftViewField = Omit<FlatViewField, 'id'> & {
id?: string;
clientRecordFieldId?: string;
};
export type RecordTableWidgetViewSnapshot = {
view: FlatView;
viewFields: FlatViewField[];
viewFields: RecordTableWidgetDraftViewField[];
viewFilters: FlatViewFilter[];
viewFilterGroups: FlatViewFilterGroup[];
viewSorts: FlatViewSort[];
};
@@ -1,208 +0,0 @@
import { v4 } from 'uuid';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { computeViewFieldsToCreateAndUpdate } from '@/page-layout/widgets/record-table/utils/computeViewFieldsToCreateAndUpdate';
import { type ViewField } from '@/views/types/ViewField';
const buildViewField = (
overrides: Partial<Omit<ViewField, 'definition'>>,
): Omit<ViewField, 'definition'> => ({
id: v4(),
fieldMetadataId: v4(),
position: 0,
isActive: true,
isVisible: true,
size: 100,
aggregateOperation: null,
...overrides,
});
const buildExistingViewField = (overrides: Partial<ViewField>): ViewField => ({
id: v4(),
fieldMetadataId: v4(),
position: 0,
isActive: true,
isVisible: true,
size: 100,
aggregateOperation: null,
...overrides,
});
describe('computeViewFieldsToCreateAndUpdate', () => {
it('adds new fields with no matching fieldMetadataId to viewFieldsToCreate', () => {
const viewId = v4();
const newViewField = buildViewField({
position: 0,
isVisible: true,
size: 150,
});
const result = computeViewFieldsToCreateAndUpdate({
newViewFields: [newViewField],
existingViewFields: [],
viewId,
});
expect(result.viewFieldsToCreate).toHaveLength(1);
expect(result.viewFieldsToCreate[0]).toEqual({
id: newViewField.id,
fieldMetadataId: newViewField.fieldMetadataId,
position: newViewField.position,
isVisible: newViewField.isVisible,
size: newViewField.size,
aggregateOperation: newViewField.aggregateOperation,
viewId,
});
expect(result.viewFieldsToUpdate).toHaveLength(0);
});
it('adds existing fields with changed properties to viewFieldsToUpdate', () => {
const viewId = v4();
const fieldMetadataId = v4();
const existingFieldId = v4();
const existingViewField = buildExistingViewField({
id: existingFieldId,
fieldMetadataId,
position: 0,
isVisible: true,
size: 100,
aggregateOperation: null,
});
const newViewField = buildViewField({
fieldMetadataId,
position: 2,
isVisible: false,
size: 200,
aggregateOperation: AggregateOperations.COUNT,
});
const result = computeViewFieldsToCreateAndUpdate({
newViewFields: [newViewField],
existingViewFields: [existingViewField],
viewId,
});
expect(result.viewFieldsToCreate).toHaveLength(0);
expect(result.viewFieldsToUpdate).toHaveLength(1);
expect(result.viewFieldsToUpdate[0]).toEqual({
input: {
id: existingFieldId,
update: {
isVisible: false,
position: 2,
size: 200,
aggregateOperation: AggregateOperations.COUNT,
},
},
});
});
it('skips existing fields with identical properties', () => {
const viewId = v4();
const fieldMetadataId = v4();
const existingViewField = buildExistingViewField({
fieldMetadataId,
position: 1,
isVisible: true,
size: 120,
aggregateOperation: AggregateOperations.SUM,
});
const newViewField = buildViewField({
fieldMetadataId,
position: 1,
isVisible: true,
size: 120,
aggregateOperation: AggregateOperations.SUM,
});
const result = computeViewFieldsToCreateAndUpdate({
newViewFields: [newViewField],
existingViewFields: [existingViewField],
viewId,
});
expect(result.viewFieldsToCreate).toHaveLength(0);
expect(result.viewFieldsToUpdate).toHaveLength(0);
});
it('returns empty arrays when newViewFields is empty', () => {
const viewId = v4();
const existingViewField = buildExistingViewField({});
const result = computeViewFieldsToCreateAndUpdate({
newViewFields: [],
existingViewFields: [existingViewField],
viewId,
});
expect(result.viewFieldsToCreate).toHaveLength(0);
expect(result.viewFieldsToUpdate).toHaveLength(0);
});
it('handles a mix of creates and updates in a single call', () => {
const viewId = v4();
const existingFieldMetadataId = v4();
const newFieldMetadataId = v4();
const existingFieldId = v4();
const existingViewField = buildExistingViewField({
id: existingFieldId,
fieldMetadataId: existingFieldMetadataId,
position: 0,
isVisible: true,
size: 100,
aggregateOperation: null,
});
const updatedNewViewField = buildViewField({
fieldMetadataId: existingFieldMetadataId,
position: 5,
isVisible: false,
size: 100,
aggregateOperation: null,
});
const brandNewViewField = buildViewField({
fieldMetadataId: newFieldMetadataId,
position: 1,
isVisible: true,
size: 80,
aggregateOperation: AggregateOperations.MIN,
});
const result = computeViewFieldsToCreateAndUpdate({
newViewFields: [updatedNewViewField, brandNewViewField],
existingViewFields: [existingViewField],
viewId,
});
expect(result.viewFieldsToCreate).toHaveLength(1);
expect(result.viewFieldsToCreate[0]).toEqual({
id: brandNewViewField.id,
fieldMetadataId: newFieldMetadataId,
position: 1,
isVisible: true,
size: 80,
aggregateOperation: AggregateOperations.MIN,
viewId,
});
expect(result.viewFieldsToUpdate).toHaveLength(1);
expect(result.viewFieldsToUpdate[0]).toEqual({
input: {
id: existingFieldId,
update: {
isVisible: false,
position: 5,
size: 100,
aggregateOperation: null,
},
},
});
});
});
@@ -1,87 +0,0 @@
import { type ViewField } from '@/views/types/ViewField';
import { isDefined } from 'twenty-shared/utils';
import {
type CreateViewFieldInput,
type UpdateViewFieldMutationVariables,
} from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
type ViewFieldsToCreateAndUpdate = {
viewFieldsToCreate: CreateViewFieldInput[];
viewFieldsToUpdate: UpdateViewFieldMutationVariables[];
};
export const computeViewFieldsToCreateAndUpdate = ({
newViewFields,
existingViewFields,
viewId,
}: {
newViewFields: Omit<ViewField, 'definition'>[];
existingViewFields: ViewField[];
viewId: string;
}): ViewFieldsToCreateAndUpdate => {
return newViewFields.reduce<ViewFieldsToCreateAndUpdate>(
(accumulator, newViewField) => {
const existingViewField = existingViewFields.find(
(existingField) =>
existingField.fieldMetadataId === newViewField.fieldMetadataId,
);
if (!isDefined(existingViewField)) {
return {
viewFieldsToCreate: [
...accumulator.viewFieldsToCreate,
{
id: newViewField.id,
fieldMetadataId: newViewField.fieldMetadataId,
position: newViewField.position,
isVisible: newViewField.isVisible,
size: newViewField.size,
aggregateOperation: newViewField.aggregateOperation,
viewId,
},
],
viewFieldsToUpdate: accumulator.viewFieldsToUpdate,
};
}
if (
isDeeplyEqual(
{
position: existingViewField.position,
size: existingViewField.size,
isVisible: existingViewField.isVisible,
aggregateOperation: existingViewField.aggregateOperation,
},
{
position: newViewField.position,
size: newViewField.size,
isVisible: newViewField.isVisible,
aggregateOperation: newViewField.aggregateOperation,
},
)
) {
return accumulator;
}
return {
viewFieldsToCreate: accumulator.viewFieldsToCreate,
viewFieldsToUpdate: [
...accumulator.viewFieldsToUpdate,
{
input: {
id: existingViewField.id,
update: {
isVisible: newViewField.isVisible,
position: newViewField.position,
size: newViewField.size,
aggregateOperation: newViewField.aggregateOperation,
},
},
},
],
};
},
{ viewFieldsToCreate: [], viewFieldsToUpdate: [] },
);
};
@@ -1,13 +1,22 @@
import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
import { getRecordTableWidgetDraftViewFieldClientId } from '@/page-layout/widgets/record-table/utils/getRecordTableWidgetDraftViewFieldClientId';
import { type View } from '@/views/types/View';
export const constructViewFromRecordTableWidgetViewSnapshot = (
snapshot: RecordTableWidgetViewSnapshot,
): View => ({
...snapshot.view,
viewFields: snapshot.viewFields,
viewFilters: [],
viewSorts: [],
viewFields: snapshot.viewFields.map((draftField) => {
const { clientRecordFieldId: _clientRecordFieldId, ...fieldRest } =
draftField;
return {
...fieldRest,
id: getRecordTableWidgetDraftViewFieldClientId(draftField),
};
}),
viewFilters: snapshot.viewFilters,
viewFilterGroups: snapshot.viewFilterGroups,
viewSorts: snapshot.viewSorts,
viewGroups: [],
viewFilterGroups: [],
});
@@ -0,0 +1,21 @@
import { type RecordTableWidgetDraftViewField } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
import { isDefined } from 'twenty-shared/utils';
type RecordTableWidgetDraftViewFieldIdentity = Pick<
RecordTableWidgetDraftViewField,
'id' | 'clientRecordFieldId'
>;
export const getRecordTableWidgetDraftViewFieldClientId = (
field: RecordTableWidgetDraftViewFieldIdentity,
): string => {
if (isDefined(field.id)) {
return field.id;
}
if (isDefined(field.clientRecordFieldId)) {
return field.clientRecordFieldId;
}
return '';
};
@@ -1,5 +1,6 @@
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { CommandMenuItemDropdown } from '@/command-menu/components/CommandMenuItemDropdown';
import { useRecordTableWidgetFieldCallbacks } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetFieldCallbacks';
import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
@@ -65,6 +66,13 @@ export const SidePanelDashboardRecordTableSettings = () => {
viewId,
});
const { handleFieldUpdated, handleFieldCreated } =
useRecordTableWidgetFieldCallbacks({
pageLayoutId,
widgetId: widgetInEditMode?.id ?? '',
viewId: viewId ?? '',
});
if (!isDefined(widgetInEditMode)) {
return null;
}
@@ -136,6 +144,8 @@ export const SidePanelDashboardRecordTableSettings = () => {
<RecordTableFieldsDropdownContent
viewId={viewId}
objectMetadataId={widgetInEditMode.objectMetadataId!}
onFieldUpdated={handleFieldUpdated}
onFieldCreated={handleFieldCreated}
/>
}
dropdownPlacement="bottom-end"
@@ -1,4 +1,5 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { type RecordField } from '@/object-record/record-field/types/RecordField';
import { RecordFieldsComponentInstanceContext } from '@/object-record/record-field/states/context/RecordFieldsComponentInstanceContext';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { RecordTableFieldsDropdownHiddenFieldsContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableFieldsDropdownHiddenFieldsContent';
@@ -8,11 +9,18 @@ import { useState } from 'react';
type RecordTableFieldsDropdownContentProps = {
viewId: string;
objectMetadataId: string;
onFieldUpdated?: (
viewFieldId: string,
update: Partial<{ position: number; isVisible: boolean }>,
) => void;
onFieldCreated?: (recordField: RecordField) => void;
};
export const RecordTableFieldsDropdownContent = ({
viewId,
objectMetadataId,
onFieldUpdated,
onFieldCreated,
}: RecordTableFieldsDropdownContentProps) => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
@@ -34,12 +42,15 @@ export const RecordTableFieldsDropdownContent = ({
objectMetadataId={objectMetadataId}
recordIndexId={recordIndexId}
onBack={() => setShowHiddenFields(false)}
onFieldUpdated={onFieldUpdated}
onFieldCreated={onFieldCreated}
/>
) : (
<RecordTableFieldsDropdownVisibleFieldsContent
objectMetadataId={objectMetadataId}
recordIndexId={recordIndexId}
onShowHiddenFields={() => setShowHiddenFields(true)}
onFieldUpdated={onFieldUpdated}
/>
)}
</RecordFieldsComponentInstanceContext.Provider>
@@ -20,12 +20,19 @@ type RecordTableFieldsDropdownHiddenFieldsContentProps = {
objectMetadataId: string;
recordIndexId: string;
onBack: () => void;
onFieldUpdated?: (
viewFieldId: string,
update: Partial<{ isVisible: boolean }>,
) => void;
onFieldCreated?: (recordField: RecordField) => void;
};
export const RecordTableFieldsDropdownHiddenFieldsContent = ({
objectMetadataId,
recordIndexId,
onBack,
onFieldUpdated,
onFieldCreated,
}: RecordTableFieldsDropdownHiddenFieldsContentProps) => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
@@ -61,6 +68,7 @@ export const RecordTableFieldsDropdownHiddenFieldsContent = ({
if (isDefined(existingRecordField)) {
updateRecordField(fieldMetadataId, { isVisible: true });
onFieldUpdated?.(existingRecordField.id, { isVisible: true });
} else {
const lastPosition =
currentRecordFields.toSorted(sortByProperty('position', 'desc'))?.[0]
@@ -75,6 +83,7 @@ export const RecordTableFieldsDropdownHiddenFieldsContent = ({
};
upsertRecordField(newRecordField);
onFieldCreated?.(newRecordField);
}
};
@@ -21,12 +21,17 @@ type RecordTableFieldsDropdownVisibleFieldsContentProps = {
objectMetadataId: string;
recordIndexId: string;
onShowHiddenFields: () => void;
onFieldUpdated?: (
viewFieldId: string,
update: Partial<{ position: number; isVisible: boolean }>,
) => void;
};
export const RecordTableFieldsDropdownVisibleFieldsContent = ({
objectMetadataId,
recordIndexId,
onShowHiddenFields,
onFieldUpdated,
}: RecordTableFieldsDropdownVisibleFieldsContentProps) => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
@@ -71,14 +76,24 @@ export const RecordTableFieldsDropdownVisibleFieldsContent = ({
return;
}
reorderVisibleRecordFields({
const updatedField = reorderVisibleRecordFields({
fromIndex: result.source.index - 1,
toIndex: result.destination.index - 1,
});
if (isDefined(updatedField)) {
onFieldUpdated?.(updatedField.id, { position: updatedField.position });
}
};
const handleHideField = (fieldMetadataId: string) => {
updateRecordField(fieldMetadataId, { isVisible: false });
const updatedField = updateRecordField(fieldMetadataId, {
isVisible: false,
});
if (isDefined(updatedField)) {
onFieldUpdated?.(updatedField.id, { isVisible: false });
}
};
return (
@@ -3,6 +3,7 @@ import { AdvancedFilterSidePanelContainer } from '@/object-record/advanced-filte
import { RecordFilterGroupsComponentInstanceContext } from '@/object-record/record-filter-group/states/context/RecordFilterGroupsComponentInstanceContext';
import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { useRecordTableWidgetFilterCallbacks } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetFilterCallbacks';
import { useRecordTableWidgetViewForDisplay } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetViewForDisplay';
import { RecordTableSettingsFiltersInitializeStateEffect } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsFiltersInitializeStateEffect';
import { InputLabel } from '@/ui/input/components/InputLabel';
@@ -45,6 +46,13 @@ export const RecordTableSettingsFilters = ({
viewId,
);
const { handleFilterUpdate } = useRecordTableWidgetFilterCallbacks({
pageLayoutId,
widgetId,
viewId,
recordIndexId,
});
if (!isDefined(view)) {
return null;
}
@@ -61,6 +69,7 @@ export const RecordTableSettingsFilters = ({
<AdvancedFilterSidePanelContainer
objectMetadataItem={objectMetadataItem}
isWorkflowFindRecords={false}
onUpdate={handleFilterUpdate}
/>
<RecordTableSettingsFiltersInitializeStateEffect
view={view}
@@ -3,6 +3,7 @@ import { filterSortableFieldMetadataItems } from '@/object-metadata/utils/filter
import { RecordSortsComponentInstanceContext } from '@/object-record/record-sort/states/context/RecordSortsComponentInstanceContext';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { useRecordTableWidgetViewForDisplay } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetViewForDisplay';
import { useRecordTableWidgetSortCallbacks } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetSortCallbacks';
import { RecordTableSettingsSortsContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsSortsContent';
import { RecordTableSettingsSortsInitializeStateEffect } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsSortsInitializeStateEffect';
import { InputLabel } from '@/ui/input/components/InputLabel';
@@ -50,6 +51,13 @@ export const RecordTableSettingsSorts = ({
viewId,
);
const { handleSortUpdate } = useRecordTableWidgetSortCallbacks({
pageLayoutId,
widgetId,
viewId,
recordIndexId,
});
if (!isDefined(view)) {
return null;
}
@@ -82,6 +90,7 @@ export const RecordTableSettingsSorts = ({
<RecordTableSettingsSortsContent
sortableFieldOptions={sortableFieldOptions}
directionOptions={directionOptions}
onUpdate={handleSortUpdate}
/>
<RecordTableSettingsSortsInitializeStateEffect view={view} />
</RecordSortsComponentInstanceContext.Provider>
@@ -24,11 +24,13 @@ const StyledAddButtonContainer = styled.div`
type RecordTableSettingsSortsContentProps = {
sortableFieldOptions: Array<SelectOption<string>>;
directionOptions: Array<SelectOption<ViewSortDirection>>;
onUpdate?: () => void;
};
export const RecordTableSettingsSortsContent = ({
sortableFieldOptions,
directionOptions,
onUpdate,
}: RecordTableSettingsSortsContentProps) => {
const [currentRecordSorts, setCurrentRecordSorts] = useAtomComponentState(
currentRecordSortsComponentState,
@@ -48,12 +50,14 @@ export const RecordTableSettingsSortsContent = ({
};
setCurrentRecordSorts([...currentRecordSorts, newSort]);
onUpdate?.();
};
const handleRemoveSort = (sortId: string) => {
setCurrentRecordSorts(
currentRecordSorts.filter((sort) => sort.id !== sortId),
);
onUpdate?.();
};
const handleFieldChange = (sortId: string, fieldMetadataId: string) => {
@@ -62,6 +66,7 @@ export const RecordTableSettingsSortsContent = ({
sort.id === sortId ? { ...sort, fieldMetadataId } : sort,
),
);
onUpdate?.();
};
const handleDirectionChange = (
@@ -73,6 +78,7 @@ export const RecordTableSettingsSortsContent = ({
sort.id === sortId ? { ...sort, direction } : sort,
),
);
onUpdate?.();
};
return (
@@ -1,8 +1,9 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { currentRecordFieldsComponentState } from '@/object-record/record-field/states/currentRecordFieldsComponentState';
import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useViewById } from '@/views/hooks/useViewById';
import { t } from '@lingui/core/macro';
@@ -45,22 +46,20 @@ export const useRecordTableSettingsDescriptions = ({
recordIndexId,
);
const currentRecordFields = useAtomComponentStateValue(
currentRecordFieldsComponentState,
const visibleRecordFields = useAtomComponentSelectorValue(
visibleRecordFieldsComponentSelector,
recordIndexId,
);
const hasViewId = isDefined(viewId);
const activeVisibleFieldLabels: Array<string | undefined> = hasViewId
? currentRecordFields
.filter((field) => field.isVisible)
.map(
(field) =>
sourceObjectMetadataItem?.fields.find(
(metaField) => metaField.id === field.fieldMetadataItemId,
)?.label,
)
? visibleRecordFields.map(
(field) =>
sourceObjectMetadataItem?.fields.find(
(metaField) => metaField.id === field.fieldMetadataItemId,
)?.label,
)
: [];
const activeFilterLabels: Array<string | undefined> =
@@ -0,0 +1,40 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsBoolean, IsNumber, IsOptional, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { AtLeastOneOf } from 'src/engine/metadata-modules/view-field-group/dtos/validators/at-least-one-of.validator';
@InputType()
@AtLeastOneOf(['viewFieldId', 'fieldMetadataId'])
export class UpsertViewWidgetViewFieldInput {
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, {
nullable: true,
description: 'The id of an existing view field to update.',
})
viewFieldId?: string;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, {
nullable: true,
description:
'The field metadata id. Used to create a new view field when viewFieldId is not provided.',
})
fieldMetadataId?: string;
@IsBoolean()
@Field()
isVisible: boolean;
@IsNumber()
@Field()
position: number;
@IsOptional()
@IsNumber()
@Field({ nullable: true })
size?: number;
}
@@ -0,0 +1,30 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNumber, IsOptional, IsUUID } from 'class-validator';
import { ViewFilterGroupLogicalOperator } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpsertViewWidgetViewFilterGroupInput {
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
parentViewFilterGroupId?: string;
@Field(() => ViewFilterGroupLogicalOperator, {
nullable: true,
defaultValue: ViewFilterGroupLogicalOperator.AND,
})
logicalOperator?: ViewFilterGroupLogicalOperator;
@IsOptional()
@IsNumber()
@Field({ nullable: true })
positionInViewFilterGroup?: number;
}
@@ -0,0 +1,51 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsDefined,
IsEnum,
IsNumber,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
import { ViewFilterOperand } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
@InputType()
export class UpsertViewWidgetViewFilterInput {
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@IsUUID()
@Field(() => UUIDScalarType, { nullable: false })
fieldMetadataId: string;
@IsOptional()
@IsEnum(ViewFilterOperand)
@Field({ nullable: true, defaultValue: ViewFilterOperand.CONTAINS })
operand?: ViewFilterOperand;
@IsDefined()
@Field(() => GraphQLJSON, { nullable: false })
value: ViewFilterValue;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
viewFilterGroupId?: string;
@IsOptional()
@IsNumber()
@Field({ nullable: true })
positionInViewFilterGroup?: number;
@IsOptional()
@IsString()
@Field({ nullable: true })
subFieldName?: string;
}
@@ -0,0 +1,26 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsEnum, IsOptional, IsUUID } from 'class-validator';
import { ViewSortDirection } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpsertViewWidgetViewSortInput {
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@IsUUID()
@Field(() => UUIDScalarType, { nullable: false })
fieldMetadataId: string;
@IsOptional()
@IsEnum(ViewSortDirection)
@Field(() => ViewSortDirection, {
nullable: true,
defaultValue: ViewSortDirection.ASC,
})
direction?: ViewSortDirection;
}
@@ -0,0 +1,61 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsNotEmpty,
IsOptional,
IsUUID,
ValidateNested,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { UpsertViewWidgetViewFieldInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-field.input';
import { UpsertViewWidgetViewFilterGroupInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-filter-group.input';
import { UpsertViewWidgetViewFilterInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-filter.input';
import { UpsertViewWidgetViewSortInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-sort.input';
@InputType()
export class UpsertViewWidgetInput {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType, {
description: 'The id of the view widget (page layout widget).',
})
widgetId: string;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => UpsertViewWidgetViewFieldInput)
@Field(() => [UpsertViewWidgetViewFieldInput], {
nullable: true,
description: 'The view fields to upsert.',
})
viewFields?: UpsertViewWidgetViewFieldInput[];
@IsOptional()
@ValidateNested({ each: true })
@Type(() => UpsertViewWidgetViewFilterInput)
@Field(() => [UpsertViewWidgetViewFilterInput], {
nullable: true,
description: 'The view filters to upsert.',
})
viewFilters?: UpsertViewWidgetViewFilterInput[];
@IsOptional()
@ValidateNested({ each: true })
@Type(() => UpsertViewWidgetViewFilterGroupInput)
@Field(() => [UpsertViewWidgetViewFilterGroupInput], {
nullable: true,
description: 'The view filter groups to upsert.',
})
viewFilterGroups?: UpsertViewWidgetViewFilterGroupInput[];
@IsOptional()
@ValidateNested({ each: true })
@Type(() => UpsertViewWidgetViewSortInput)
@Field(() => [UpsertViewWidgetViewSortInput], {
nullable: true,
description: 'The view sorts to upsert.',
})
viewSorts?: UpsertViewWidgetViewSortInput[];
}
@@ -21,6 +21,7 @@ export enum ViewExceptionCode {
INVALID_VIEW_DATA = 'INVALID_VIEW_DATA',
VIEW_CREATE_PERMISSION_DENIED = 'VIEW_CREATE_PERMISSION_DENIED',
VIEW_MODIFY_PERMISSION_DENIED = 'VIEW_MODIFY_PERMISSION_DENIED',
VIEW_WIDGET_NOT_FOUND = 'VIEW_WIDGET_NOT_FOUND',
}
export enum ViewExceptionMessageKey {
@@ -1,4 +1,4 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import {
Args,
Context,
@@ -8,10 +8,12 @@ import {
ResolveField,
} from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ViewType, ViewVisibility } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { type I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -20,6 +22,7 @@ import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-worksp
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
import { ViewFieldGroupDTO } from 'src/engine/metadata-modules/view-field-group/dtos/view-field-group.dto';
@@ -34,7 +37,10 @@ import { UpdateViewPermissionGuard } from 'src/engine/metadata-modules/view-perm
import { ViewSortDTO } from 'src/engine/metadata-modules/view-sort/dtos/view-sort.dto';
import { CreateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/create-view.input';
import { UpdateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/update-view.input';
import { UpsertViewWidgetInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget.input';
import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
import { type ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewWidgetUpsertService } from 'src/engine/metadata-modules/view/services/view-widget-upsert.service';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/utils/view-graphql-api-exception.filter';
@@ -44,6 +50,7 @@ import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/
export class ViewResolver {
constructor(
private readonly viewService: ViewService,
private readonly viewWidgetUpsertService: ViewWidgetUpsertService,
private readonly i18nService: I18nService,
) {}
@@ -197,6 +204,19 @@ export class ViewResolver {
return isDefined(deletedView);
}
@Mutation(() => ViewDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
@UsePipes(ResolverValidationPipe)
async upsertViewWidget(
@Args('input') input: UpsertViewWidgetInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ViewEntity> {
return await this.viewWidgetUpsertService.upsertViewWidget({
input,
workspaceId,
});
}
@ResolveField(() => [ViewFieldDTO])
async viewFields(
@Parent() view: ViewDTO,
@@ -0,0 +1,859 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { t } from '@lingui/core/macro';
import {
ViewFilterGroupLogicalOperator,
ViewFilterOperand,
ViewSortDirection,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util';
import { splitEntitiesByRemovalStrategy } from 'src/engine/metadata-modules/flat-entity/utils/split-entities-by-removal-strategy.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isFlatPageLayoutWidgetConfigurationOfType } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/is-flat-page-layout-widget-configuration-of-type.util';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/metadata-modules/flat-view-field/constants/default-view-field-size.constant';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { fromViewFieldOverridesToUniversalOverrides } from 'src/engine/metadata-modules/flat-view-field/utils/from-view-field-overrides-to-universal-overrides.util';
import { type FlatViewFilterGroupMaps } from 'src/engine/metadata-modules/flat-view-filter-group/types/flat-view-filter-group-maps.type';
import { type FlatViewFilterGroup } from 'src/engine/metadata-modules/flat-view-filter-group/types/flat-view-filter-group.type';
import { type FlatViewFilter } from 'src/engine/metadata-modules/flat-view-filter/types/flat-view-filter.type';
import { type FlatViewSort } from 'src/engine/metadata-modules/flat-view-sort/types/flat-view-sort.type';
import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { isCallerOverridingEntity } from 'src/engine/metadata-modules/utils/is-caller-overriding-entity.util';
import { sanitizeOverridableEntityInput } from 'src/engine/metadata-modules/utils/sanitize-overridable-entity-input.util';
import { type UpsertViewWidgetViewFieldInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-field.input';
import { type UpsertViewWidgetViewFilterGroupInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-filter-group.input';
import { type UpsertViewWidgetViewFilterInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-filter.input';
import { type UpsertViewWidgetViewSortInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-sort.input';
import { type UpsertViewWidgetInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget.input';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import {
ViewException,
ViewExceptionCode,
} from 'src/engine/metadata-modules/view/exceptions/view.exception';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const EMPTY_FIELD_OPS = {
fieldsToCreate: [] as FlatViewField[],
fieldsToUpdate: [] as FlatViewField[],
};
const EMPTY_FILTER_GROUP_OPS = {
filterGroupsToCreate: [] as FlatViewFilterGroup[],
filterGroupsToUpdate: [] as FlatViewFilterGroup[],
filterGroupsToRemove: [] as FlatViewFilterGroup[],
};
const EMPTY_FILTER_OPS = {
filtersToCreate: [] as FlatViewFilter[],
filtersToUpdate: [] as FlatViewFilter[],
filtersToRemove: [] as FlatViewFilter[],
};
const EMPTY_SORT_OPS = {
sortsToCreate: [] as FlatViewSort[],
sortsToUpdate: [] as FlatViewSort[],
sortsToRemove: [] as FlatViewSort[],
};
@Injectable()
export class ViewWidgetUpsertService {
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
@InjectRepository(ViewEntity)
private readonly viewRepository: Repository<ViewEntity>,
) {}
async upsertViewWidget({
input,
workspaceId,
}: {
input: UpsertViewWidgetInput;
workspaceId: string;
}): Promise<ViewEntity> {
const { widgetId } = input;
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const {
flatPageLayoutWidgetMaps,
flatFieldMetadataMaps,
flatViewFieldMaps,
flatViewFilterMaps,
flatViewFilterGroupMaps,
flatViewSortMaps,
flatViewMaps,
} =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
'flatPageLayoutWidgetMaps',
'flatFieldMetadataMaps',
'flatViewFieldMaps',
'flatViewFilterMaps',
'flatViewFilterGroupMaps',
'flatViewSortMaps',
'flatViewMaps',
],
},
);
const widget = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: widgetId,
flatEntityMaps: flatPageLayoutWidgetMaps,
});
if (
!isDefined(widget) ||
!isFlatPageLayoutWidgetConfigurationOfType(
widget,
WidgetConfigurationType.RECORD_TABLE,
)
) {
throw new ViewException(
t`Record table widget not found`,
ViewExceptionCode.VIEW_WIDGET_NOT_FOUND,
);
}
const viewId = widget.configuration.viewId;
if (!isDefined(viewId)) {
throw new ViewException(
t`Record table widget has no associated view`,
ViewExceptionCode.VIEW_WIDGET_NOT_FOUND,
);
}
const flatView = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: viewId,
flatEntityMaps: flatViewMaps,
});
if (!isDefined(flatView)) {
throw new ViewException(
t`View not found for widget`,
ViewExceptionCode.VIEW_NOT_FOUND,
);
}
const upsertContext = {
viewId,
workspaceId,
applicationId: workspaceCustomFlatApplication.id,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
now: new Date().toISOString(),
};
if (
!isDefined(input.viewFields) &&
!isDefined(input.viewFilterGroups) &&
!isDefined(input.viewFilters) &&
!isDefined(input.viewSorts)
) {
const view = await this.viewRepository.findOne({
where: {
id: upsertContext.viewId,
workspaceId: upsertContext.workspaceId,
deletedAt: IsNull(),
},
});
if (!isDefined(view)) {
throw new ViewException(
t`View not found`,
ViewExceptionCode.VIEW_NOT_FOUND,
);
}
return view;
}
const existingViewFields = Object.values(
flatViewFieldMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((field) => field.isActive && field.viewId === viewId);
const existingViewFilters = Object.values(
flatViewFilterMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((filter) => filter.viewId === viewId);
const existingViewFilterGroups = Object.values(
flatViewFilterGroupMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((group) => group.viewId === viewId);
const existingViewSorts = Object.values(
flatViewSortMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((sort) => sort.viewId === viewId);
const viewFieldOperations = isDefined(input.viewFields)
? this.computeViewFieldOperations({
inputFields: input.viewFields,
existingViewFields,
...upsertContext,
flatFieldMetadataMaps,
flatViewMaps,
})
: EMPTY_FIELD_OPS;
const viewFilterGroupOperations = isDefined(input.viewFilterGroups)
? this.computeViewFilterGroupOperations({
inputFilterGroups: input.viewFilterGroups,
existingViewFilterGroups,
...upsertContext,
flatViewMaps,
flatViewFilterGroupMaps,
})
: EMPTY_FILTER_GROUP_OPS;
const optimisticFilterGroupMaps =
viewFilterGroupOperations.filterGroupsToCreate.reduce(
(maps, group) =>
addFlatEntityToFlatEntityMapsOrThrow({
flatEntity: group,
flatEntityMaps: maps,
}),
flatViewFilterGroupMaps,
);
const viewFilterOperations = isDefined(input.viewFilters)
? this.computeViewFilterOperations({
inputFilters: input.viewFilters,
existingViewFilters,
...upsertContext,
flatFieldMetadataMaps,
flatViewMaps,
flatViewFilterGroupMaps: optimisticFilterGroupMaps,
})
: EMPTY_FILTER_OPS;
const viewSortOperations = isDefined(input.viewSorts)
? this.computeViewSortOperations({
inputSorts: input.viewSorts,
existingViewSorts,
...upsertContext,
flatFieldMetadataMaps,
flatViewMaps,
})
: EMPTY_SORT_OPS;
const {
toHardDelete: filterGroupsToDelete,
toDeactivate: filterGroupsToDeactivate,
} = splitEntitiesByRemovalStrategy({
entitiesToRemove: viewFilterGroupOperations.filterGroupsToRemove,
workspaceCustomApplicationUniversalIdentifier:
upsertContext.applicationUniversalIdentifier,
now: upsertContext.now,
});
const { toHardDelete: filtersToDelete, toDeactivate: filtersToDeactivate } =
splitEntitiesByRemovalStrategy({
entitiesToRemove: viewFilterOperations.filtersToRemove,
workspaceCustomApplicationUniversalIdentifier:
upsertContext.applicationUniversalIdentifier,
now: upsertContext.now,
});
const { toHardDelete: sortsToDelete, toDeactivate: sortsToDeactivate } =
splitEntitiesByRemovalStrategy({
entitiesToRemove: viewSortOperations.sortsToRemove,
workspaceCustomApplicationUniversalIdentifier:
upsertContext.applicationUniversalIdentifier,
now: upsertContext.now,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
viewField: {
flatEntityToCreate: viewFieldOperations.fieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: viewFieldOperations.fieldsToUpdate,
},
viewFilterGroup: {
flatEntityToCreate:
viewFilterGroupOperations.filterGroupsToCreate,
flatEntityToDelete: filterGroupsToDelete,
flatEntityToUpdate: [
...viewFilterGroupOperations.filterGroupsToUpdate,
...filterGroupsToDeactivate,
],
},
viewFilter: {
flatEntityToCreate: viewFilterOperations.filtersToCreate,
flatEntityToDelete: filtersToDelete,
flatEntityToUpdate: [
...viewFilterOperations.filtersToUpdate,
...filtersToDeactivate,
],
},
viewSort: {
flatEntityToCreate: viewSortOperations.sortsToCreate,
flatEntityToDelete: sortsToDelete,
flatEntityToUpdate: [
...viewSortOperations.sortsToUpdate,
...sortsToDeactivate,
],
},
},
workspaceId: upsertContext.workspaceId,
isSystemBuild: false,
applicationUniversalIdentifier:
upsertContext.applicationUniversalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while upserting view widget',
);
}
const view = await this.viewRepository.findOne({
where: {
id: upsertContext.viewId,
workspaceId: upsertContext.workspaceId,
deletedAt: IsNull(),
},
});
if (!isDefined(view)) {
throw new ViewException(
t`View not found after upsert`,
ViewExceptionCode.VIEW_NOT_FOUND,
);
}
return view;
}
private computeViewFieldOperations({
inputFields,
existingViewFields,
viewId,
workspaceId,
applicationId,
applicationUniversalIdentifier,
now,
flatFieldMetadataMaps,
flatViewMaps,
}: {
inputFields: UpsertViewWidgetViewFieldInput[];
existingViewFields: FlatViewField[];
viewId: string;
workspaceId: string;
applicationId: string;
applicationUniversalIdentifier: string;
now: string;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatViewMaps: FlatViewMaps;
}): {
fieldsToCreate: FlatViewField[];
fieldsToUpdate: FlatViewField[];
} {
const fieldsToCreate: FlatViewField[] = [];
const fieldsToUpdate: FlatViewField[] = [];
for (const inputField of inputFields) {
const existingField = isDefined(inputField.viewFieldId)
? existingViewFields.find((f) => f.id === inputField.viewFieldId)
: isDefined(inputField.fieldMetadataId)
? existingViewFields.find(
(f) => f.fieldMetadataId === inputField.fieldMetadataId,
)
: undefined;
if (isDefined(existingField)) {
const resolvedIsVisible = isDefined(existingField.overrides?.isVisible)
? existingField.overrides.isVisible
: existingField.isVisible;
const resolvedPosition = isDefined(existingField.overrides?.position)
? existingField.overrides.position
: existingField.position;
const resolvedSize = isDefined(existingField.overrides?.size)
? existingField.overrides.size
: existingField.size;
const hasChanged =
resolvedIsVisible !== inputField.isVisible ||
resolvedPosition !== inputField.position ||
(isDefined(inputField.size) && resolvedSize !== inputField.size);
if (!hasChanged) {
continue;
}
const shouldOverride = isCallerOverridingEntity({
callerApplicationUniversalIdentifier: applicationUniversalIdentifier,
entityApplicationUniversalIdentifier:
existingField.applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
applicationUniversalIdentifier,
});
const { overrides, updatedEditableProperties: sanitizedFieldProps } =
sanitizeOverridableEntityInput({
metadataName: 'viewField',
existingFlatEntity: existingField,
updatedEditableProperties: {
isVisible: inputField.isVisible,
position: inputField.position,
...(isDefined(inputField.size) ? { size: inputField.size } : {}),
},
shouldOverride,
});
const updatedField: FlatViewField = {
...existingField,
...sanitizedFieldProps,
overrides,
updatedAt: now,
};
if (isDefined(overrides)) {
updatedField.universalOverrides =
fromViewFieldOverridesToUniversalOverrides({
overrides,
viewFieldGroupUniversalIdentifierById: {},
});
} else {
updatedField.universalOverrides = null;
}
fieldsToUpdate.push(updatedField);
continue;
}
if (!isDefined(inputField.fieldMetadataId)) {
continue;
}
const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: inputField.fieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!isDefined(fieldMetadata)) {
continue;
}
const { fieldMetadataUniversalIdentifier, viewUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewField',
foreignKeyValues: {
fieldMetadataId: inputField.fieldMetadataId,
viewId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
flatViewMaps,
},
});
fieldsToCreate.push({
id: v4(),
workspaceId,
applicationId,
universalIdentifier: v4(),
applicationUniversalIdentifier,
fieldMetadataId: inputField.fieldMetadataId,
fieldMetadataUniversalIdentifier,
viewId,
viewUniversalIdentifier,
viewFieldGroupId: null,
viewFieldGroupUniversalIdentifier: null,
isVisible: inputField.isVisible,
size: inputField.size ?? DEFAULT_VIEW_FIELD_SIZE,
position: inputField.position,
aggregateOperation: null,
overrides: null,
universalOverrides: null,
isActive: true,
createdAt: now,
updatedAt: now,
deletedAt: null,
});
}
return { fieldsToCreate, fieldsToUpdate };
}
private computeViewFilterGroupOperations({
inputFilterGroups,
existingViewFilterGroups,
viewId,
workspaceId,
applicationId,
applicationUniversalIdentifier,
now,
flatViewMaps,
flatViewFilterGroupMaps,
}: {
inputFilterGroups: UpsertViewWidgetViewFilterGroupInput[];
existingViewFilterGroups: FlatViewFilterGroup[];
viewId: string;
workspaceId: string;
applicationId: string;
applicationUniversalIdentifier: string;
now: string;
flatViewMaps: FlatViewMaps;
flatViewFilterGroupMaps: FlatViewFilterGroupMaps;
}): {
filterGroupsToCreate: FlatViewFilterGroup[];
filterGroupsToUpdate: FlatViewFilterGroup[];
filterGroupsToRemove: FlatViewFilterGroup[];
} {
const filterGroupsToCreate: FlatViewFilterGroup[] = [];
const filterGroupsToUpdate: FlatViewFilterGroup[] = [];
const inputFilterGroupIds = new Set(
inputFilterGroups.map((g) => g.id).filter(isDefined),
);
for (const inputGroup of inputFilterGroups) {
const existingGroup = isDefined(inputGroup.id)
? existingViewFilterGroups.find((g) => g.id === inputGroup.id)
: undefined;
if (!isDefined(existingGroup)) {
const filterGroupId = inputGroup.id ?? v4();
const {
viewUniversalIdentifier,
parentViewFilterGroupUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilterGroup',
foreignKeyValues: {
viewId,
parentViewFilterGroupId: inputGroup.parentViewFilterGroupId,
},
flatEntityMaps: { flatViewMaps, flatViewFilterGroupMaps },
});
filterGroupsToCreate.push({
id: filterGroupId,
workspaceId,
applicationId,
universalIdentifier: filterGroupId,
applicationUniversalIdentifier,
viewId,
viewUniversalIdentifier,
logicalOperator:
inputGroup.logicalOperator ?? ViewFilterGroupLogicalOperator.AND,
parentViewFilterGroupId: inputGroup.parentViewFilterGroupId ?? null,
parentViewFilterGroupUniversalIdentifier,
positionInViewFilterGroup:
inputGroup.positionInViewFilterGroup ?? null,
viewFilterIds: [],
viewFilterUniversalIdentifiers: [],
childViewFilterGroupIds: [],
childViewFilterGroupUniversalIdentifiers: [],
createdAt: now,
updatedAt: now,
deletedAt: null,
});
} else {
const hasChanged =
existingGroup.logicalOperator !== inputGroup.logicalOperator ||
existingGroup.positionInViewFilterGroup !==
inputGroup.positionInViewFilterGroup ||
existingGroup.parentViewFilterGroupId !==
inputGroup.parentViewFilterGroupId;
if (hasChanged) {
const resolvedParentId =
inputGroup.parentViewFilterGroupId ??
existingGroup.parentViewFilterGroupId;
const { parentViewFilterGroupUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilterGroup',
foreignKeyValues: {
parentViewFilterGroupId: resolvedParentId,
},
flatEntityMaps: { flatViewFilterGroupMaps },
});
filterGroupsToUpdate.push({
...existingGroup,
logicalOperator:
inputGroup.logicalOperator ?? existingGroup.logicalOperator,
positionInViewFilterGroup:
inputGroup.positionInViewFilterGroup ??
existingGroup.positionInViewFilterGroup,
parentViewFilterGroupId: resolvedParentId,
parentViewFilterGroupUniversalIdentifier,
updatedAt: now,
});
}
}
}
const filterGroupsToRemove = existingViewFilterGroups.filter(
(g) => !inputFilterGroupIds.has(g.id),
);
return { filterGroupsToCreate, filterGroupsToUpdate, filterGroupsToRemove };
}
private computeViewFilterOperations({
inputFilters,
existingViewFilters,
viewId,
workspaceId,
applicationId,
applicationUniversalIdentifier,
now,
flatFieldMetadataMaps,
flatViewMaps,
flatViewFilterGroupMaps,
}: {
inputFilters: UpsertViewWidgetViewFilterInput[];
existingViewFilters: FlatViewFilter[];
viewId: string;
workspaceId: string;
applicationId: string;
applicationUniversalIdentifier: string;
now: string;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatViewMaps: FlatViewMaps;
flatViewFilterGroupMaps: FlatViewFilterGroupMaps;
}): {
filtersToCreate: FlatViewFilter[];
filtersToUpdate: FlatViewFilter[];
filtersToRemove: FlatViewFilter[];
} {
const filtersToCreate: FlatViewFilter[] = [];
const filtersToUpdate: FlatViewFilter[] = [];
const inputFilterIds = new Set(
inputFilters.map((f) => f.id).filter(isDefined),
);
for (const inputFilter of inputFilters) {
const existingFilter = isDefined(inputFilter.id)
? existingViewFilters.find((f) => f.id === inputFilter.id)
: undefined;
if (!isDefined(existingFilter)) {
const filterId = inputFilter.id ?? v4();
const {
fieldMetadataUniversalIdentifier,
viewUniversalIdentifier,
viewFilterGroupUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilter',
foreignKeyValues: {
fieldMetadataId: inputFilter.fieldMetadataId,
viewId,
viewFilterGroupId: inputFilter.viewFilterGroupId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
flatViewMaps,
flatViewFilterGroupMaps,
},
});
filtersToCreate.push({
id: filterId,
workspaceId,
applicationId,
universalIdentifier: v4(),
applicationUniversalIdentifier,
fieldMetadataId: inputFilter.fieldMetadataId,
fieldMetadataUniversalIdentifier,
viewId,
viewUniversalIdentifier,
operand: inputFilter.operand ?? ViewFilterOperand.CONTAINS,
value: inputFilter.value,
viewFilterGroupId: inputFilter.viewFilterGroupId ?? null,
viewFilterGroupUniversalIdentifier,
positionInViewFilterGroup:
inputFilter.positionInViewFilterGroup ?? null,
subFieldName: inputFilter.subFieldName ?? null,
createdAt: now,
updatedAt: now,
deletedAt: null,
});
} else {
const hasChanged =
existingFilter.fieldMetadataId !== inputFilter.fieldMetadataId ||
existingFilter.operand !== inputFilter.operand ||
JSON.stringify(existingFilter.value) !==
JSON.stringify(inputFilter.value) ||
existingFilter.viewFilterGroupId !== inputFilter.viewFilterGroupId ||
existingFilter.positionInViewFilterGroup !==
inputFilter.positionInViewFilterGroup ||
existingFilter.subFieldName !== inputFilter.subFieldName;
if (hasChanged) {
const {
fieldMetadataUniversalIdentifier,
viewFilterGroupUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilter',
foreignKeyValues: {
fieldMetadataId: inputFilter.fieldMetadataId,
viewFilterGroupId: inputFilter.viewFilterGroupId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
flatViewFilterGroupMaps,
},
});
filtersToUpdate.push({
...existingFilter,
fieldMetadataId: inputFilter.fieldMetadataId,
fieldMetadataUniversalIdentifier,
operand: inputFilter.operand ?? existingFilter.operand,
value: inputFilter.value,
viewFilterGroupId:
inputFilter.viewFilterGroupId ?? existingFilter.viewFilterGroupId,
viewFilterGroupUniversalIdentifier,
positionInViewFilterGroup:
inputFilter.positionInViewFilterGroup ??
existingFilter.positionInViewFilterGroup,
subFieldName:
inputFilter.subFieldName ?? existingFilter.subFieldName,
updatedAt: now,
});
}
}
}
const filtersToRemove = existingViewFilters.filter(
(f) => !inputFilterIds.has(f.id),
);
return { filtersToCreate, filtersToUpdate, filtersToRemove };
}
private computeViewSortOperations({
inputSorts,
existingViewSorts,
viewId,
workspaceId,
applicationId,
applicationUniversalIdentifier,
now,
flatFieldMetadataMaps,
flatViewMaps,
}: {
inputSorts: UpsertViewWidgetViewSortInput[];
existingViewSorts: FlatViewSort[];
viewId: string;
workspaceId: string;
applicationId: string;
applicationUniversalIdentifier: string;
now: string;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatViewMaps: FlatViewMaps;
}): {
sortsToCreate: FlatViewSort[];
sortsToUpdate: FlatViewSort[];
sortsToRemove: FlatViewSort[];
} {
const sortsToCreate: FlatViewSort[] = [];
const sortsToUpdate: FlatViewSort[] = [];
const inputSortIds = new Set(inputSorts.map((s) => s.id).filter(isDefined));
for (const inputSort of inputSorts) {
const existingSort = isDefined(inputSort.id)
? existingViewSorts.find((s) => s.id === inputSort.id)
: undefined;
if (!isDefined(existingSort)) {
const sortId = inputSort.id ?? v4();
const { fieldMetadataUniversalIdentifier, viewUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewSort',
foreignKeyValues: {
fieldMetadataId: inputSort.fieldMetadataId,
viewId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
flatViewMaps,
},
});
sortsToCreate.push({
id: sortId,
workspaceId,
applicationId,
universalIdentifier: v4(),
applicationUniversalIdentifier,
fieldMetadataId: inputSort.fieldMetadataId,
fieldMetadataUniversalIdentifier,
viewId,
viewUniversalIdentifier,
direction: inputSort.direction ?? ViewSortDirection.ASC,
createdAt: now,
updatedAt: now,
deletedAt: null,
});
} else {
const hasChanged =
existingSort.fieldMetadataId !== inputSort.fieldMetadataId ||
existingSort.direction !== inputSort.direction;
if (hasChanged) {
const { fieldMetadataUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewSort',
foreignKeyValues: {
fieldMetadataId: inputSort.fieldMetadataId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
},
});
sortsToUpdate.push({
...existingSort,
fieldMetadataId: inputSort.fieldMetadataId,
fieldMetadataUniversalIdentifier,
direction: inputSort.direction ?? existingSort.direction,
updatedAt: now,
});
}
}
}
const sortsToRemove = existingViewSorts.filter(
(s) => !inputSortIds.has(s.id),
);
return { sortsToCreate, sortsToUpdate, sortsToRemove };
}
}
@@ -59,6 +59,8 @@ export const viewGraphqlApiExceptionHandler = (error: Error, i18n: I18n) => {
throw new ForbiddenError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
case ViewExceptionCode.VIEW_WIDGET_NOT_FOUND:
throw new NotFoundError(error.message);
default: {
return assertUnreachable(error.code);
}
@@ -12,6 +12,7 @@ import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-fie
import { ViewFilterModule } from 'src/engine/metadata-modules/view-filter/view-filter.module';
import { ViewPermissionsModule } from 'src/engine/metadata-modules/view-permissions/view-permissions.module';
import { ViewSortModule } from 'src/engine/metadata-modules/view-sort/view-sort.module';
import { ViewWidgetUpsertService } from 'src/engine/metadata-modules/view/services/view-widget-upsert.service';
import { ViewController } from 'src/engine/metadata-modules/view/controllers/view.controller';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewResolver } from 'src/engine/metadata-modules/view/resolvers/view.resolver';
@@ -44,6 +45,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
ViewResolver,
ViewQueryParamsService,
ViewToolsFactory,
ViewWidgetUpsertService,
],
exports: [
ViewService,
@@ -0,0 +1,23 @@
import gql from 'graphql-tag';
import { VIEW_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants';
import { type UpsertViewWidgetInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget.input';
export const upsertViewWidgetQueryFactory = ({
gqlFields = VIEW_GQL_FIELDS,
input,
}: {
gqlFields?: string;
input: UpsertViewWidgetInput;
}) => ({
query: gql`
mutation UpsertViewWidget($input: UpsertViewWidgetInput!) {
upsertViewWidget(input: $input) {
${gqlFields}
}
}
`,
variables: {
input,
},
});
@@ -0,0 +1,43 @@
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { upsertViewWidgetQueryFactory } from 'test/integration/metadata/suites/view/utils/upsert-view-widget-query-factory.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type UpsertViewWidgetInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget.input';
import { type ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
export const upsertViewWidget = async ({
input,
gqlFields,
expectToFail,
}: {
input: UpsertViewWidgetInput;
gqlFields?: string;
expectToFail?: boolean | null;
}): CommonResponseBody<{
upsertViewWidget: ViewDTO;
}> => {
const graphqlOperation = upsertViewWidgetQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Upsert view widget should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Upsert view widget has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};