fix(page-layout): render relation field widgets in table display mode (#22220)
Adding a to-many relation field as a **Table** on a record page rendered an empty widget (header only) in several cases. This fixes three independent defects behind that. - **Morph inverse relations crashed the table.** The host-scoping view filter (`IS current record`) is built on the relation's inverse field. When that inverse is a `MORPH_RELATION` (attachments, notes, tasks…), `getFilterTypeFromFieldType` fell through to `TEXT` and the GraphQL builder threw `Unknown operand IS for TEXT filter`, unmounting the table via the ErrorBoundary. `MORPH_RELATION` now classifies as `RELATION`, and the relation filter resolves the correct morph join column (e.g. `targetPersonId`) from the current record's object type. - **Stale `viewId` on field change.** Changing the bound field on a Table widget kept the previous relation's draft view (wrong object/fields/filter). Field selection now regenerates the draft view for the new relation, or clears the stale `viewId` when the new field can't back a table. - **Label identifier could be hidden or reordered.** Relation-table widget views now pin the label-identifier field first and visible on view creation and save. Deferred: morph relation filters with arbitrary selected record ids (not just "current record") — needs target-object identity in the filter value schema. **Test:** open a Person → edit layout → add a Field widget → bind a to-many relation → switch Layout to Table. Previously empty for `attachments` (morph) and for any field changed on an existing Table widget; now scoped to the host record. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22220?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+5
-3
@@ -1,8 +1,10 @@
|
||||
import { createContext } from 'react';
|
||||
import { type RecordFilterValueDependencies } from 'twenty-shared/types';
|
||||
|
||||
export type RecordFilterValueDependenciesContextValue = {
|
||||
currentRecordId?: string;
|
||||
};
|
||||
export type RecordFilterValueDependenciesContextValue = Pick<
|
||||
RecordFilterValueDependencies,
|
||||
'currentRecord'
|
||||
>;
|
||||
|
||||
export const RecordFilterValueDependenciesContext =
|
||||
createContext<RecordFilterValueDependenciesContextValue>({});
|
||||
|
||||
+3
-3
@@ -14,15 +14,15 @@ export const useFilterValueDependencies = (): {
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const { currentRecordId } = useContext(RecordFilterValueDependenciesContext);
|
||||
const { currentRecord } = useContext(RecordFilterValueDependenciesContext);
|
||||
|
||||
const filterValueDependencies = useMemo(
|
||||
() => ({
|
||||
currentWorkspaceMemberId,
|
||||
currentRecordId,
|
||||
currentRecord,
|
||||
timeZone: userTimezone,
|
||||
}),
|
||||
[currentWorkspaceMemberId, currentRecordId, userTimezone],
|
||||
[currentWorkspaceMemberId, currentRecord, userTimezone],
|
||||
);
|
||||
|
||||
return { filterValueDependencies };
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ export const useBuildRecordInputFromFilters = ({
|
||||
|
||||
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
|
||||
|
||||
const { currentRecordId } = useContext(RecordFilterValueDependenciesContext);
|
||||
const { currentRecord } = useContext(RecordFilterValueDependenciesContext);
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
@@ -33,7 +33,7 @@ export const useBuildRecordInputFromFilters = ({
|
||||
currentRecordFilters,
|
||||
objectMetadataItem,
|
||||
currentWorkspaceMember: currentWorkspaceMember ?? undefined,
|
||||
currentRecordId,
|
||||
currentRecordId: currentRecord?.id,
|
||||
timeZone: userTimezone,
|
||||
});
|
||||
};
|
||||
|
||||
+38
-2
@@ -1,3 +1,4 @@
|
||||
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
|
||||
import { UPSERT_VIEW_WIDGET } from '@/page-layout/graphql/mutations/upsertViewWidget';
|
||||
import { useHasRecordTableWidgetViewChanges } from '@/page-layout/hooks/useHasRecordTableWidgetViewChanges';
|
||||
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
|
||||
@@ -5,6 +6,7 @@ import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/r
|
||||
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
|
||||
import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId';
|
||||
import { widgetUsesRecordTableView } from '@/page-layout/utils/widgetUsesRecordTableView';
|
||||
import { normalizeRecordTableWidgetViewFields } from '@/page-layout/widgets/record-table/utils/normalizeRecordTableWidgetViewFields';
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
@@ -47,6 +49,10 @@ export const useSaveRecordTableWidgetViews = () => {
|
||||
.flatMap((tab) => tab.widgets)
|
||||
.filter(widgetUsesRecordTableView);
|
||||
|
||||
const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);
|
||||
|
||||
let normalizedRecordTableWidgetViewDraft = recordTableWidgetViewDraft;
|
||||
|
||||
for (const widget of draftRecordTableWidgets) {
|
||||
const viewId = getWidgetConfigurationViewId(widget.configuration);
|
||||
|
||||
@@ -60,11 +66,34 @@ export const useSaveRecordTableWidgetViews = () => {
|
||||
continue;
|
||||
}
|
||||
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
(objectMetadataItem) =>
|
||||
objectMetadataItem.id === widgetViewDraft.view.objectMetadataId,
|
||||
);
|
||||
|
||||
const normalizedViewFields = isDefined(objectMetadataItem)
|
||||
? normalizeRecordTableWidgetViewFields({
|
||||
viewFields: widgetViewDraft.viewFields,
|
||||
labelIdentifierFieldMetadataId:
|
||||
objectMetadataItem.labelIdentifierFieldMetadataId,
|
||||
})
|
||||
: widgetViewDraft.viewFields;
|
||||
|
||||
const normalizedWidgetViewDraft = {
|
||||
...widgetViewDraft,
|
||||
viewFields: normalizedViewFields,
|
||||
};
|
||||
|
||||
normalizedRecordTableWidgetViewDraft = {
|
||||
...normalizedRecordTableWidgetViewDraft,
|
||||
[widget.id]: normalizedWidgetViewDraft,
|
||||
};
|
||||
|
||||
await upsertViewWidgetMutation({
|
||||
variables: {
|
||||
input: {
|
||||
widgetId: widget.id,
|
||||
viewFields: widgetViewDraft.viewFields.map((field) => ({
|
||||
viewFields: normalizedWidgetViewDraft.viewFields.map((field) => ({
|
||||
fieldMetadataId: field.fieldMetadataId,
|
||||
isVisible: field.isVisible,
|
||||
position: field.position,
|
||||
@@ -100,11 +129,18 @@ export const useSaveRecordTableWidgetViews = () => {
|
||||
});
|
||||
}
|
||||
|
||||
store.set(
|
||||
recordTableWidgetViewDraftComponentState.atomFamily({
|
||||
instanceId: pageLayoutId,
|
||||
}),
|
||||
normalizedRecordTableWidgetViewDraft,
|
||||
);
|
||||
|
||||
store.set(
|
||||
recordTableWidgetViewPersistedComponentState.atomFamily({
|
||||
instanceId: pageLayoutId,
|
||||
}),
|
||||
recordTableWidgetViewDraft,
|
||||
normalizedRecordTableWidgetViewDraft,
|
||||
);
|
||||
},
|
||||
[hasRecordTableWidgetViewChanges, store, upsertViewWidgetMutation],
|
||||
|
||||
+13
-2
@@ -41,14 +41,25 @@ export const FieldWidgetRelationTable = ({
|
||||
|
||||
const relationObjectMetadataId =
|
||||
fieldDefinition.metadata.relationObjectMetadataId;
|
||||
const recordPageObjectMetadataNameSingular =
|
||||
fieldDefinition.metadata.objectMetadataNameSingular;
|
||||
|
||||
if (!isDefined(viewId) || !isDefined(relationObjectMetadataId)) {
|
||||
if (
|
||||
!isDefined(viewId) ||
|
||||
!isDefined(relationObjectMetadataId) ||
|
||||
!isDefined(recordPageObjectMetadataNameSingular)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RecordFilterValueDependenciesContext.Provider
|
||||
value={{ currentRecordId: recordId }}
|
||||
value={{
|
||||
currentRecord: {
|
||||
id: recordId,
|
||||
objectMetadataNameSingular: recordPageObjectMetadataNameSingular,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<StyledContainer>
|
||||
<RecordTableWidgetRendererContent
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { useAddDraftViewForFieldRelationTableWidget } from '@/page-layout/widgets/record-table/hooks/useAddDraftViewForFieldRelationTableWidget';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
FieldDisplayMode,
|
||||
type FieldConfiguration,
|
||||
RelationType,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type ResolveFieldWidgetRelationTableViewIdChangeArgs = {
|
||||
selectedField: FieldMetadataItem | undefined;
|
||||
currentDisplayMode: FieldDisplayMode | undefined;
|
||||
isSelectingDifferentField: boolean;
|
||||
widgetId: string | undefined;
|
||||
currentViewId: string | null | undefined;
|
||||
};
|
||||
|
||||
export const useResolveFieldWidgetRelationTableViewIdChange = (
|
||||
pageLayoutId: string,
|
||||
) => {
|
||||
const { addDraftViewForFieldRelationTableWidget } =
|
||||
useAddDraftViewForFieldRelationTableWidget(pageLayoutId);
|
||||
|
||||
const resolveFieldWidgetRelationTableViewIdChange = ({
|
||||
selectedField,
|
||||
currentDisplayMode,
|
||||
isSelectingDifferentField,
|
||||
widgetId,
|
||||
currentViewId,
|
||||
}: ResolveFieldWidgetRelationTableViewIdChangeArgs):
|
||||
| Pick<FieldConfiguration, 'viewId'>
|
||||
| undefined => {
|
||||
const targetObjectMetadataId =
|
||||
selectedField?.relation?.targetObjectMetadata.id;
|
||||
const targetFieldMetadataId =
|
||||
selectedField?.relation?.targetFieldMetadata.id;
|
||||
|
||||
const shouldRegenerateRelationTableView =
|
||||
currentDisplayMode === FieldDisplayMode.TABLE &&
|
||||
isSelectingDifferentField &&
|
||||
selectedField?.type === FieldMetadataType.RELATION &&
|
||||
selectedField.relation?.type === RelationType.ONE_TO_MANY &&
|
||||
isDefined(widgetId) &&
|
||||
isDefined(targetObjectMetadataId) &&
|
||||
isDefined(targetFieldMetadataId);
|
||||
|
||||
const regeneratedRelationTableViewId = shouldRegenerateRelationTableView
|
||||
? addDraftViewForFieldRelationTableWidget(
|
||||
widgetId,
|
||||
targetObjectMetadataId,
|
||||
targetFieldMetadataId,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (isDefined(regeneratedRelationTableViewId)) {
|
||||
return { viewId: regeneratedRelationTableViewId };
|
||||
}
|
||||
|
||||
if (isSelectingDifferentField && isDefined(currentViewId)) {
|
||||
return { viewId: undefined };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return { resolveFieldWidgetRelationTableViewIdChange };
|
||||
};
|
||||
+24
@@ -10,4 +10,28 @@ describe('buildRecordTableWidgetViewSnapshot', () => {
|
||||
|
||||
expect(view.visibility).toBe(ViewVisibility.WORKSPACE);
|
||||
});
|
||||
|
||||
it('should keep the label identifier view field visible and in the lowest position', () => {
|
||||
const { viewFields } =
|
||||
buildRecordTableWidgetViewSnapshot(objectMetadataItem);
|
||||
|
||||
const labelIdentifierViewField = viewFields.find(
|
||||
(viewField) =>
|
||||
viewField.fieldMetadataId ===
|
||||
objectMetadataItem.labelIdentifierFieldMetadataId,
|
||||
);
|
||||
|
||||
expect(labelIdentifierViewField).toMatchObject({
|
||||
isVisible: true,
|
||||
position: 0,
|
||||
});
|
||||
expect(
|
||||
viewFields.every(
|
||||
(viewField) =>
|
||||
viewField.fieldMetadataId ===
|
||||
objectMetadataItem.labelIdentifierFieldMetadataId ||
|
||||
viewField.position > 0,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { normalizeRecordTableWidgetViewFields } from '@/page-layout/widgets/record-table/utils/normalizeRecordTableWidgetViewFields';
|
||||
|
||||
describe('normalizeRecordTableWidgetViewFields', () => {
|
||||
it('should keep the label identifier view field visible and before every other view field', () => {
|
||||
const normalizedViewFields = normalizeRecordTableWidgetViewFields({
|
||||
labelIdentifierFieldMetadataId: 'label-field-id',
|
||||
viewFields: [
|
||||
{
|
||||
id: 'hidden-field-id',
|
||||
fieldMetadataId: 'hidden-field-metadata-id',
|
||||
isVisible: false,
|
||||
position: 0,
|
||||
size: 180,
|
||||
},
|
||||
{
|
||||
id: 'label-view-field-id',
|
||||
fieldMetadataId: 'label-field-id',
|
||||
isVisible: false,
|
||||
position: 4,
|
||||
size: 180,
|
||||
},
|
||||
{
|
||||
id: 'visible-field-id',
|
||||
fieldMetadataId: 'visible-field-metadata-id',
|
||||
isVisible: true,
|
||||
position: 0,
|
||||
size: 180,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(normalizedViewFields).toEqual([
|
||||
{
|
||||
id: 'label-view-field-id',
|
||||
fieldMetadataId: 'label-field-id',
|
||||
isVisible: true,
|
||||
position: 0,
|
||||
size: 180,
|
||||
},
|
||||
{
|
||||
id: 'hidden-field-id',
|
||||
fieldMetadataId: 'hidden-field-metadata-id',
|
||||
isVisible: false,
|
||||
position: 1,
|
||||
size: 180,
|
||||
},
|
||||
{
|
||||
id: 'visible-field-id',
|
||||
fieldMetadataId: 'visible-field-metadata-id',
|
||||
isVisible: true,
|
||||
position: 2,
|
||||
size: 180,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should leave view fields unchanged when the label identifier view field is absent', () => {
|
||||
const viewFields = [
|
||||
{
|
||||
id: 'field-id',
|
||||
fieldMetadataId: 'field-metadata-id',
|
||||
isVisible: false,
|
||||
position: 0,
|
||||
size: 180,
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
normalizeRecordTableWidgetViewFields({
|
||||
viewFields,
|
||||
labelIdentifierFieldMetadataId: 'missing-label-field-id',
|
||||
}),
|
||||
).toBe(viewFields);
|
||||
});
|
||||
});
|
||||
+14
-9
@@ -3,6 +3,7 @@ import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
|
||||
import { filterFieldsForRecordTableViewCreation } from '@/page-layout/widgets/record-table/utils/filterFieldsForRecordTableViewCreation';
|
||||
import { normalizeRecordTableWidgetViewFields } from '@/page-layout/widgets/record-table/utils/normalizeRecordTableWidgetViewFields';
|
||||
import { sortFieldsByRelevanceForRecordTableWidget } from '@/page-layout/widgets/record-table/utils/sortFieldsByRelevanceForRecordTableWidget';
|
||||
import { v4 } from 'uuid';
|
||||
import {
|
||||
@@ -46,15 +47,19 @@ export const buildRecordTableWidgetViewSnapshot = (
|
||||
),
|
||||
);
|
||||
|
||||
const flatViewFields: FlatViewField[] = sortedFields.map((field, index) => ({
|
||||
id: v4(),
|
||||
viewId: newViewId,
|
||||
fieldMetadataId: field.id,
|
||||
position: index,
|
||||
size: DEFAULT_VIEW_FIELD_SIZE,
|
||||
isVisible: index < INITIAL_VISIBLE_FIELDS_COUNT_IN_WIDGET,
|
||||
isActive: true,
|
||||
}));
|
||||
const flatViewFields: FlatViewField[] = normalizeRecordTableWidgetViewFields({
|
||||
viewFields: sortedFields.map((field, index) => ({
|
||||
id: v4(),
|
||||
viewId: newViewId,
|
||||
fieldMetadataId: field.id,
|
||||
position: index,
|
||||
size: DEFAULT_VIEW_FIELD_SIZE,
|
||||
isVisible: index < INITIAL_VISIBLE_FIELDS_COUNT_IN_WIDGET,
|
||||
isActive: true,
|
||||
})),
|
||||
labelIdentifierFieldMetadataId:
|
||||
objectMetadataItem.labelIdentifierFieldMetadataId,
|
||||
});
|
||||
|
||||
return {
|
||||
view: flatView,
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type RecordTableWidgetViewFieldWithLabelIdentifierInvariant = {
|
||||
fieldMetadataId: string;
|
||||
isVisible: boolean;
|
||||
position: number;
|
||||
};
|
||||
|
||||
type NormalizeRecordTableWidgetViewFieldsArgs<
|
||||
ViewField extends RecordTableWidgetViewFieldWithLabelIdentifierInvariant,
|
||||
> = {
|
||||
viewFields: ViewField[];
|
||||
labelIdentifierFieldMetadataId: string;
|
||||
};
|
||||
|
||||
export const normalizeRecordTableWidgetViewFields = <
|
||||
ViewField extends RecordTableWidgetViewFieldWithLabelIdentifierInvariant,
|
||||
>({
|
||||
viewFields,
|
||||
labelIdentifierFieldMetadataId,
|
||||
}: NormalizeRecordTableWidgetViewFieldsArgs<ViewField>): ViewField[] => {
|
||||
const labelIdentifierViewField = viewFields.find(
|
||||
(viewField) => viewField.fieldMetadataId === labelIdentifierFieldMetadataId,
|
||||
);
|
||||
|
||||
if (!isDefined(labelIdentifierViewField)) {
|
||||
return viewFields;
|
||||
}
|
||||
|
||||
const normalizedLabelIdentifierViewField: ViewField = {
|
||||
...labelIdentifierViewField,
|
||||
isVisible: true,
|
||||
position: 0,
|
||||
};
|
||||
|
||||
const normalizedOtherViewFields = viewFields
|
||||
.filter(
|
||||
(viewField) =>
|
||||
viewField.fieldMetadataId !== labelIdentifierFieldMetadataId,
|
||||
)
|
||||
.toSorted((viewFieldA, viewFieldB) => {
|
||||
return viewFieldA.position - viewFieldB.position;
|
||||
})
|
||||
.map<ViewField>((viewField, index) => ({
|
||||
...viewField,
|
||||
position: index + 1,
|
||||
}));
|
||||
|
||||
return [normalizedLabelIdentifierViewField, ...normalizedOtherViewFields];
|
||||
};
|
||||
+18
-1
@@ -1,8 +1,8 @@
|
||||
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { isAdvancedRelationFieldMetadataItem } from '@/object-record/utils/isAdvancedRelationFieldMetadataItem';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
|
||||
import { useResolveFieldWidgetRelationTableViewIdChange } from '@/page-layout/widgets/record-table/hooks/useResolveFieldWidgetRelationTableViewIdChange';
|
||||
import { useFieldWidgetEligibleFields } from '@/page-layout/widgets/field/hooks/useFieldWidgetEligibleFields';
|
||||
import {
|
||||
getFieldWidgetDefaultDisplayMode,
|
||||
@@ -26,6 +26,7 @@ import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/com
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useIcons } from 'twenty-ui/icon';
|
||||
import { MenuItemSelect } from 'twenty-ui/navigation';
|
||||
import { type FieldConfiguration } from '~/generated-metadata/graphql';
|
||||
@@ -92,6 +93,9 @@ export const FieldWidgetFieldDropdownContent = () => {
|
||||
|
||||
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
|
||||
|
||||
const { resolveFieldWidgetRelationTableViewIdChange } =
|
||||
useResolveFieldWidgetRelationTableViewIdChange(pageLayoutId);
|
||||
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
|
||||
const { getIcon } = useIcons();
|
||||
@@ -126,9 +130,22 @@ export const FieldWidgetFieldDropdownContent = () => {
|
||||
selectedField.relation?.type,
|
||||
);
|
||||
|
||||
const isSelectingDifferentField =
|
||||
currentFieldMetadataId !== fieldMetadataId;
|
||||
|
||||
const relationTableViewIdChange =
|
||||
resolveFieldWidgetRelationTableViewIdChange({
|
||||
selectedField,
|
||||
currentDisplayMode,
|
||||
isSelectingDifferentField,
|
||||
widgetId: widgetInEditMode?.id,
|
||||
currentViewId: fieldConfiguration?.viewId,
|
||||
});
|
||||
|
||||
updateCurrentWidgetConfig({
|
||||
configToUpdate: {
|
||||
fieldMetadataId,
|
||||
...relationTableViewIdChange,
|
||||
...(needsDisplayModeSwitch && {
|
||||
fieldDisplayMode: getFieldWidgetDefaultDisplayMode(
|
||||
selectedField.type,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export interface RecordFilterValueDependencies {
|
||||
currentWorkspaceMemberId?: string;
|
||||
currentRecordId?: string;
|
||||
currentRecord?: {
|
||||
id: string;
|
||||
objectMetadataNameSingular: string;
|
||||
};
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
+84
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
FieldMetadataType,
|
||||
RelationType,
|
||||
ViewFilterOperand as RecordFilterOperand,
|
||||
} from '@/types';
|
||||
import { type RecordFilter } from '@/utils';
|
||||
@@ -55,6 +56,28 @@ const fields = [
|
||||
type: FieldMetadataType.RELATION,
|
||||
label: 'Account Owner',
|
||||
},
|
||||
{
|
||||
id: 'f-morph-relation',
|
||||
name: 'target',
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
label: 'Target',
|
||||
morphRelations: [
|
||||
{
|
||||
type: RelationType.MANY_TO_ONE,
|
||||
targetObjectMetadata: {
|
||||
nameSingular: 'person',
|
||||
namePlural: 'people',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: RelationType.MANY_TO_ONE,
|
||||
targetObjectMetadata: {
|
||||
nameSingular: 'company',
|
||||
namePlural: 'companies',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'f-bool',
|
||||
name: 'isActive',
|
||||
@@ -614,6 +637,67 @@ describe('turnRecordFilterIntoRecordGqlOperationFilter', () => {
|
||||
|
||||
expect(result).toHaveProperty('or');
|
||||
});
|
||||
|
||||
it('should resolve programmatic current-record morph relation-table filters to the matching join column', () => {
|
||||
const result = turnRecordFilterIntoRecordGqlOperationFilter({
|
||||
filterValueDependencies: {
|
||||
...filterValueDependencies,
|
||||
currentRecord: {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
objectMetadataNameSingular: 'person',
|
||||
},
|
||||
},
|
||||
recordFilter: makeFilter(
|
||||
'f-morph-relation',
|
||||
RecordFilterOperand.IS,
|
||||
JSON.stringify({
|
||||
selectedRecordIds: [],
|
||||
isCurrentRecordSelected: true,
|
||||
}),
|
||||
'RELATION',
|
||||
),
|
||||
fieldMetadataItemById,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
targetPersonId: { in: ['11111111-1111-4111-8111-111111111111'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('should omit programmatic current-record morph filters when morph relation metadata is missing', () => {
|
||||
const fieldMetadataItemByIdWithoutMorphRelations = new Map(
|
||||
fieldMetadataItemById,
|
||||
);
|
||||
|
||||
fieldMetadataItemByIdWithoutMorphRelations.set('f-morph-relation', {
|
||||
id: 'f-morph-relation',
|
||||
name: 'target',
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
label: 'Target',
|
||||
});
|
||||
|
||||
const result = turnRecordFilterIntoRecordGqlOperationFilter({
|
||||
filterValueDependencies: {
|
||||
...filterValueDependencies,
|
||||
currentRecord: {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
objectMetadataNameSingular: 'person',
|
||||
},
|
||||
},
|
||||
recordFilter: makeFilter(
|
||||
'f-morph-relation',
|
||||
RecordFilterOperand.IS,
|
||||
JSON.stringify({
|
||||
selectedRecordIds: [],
|
||||
isCurrentRecordSelected: true,
|
||||
}),
|
||||
'RELATION',
|
||||
),
|
||||
fieldMetadataItemById: fieldMetadataItemByIdWithoutMorphRelations,
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RAW_JSON filter', () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type RawJsonFilter,
|
||||
type RecordFilterValueDependencies,
|
||||
type RecordGqlOperationFilter,
|
||||
type RelationType,
|
||||
type RelationFilter,
|
||||
type SelectFilter,
|
||||
type StringFilter,
|
||||
@@ -54,12 +55,25 @@ import {
|
||||
import { arrayOfStringsOrVariablesSchema } from '@/utils/filter/utils/validation-schemas/arrayOfStringsOrVariablesSchema';
|
||||
import { arrayOfUuidOrVariableSchema } from '@/utils/filter/utils/validation-schemas/arrayOfUuidsOrVariablesSchema';
|
||||
import { jsonRelationFilterValueSchema } from '@/utils/filter/utils/validation-schemas/jsonRelationFilterValueSchema';
|
||||
import {
|
||||
computeMorphRelationGqlFieldJoinColumnName,
|
||||
computeRelationGqlFieldJoinColumnName,
|
||||
} from '@/utils/fieldMetadata/compute-relation-gql-field-join-column-name';
|
||||
|
||||
type FieldSharedMorphRelation = {
|
||||
type: RelationType;
|
||||
targetObjectMetadata: {
|
||||
nameSingular: string;
|
||||
namePlural: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type FieldShared = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: FieldMetadataType;
|
||||
label: string;
|
||||
morphRelations?: FieldSharedMorphRelation[] | null;
|
||||
};
|
||||
|
||||
type TurnRecordFilterIntoRecordGqlOperationFilterParams = {
|
||||
@@ -131,6 +145,39 @@ type BuildDirectFieldGqlOperationFilterParams = {
|
||||
fieldMetadataItem: FieldShared;
|
||||
};
|
||||
|
||||
const getRelationFilterJoinColumnName = ({
|
||||
fieldMetadataItem,
|
||||
filterValueDependencies,
|
||||
}: {
|
||||
fieldMetadataItem: FieldShared;
|
||||
filterValueDependencies: RecordFilterValueDependencies;
|
||||
}): string | undefined => {
|
||||
if (fieldMetadataItem.type !== FieldMetadataType.MORPH_RELATION) {
|
||||
return computeRelationGqlFieldJoinColumnName({
|
||||
name: fieldMetadataItem.name,
|
||||
});
|
||||
}
|
||||
|
||||
const matchingMorphRelation = fieldMetadataItem.morphRelations?.find(
|
||||
(morphRelation) =>
|
||||
morphRelation.targetObjectMetadata.nameSingular ===
|
||||
filterValueDependencies.currentRecord?.objectMetadataNameSingular,
|
||||
);
|
||||
|
||||
if (!isDefined(matchingMorphRelation)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return computeMorphRelationGqlFieldJoinColumnName({
|
||||
fieldName: fieldMetadataItem.name,
|
||||
relationType: matchingMorphRelation.type,
|
||||
targetObjectMetadataNameSingular:
|
||||
matchingMorphRelation.targetObjectMetadata.nameSingular,
|
||||
targetObjectMetadataNamePlural:
|
||||
matchingMorphRelation.targetObjectMetadata.namePlural,
|
||||
});
|
||||
};
|
||||
|
||||
const buildDirectFieldGqlOperationFilter = ({
|
||||
recordFilter,
|
||||
fieldMetadataItem,
|
||||
@@ -590,16 +637,25 @@ const buildDirectFieldGqlOperationFilter = ({
|
||||
? [filterValueDependencies?.currentWorkspaceMemberId]
|
||||
: []),
|
||||
...(isCurrentRecordSelected
|
||||
? [filterValueDependencies?.currentRecordId]
|
||||
? [filterValueDependencies.currentRecord?.id]
|
||||
: []),
|
||||
].filter(isDefined);
|
||||
|
||||
if (recordIds.length === 0) return;
|
||||
|
||||
const relationFilterJoinColumnName = getRelationFilterJoinColumnName({
|
||||
fieldMetadataItem,
|
||||
filterValueDependencies,
|
||||
});
|
||||
|
||||
if (!isDefined(relationFilterJoinColumnName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (recordFilter.operand) {
|
||||
case RecordFilterOperand.IS:
|
||||
return {
|
||||
[fieldMetadataItem.name + 'Id']: {
|
||||
[relationFilterJoinColumnName]: {
|
||||
in: recordIds,
|
||||
} as RelationFilter,
|
||||
};
|
||||
@@ -609,13 +665,13 @@ const buildDirectFieldGqlOperationFilter = ({
|
||||
or: [
|
||||
{
|
||||
not: {
|
||||
[fieldMetadataItem.name + 'Id']: {
|
||||
[relationFilterJoinColumnName]: {
|
||||
in: recordIds,
|
||||
} as RelationFilter,
|
||||
},
|
||||
},
|
||||
{
|
||||
[fieldMetadataItem.name + 'Id']: {
|
||||
[relationFilterJoinColumnName]: {
|
||||
is: 'NULL',
|
||||
} as RelationFilter,
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ export const getFilterTypeFromFieldType = (
|
||||
case FieldMetadataType.PHONES:
|
||||
return 'PHONES';
|
||||
case FieldMetadataType.RELATION:
|
||||
case FieldMetadataType.MORPH_RELATION:
|
||||
return 'RELATION';
|
||||
case FieldMetadataType.SELECT:
|
||||
return 'SELECT';
|
||||
|
||||
Reference in New Issue
Block a user