d1c70ab0bf
## Summary - Update dashboard record-table widget aggregate changes to write into the widget draft while page layout edit mode is active. - Include `aggregateOperation` when saving record-table widget view fields through `upsertViewWidget`. - Persist aggregate operations server-side for widget view-field create, update, and clear flows. - Add frontend utility tests and backend integration coverage for widget aggregate create/update/clear behavior. Fixes #22934. ## Why Dashboard record-table widgets use their own draft view state while a page layout is being edited. The aggregate footer path was resolving fields through the normal current-view flow and then trying to persist immediately, which can miss widget draft fields and fail before the save flow runs. This change keeps aggregate edits in the widget draft during page layout editing, then saves the aggregate operation with the rest of the widget view configuration. ## Validation - `npx nx lint twenty-front` - `npx nx typecheck twenty-front` - `npx nx test twenty-front --configuration=ci` - `npx nx build twenty-front` - `npx nx build twenty-server` - `npx nx lint twenty-server --configuration=ci` - `npx nx typecheck twenty-server` - `npx nx test twenty-server --configuration=ci` - `npx nx jest --config ./jest-integration.config.ts --logHeapUsage --runTestsByPath test/integration/metadata/suites/view/upsert-view-widget.integration-spec.ts` - `git diff --check` Disclosure: I used AI-assisted coding tools while preparing this PR. I reviewed the changes myself, tested them, and take responsibility for the implementation and any follow-up revisions needed. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23008?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. -->
1257 lines
36 KiB
TypeScript
1257 lines
36 KiB
TypeScript
import {
|
|
VIEW_FIELD_GQL_FIELDS,
|
|
VIEW_FILTER_GQL_FIELDS,
|
|
VIEW_FILTER_GROUP_GQL_FIELDS,
|
|
VIEW_GQL_FIELDS,
|
|
VIEW_SORT_GQL_FIELDS,
|
|
} from 'test/integration/constants/view-gql-fields.constants';
|
|
import { createOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/create-one-page-layout-tab.util';
|
|
import { destroyOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/destroy-one-page-layout-tab.util';
|
|
import { createOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/create-one-page-layout-widget.util';
|
|
import { destroyOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/destroy-one-page-layout-widget.util';
|
|
import {
|
|
fetchTestFieldMetadataIds,
|
|
type TestFieldMetadataIds,
|
|
} from 'test/integration/metadata/suites/page-layout-widget/utils/fetch-test-field-metadata-ids.util';
|
|
import { createOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/create-one-page-layout.util';
|
|
import { destroyOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/destroy-one-page-layout.util';
|
|
import { findViewFields } from 'test/integration/metadata/suites/view-field/utils/find-view-fields.util';
|
|
import { findViewFilters } from 'test/integration/metadata/suites/view-filter/utils/find-view-filters.util';
|
|
import { createOneView } from 'test/integration/metadata/suites/view/utils/create-one-view.util';
|
|
import { destroyOneView } from 'test/integration/metadata/suites/view/utils/destroy-one-view.util';
|
|
import { upsertViewWidget } from 'test/integration/metadata/suites/view/utils/upsert-view-widget.util';
|
|
import {
|
|
AggregateOperations,
|
|
ViewFilterGroupLogicalOperator,
|
|
ViewFilterOperand,
|
|
ViewSortDirection,
|
|
ViewType,
|
|
} from 'twenty-shared/types';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
|
|
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
|
|
|
|
type ViewWidgetTestSetup = {
|
|
pageLayoutId: string;
|
|
pageLayoutTabId: string;
|
|
widgetId: string;
|
|
viewId: string;
|
|
labelIdentifierFieldMetadataId: string | null;
|
|
viewFields: Array<{
|
|
id: string;
|
|
fieldMetadataId: string;
|
|
position: number;
|
|
isVisible: boolean;
|
|
}>;
|
|
fieldMetadataIds: string[];
|
|
};
|
|
|
|
const VIEW_WITH_ALL_RELATIONS_GQL_FIELDS = `
|
|
${VIEW_GQL_FIELDS}
|
|
viewFields {
|
|
${VIEW_FIELD_GQL_FIELDS}
|
|
}
|
|
viewFilters {
|
|
${VIEW_FILTER_GQL_FIELDS}
|
|
}
|
|
viewFilterGroups {
|
|
${VIEW_FILTER_GROUP_GQL_FIELDS}
|
|
}
|
|
viewSorts {
|
|
${VIEW_SORT_GQL_FIELDS}
|
|
}
|
|
`;
|
|
|
|
describe('upsertViewWidget', () => {
|
|
let testSetup: ViewWidgetTestSetup;
|
|
|
|
beforeAll(async () => {
|
|
const testFieldMetadataIds: TestFieldMetadataIds =
|
|
await fetchTestFieldMetadataIds();
|
|
|
|
const { data: layoutData } = await createOnePageLayout({
|
|
expectToFail: false,
|
|
input: { name: 'Test Page Layout For View Widget' },
|
|
});
|
|
|
|
const pageLayoutId = layoutData.createPageLayout.id;
|
|
|
|
const { data: tabData } = await createOnePageLayoutTab({
|
|
expectToFail: false,
|
|
input: {
|
|
title: 'Test Tab For View Widget',
|
|
pageLayoutId,
|
|
},
|
|
});
|
|
|
|
const pageLayoutTabId = tabData.createPageLayoutTab.id;
|
|
|
|
const { data: viewData } = await createOneView({
|
|
expectToFail: false,
|
|
input: {
|
|
name: 'testViewWidgetView',
|
|
objectMetadataId: testFieldMetadataIds.objectMetadataId,
|
|
icon: 'IconTable',
|
|
type: ViewType.TABLE,
|
|
},
|
|
});
|
|
|
|
const viewId = viewData.createView.id;
|
|
|
|
const { data: widgetData } = await createOnePageLayoutWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
title: 'Test Record Table Widget',
|
|
type: WidgetType.RECORD_TABLE,
|
|
pageLayoutTabId,
|
|
objectMetadataId: testFieldMetadataIds.objectMetadataId,
|
|
gridPosition: { row: 0, column: 0, rowSpan: 1, columnSpan: 1 },
|
|
configuration: {
|
|
configurationType: WidgetConfigurationType.RECORD_TABLE,
|
|
viewId,
|
|
},
|
|
},
|
|
});
|
|
|
|
const widgetId = widgetData.createPageLayoutWidget.id;
|
|
|
|
const objectMetadataRows = await global.testDataSource.query(
|
|
`SELECT "labelIdentifierFieldMetadataId"
|
|
FROM core."objectMetadata"
|
|
WHERE id = $1`,
|
|
[testFieldMetadataIds.objectMetadataId],
|
|
);
|
|
|
|
const labelIdentifierFieldMetadataId =
|
|
objectMetadataRows[0]?.labelIdentifierFieldMetadataId ?? null;
|
|
|
|
const allFieldMetadataRows = await global.testDataSource.query(
|
|
`SELECT id FROM core."fieldMetadata"
|
|
WHERE "objectMetadataId" = $1
|
|
AND "isActive" = true
|
|
ORDER BY id
|
|
LIMIT 10`,
|
|
[testFieldMetadataIds.objectMetadataId],
|
|
);
|
|
|
|
const allFieldMetadataIds: string[] = allFieldMetadataRows.map(
|
|
(row: { id: string }) => row.id,
|
|
);
|
|
|
|
if (labelIdentifierFieldMetadataId) {
|
|
const idx = allFieldMetadataIds.indexOf(labelIdentifierFieldMetadataId);
|
|
|
|
if (idx > 0) {
|
|
allFieldMetadataIds.splice(idx, 1);
|
|
allFieldMetadataIds.unshift(labelIdentifierFieldMetadataId);
|
|
} else if (idx === -1) {
|
|
allFieldMetadataIds.unshift(labelIdentifierFieldMetadataId);
|
|
}
|
|
}
|
|
|
|
const seedFieldMetadataIds = allFieldMetadataIds.slice(0, 3);
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId,
|
|
viewFields: seedFieldMetadataIds.map((fmId, index) => ({
|
|
fieldMetadataId: fmId,
|
|
isVisible: true,
|
|
position: index,
|
|
})),
|
|
},
|
|
});
|
|
|
|
const { data: fieldsData } = await findViewFields({
|
|
viewId,
|
|
gqlFields: 'id fieldMetadataId position isVisible',
|
|
expectToFail: false,
|
|
});
|
|
|
|
testSetup = {
|
|
pageLayoutId,
|
|
pageLayoutTabId,
|
|
widgetId,
|
|
viewId,
|
|
labelIdentifierFieldMetadataId,
|
|
viewFields: fieldsData.getViewFields,
|
|
fieldMetadataIds: allFieldMetadataIds,
|
|
};
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await destroyOnePageLayoutWidget({
|
|
expectToFail: false,
|
|
input: { id: testSetup.widgetId },
|
|
});
|
|
await destroyOneView({
|
|
expectToFail: false,
|
|
viewId: testSetup.viewId,
|
|
});
|
|
await destroyOnePageLayoutTab({
|
|
expectToFail: false,
|
|
input: { id: testSetup.pageLayoutTabId },
|
|
});
|
|
await destroyOnePageLayout({
|
|
expectToFail: false,
|
|
input: { id: testSetup.pageLayoutId },
|
|
});
|
|
});
|
|
|
|
describe('view fields', () => {
|
|
it('should create a new view field via fieldMetadataId', async () => {
|
|
const existingFieldMetadataIds = new Set(
|
|
testSetup.viewFields.map((f) => f.fieldMetadataId),
|
|
);
|
|
const newFieldMetadataId = testSetup.fieldMetadataIds.find(
|
|
(id) => !existingFieldMetadataIds.has(id),
|
|
);
|
|
|
|
expect(newFieldMetadataId).toBeDefined();
|
|
|
|
const { data, errors } = await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFields: [
|
|
...testSetup.viewFields.map((f) => ({
|
|
viewFieldId: f.id,
|
|
fieldMetadataId: f.fieldMetadataId,
|
|
isVisible: f.isVisible,
|
|
position: f.position,
|
|
})),
|
|
{
|
|
fieldMetadataId: newFieldMetadataId!,
|
|
isVisible: true,
|
|
position: 99,
|
|
},
|
|
],
|
|
},
|
|
gqlFields: VIEW_WITH_ALL_RELATIONS_GQL_FIELDS,
|
|
});
|
|
|
|
expect(errors).toBeUndefined();
|
|
expect(data.upsertViewWidget).toBeDefined();
|
|
|
|
const { data: fieldsData } = await findViewFields({
|
|
viewId: testSetup.viewId,
|
|
gqlFields: 'id fieldMetadataId position isVisible',
|
|
expectToFail: false,
|
|
});
|
|
|
|
const createdField = fieldsData.getViewFields.find(
|
|
(f: { fieldMetadataId: string }) =>
|
|
f.fieldMetadataId === newFieldMetadataId!,
|
|
);
|
|
|
|
expect(createdField).toBeDefined();
|
|
expect(createdField!.isVisible).toBe(true);
|
|
expect(createdField!.position).toBe(99);
|
|
});
|
|
|
|
it('should update an existing view field position and visibility', async () => {
|
|
const targetField = testSetup.viewFields.find(
|
|
(field) =>
|
|
field.fieldMetadataId !== testSetup.labelIdentifierFieldMetadataId,
|
|
)!;
|
|
|
|
expect(targetField).toBeDefined();
|
|
|
|
const labelIdentifierField = testSetup.viewFields.find(
|
|
(field) =>
|
|
field.fieldMetadataId === testSetup.labelIdentifierFieldMetadataId,
|
|
);
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFields: [
|
|
...(labelIdentifierField
|
|
? [
|
|
{
|
|
viewFieldId: labelIdentifierField.id,
|
|
fieldMetadataId: labelIdentifierField.fieldMetadataId,
|
|
isVisible: true,
|
|
position: 0,
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
viewFieldId: targetField.id,
|
|
fieldMetadataId: targetField.fieldMetadataId,
|
|
isVisible: false,
|
|
position: 42,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const { data: fieldsData } = await findViewFields({
|
|
viewId: testSetup.viewId,
|
|
gqlFields: 'id isVisible position',
|
|
expectToFail: false,
|
|
});
|
|
|
|
const updatedField = fieldsData.getViewFields.find(
|
|
(f: { id: string }) => f.id === targetField.id,
|
|
);
|
|
|
|
expect(updatedField).toBeDefined();
|
|
expect(updatedField!.isVisible).toBe(false);
|
|
expect(updatedField!.position).toBe(42);
|
|
});
|
|
|
|
it('should create a new view field with an aggregate operation', async () => {
|
|
const { data: fieldsBefore } = await findViewFields({
|
|
viewId: testSetup.viewId,
|
|
gqlFields: 'id fieldMetadataId position isVisible aggregateOperation',
|
|
expectToFail: false,
|
|
});
|
|
|
|
const existingFieldMetadataIds = new Set(
|
|
fieldsBefore.getViewFields.map(
|
|
(field: { fieldMetadataId: string }) => field.fieldMetadataId,
|
|
),
|
|
);
|
|
const newFieldMetadataId = testSetup.fieldMetadataIds.find(
|
|
(id) => !existingFieldMetadataIds.has(id),
|
|
);
|
|
|
|
expect(newFieldMetadataId).toBeDefined();
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFields: [
|
|
...fieldsBefore.getViewFields.map(
|
|
(field: {
|
|
id: string;
|
|
fieldMetadataId: string;
|
|
isVisible: boolean;
|
|
position: number;
|
|
aggregateOperation?: AggregateOperations | null;
|
|
}) => ({
|
|
viewFieldId: field.id,
|
|
fieldMetadataId: field.fieldMetadataId,
|
|
isVisible: field.isVisible,
|
|
position: field.position,
|
|
aggregateOperation: field.aggregateOperation ?? null,
|
|
}),
|
|
),
|
|
{
|
|
fieldMetadataId: newFieldMetadataId!,
|
|
isVisible: true,
|
|
position: 99,
|
|
aggregateOperation: AggregateOperations.SUM,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const { data: fieldsAfter } = await findViewFields({
|
|
viewId: testSetup.viewId,
|
|
gqlFields: 'id fieldMetadataId aggregateOperation',
|
|
expectToFail: false,
|
|
});
|
|
|
|
const createdField = fieldsAfter.getViewFields.find(
|
|
(field: { fieldMetadataId: string }) =>
|
|
field.fieldMetadataId === newFieldMetadataId,
|
|
);
|
|
|
|
expect(createdField).toBeDefined();
|
|
expect(createdField!.aggregateOperation).toBe(AggregateOperations.SUM);
|
|
});
|
|
|
|
it('should update and clear an existing view field aggregate operation', async () => {
|
|
const { data: fieldsBefore } = await findViewFields({
|
|
viewId: testSetup.viewId,
|
|
gqlFields: 'id fieldMetadataId position isVisible aggregateOperation',
|
|
expectToFail: false,
|
|
});
|
|
|
|
const targetField = fieldsBefore.getViewFields.find(
|
|
(field: { fieldMetadataId: string }) =>
|
|
field.fieldMetadataId !== testSetup.labelIdentifierFieldMetadataId,
|
|
);
|
|
|
|
expect(targetField).toBeDefined();
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFields: [
|
|
{
|
|
viewFieldId: targetField!.id,
|
|
fieldMetadataId: targetField!.fieldMetadataId,
|
|
isVisible: targetField!.isVisible,
|
|
position: targetField!.position,
|
|
aggregateOperation: AggregateOperations.COUNT,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const { data: fieldsWithAggregate } = await findViewFields({
|
|
viewId: testSetup.viewId,
|
|
gqlFields: 'id aggregateOperation',
|
|
expectToFail: false,
|
|
});
|
|
|
|
const updatedField = fieldsWithAggregate.getViewFields.find(
|
|
(field: { id: string }) => field.id === targetField!.id,
|
|
);
|
|
|
|
expect(updatedField).toBeDefined();
|
|
expect(updatedField!.aggregateOperation).toBe(AggregateOperations.COUNT);
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFields: [
|
|
{
|
|
viewFieldId: targetField!.id,
|
|
fieldMetadataId: targetField!.fieldMetadataId,
|
|
isVisible: targetField!.isVisible,
|
|
position: targetField!.position,
|
|
aggregateOperation: null,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const { data: fieldsAfterClear } = await findViewFields({
|
|
viewId: testSetup.viewId,
|
|
gqlFields: 'id aggregateOperation',
|
|
expectToFail: false,
|
|
});
|
|
|
|
const clearedField = fieldsAfterClear.getViewFields.find(
|
|
(field: { id: string }) => field.id === targetField!.id,
|
|
);
|
|
|
|
expect(clearedField).toBeDefined();
|
|
expect(clearedField!.aggregateOperation).toBeNull();
|
|
});
|
|
|
|
it('should not modify fields when viewFields is omitted', async () => {
|
|
const { data: fieldsBefore } = await findViewFields({
|
|
viewId: testSetup.viewId,
|
|
gqlFields: 'id position isVisible',
|
|
expectToFail: false,
|
|
});
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewSorts: [],
|
|
},
|
|
});
|
|
|
|
const { data: fieldsAfter } = await findViewFields({
|
|
viewId: testSetup.viewId,
|
|
gqlFields: 'id position isVisible',
|
|
expectToFail: false,
|
|
});
|
|
|
|
expect(fieldsAfter.getViewFields.length).toBe(
|
|
fieldsBefore.getViewFields.length,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('view filter groups', () => {
|
|
it('should create a new filter group', async () => {
|
|
const filterGroupId = uuidv4();
|
|
|
|
const { data, errors } = await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFilterGroups: [
|
|
{
|
|
id: filterGroupId,
|
|
logicalOperator: ViewFilterGroupLogicalOperator.AND,
|
|
},
|
|
],
|
|
},
|
|
gqlFields: VIEW_WITH_ALL_RELATIONS_GQL_FIELDS,
|
|
});
|
|
|
|
expect(errors).toBeUndefined();
|
|
expect(data.upsertViewWidget).toBeDefined();
|
|
|
|
const filterGroups = await global.testDataSource.query(
|
|
`SELECT id, "logicalOperator"
|
|
FROM core."viewFilterGroup"
|
|
WHERE id = $1 AND "deletedAt" IS NULL`,
|
|
[filterGroupId],
|
|
);
|
|
|
|
expect(filterGroups.length).toBe(1);
|
|
expect(filterGroups[0].logicalOperator).toBe(
|
|
ViewFilterGroupLogicalOperator.AND,
|
|
);
|
|
});
|
|
|
|
it('should update an existing filter group logical operator', async () => {
|
|
const filterGroupId = uuidv4();
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFilterGroups: [
|
|
{
|
|
id: filterGroupId,
|
|
logicalOperator: ViewFilterGroupLogicalOperator.AND,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFilterGroups: [
|
|
{
|
|
id: filterGroupId,
|
|
logicalOperator: ViewFilterGroupLogicalOperator.OR,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const filterGroups = await global.testDataSource.query(
|
|
`SELECT id, "logicalOperator"
|
|
FROM core."viewFilterGroup"
|
|
WHERE id = $1 AND "deletedAt" IS NULL`,
|
|
[filterGroupId],
|
|
);
|
|
|
|
expect(filterGroups.length).toBe(1);
|
|
expect(filterGroups[0].logicalOperator).toBe(
|
|
ViewFilterGroupLogicalOperator.OR,
|
|
);
|
|
});
|
|
|
|
it('should remove filter groups not included in the input', async () => {
|
|
const groupToKeepId = uuidv4();
|
|
const groupToRemoveId = uuidv4();
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFilterGroups: [
|
|
{
|
|
id: groupToKeepId,
|
|
logicalOperator: ViewFilterGroupLogicalOperator.AND,
|
|
},
|
|
{
|
|
id: groupToRemoveId,
|
|
logicalOperator: ViewFilterGroupLogicalOperator.OR,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFilterGroups: [
|
|
{
|
|
id: groupToKeepId,
|
|
logicalOperator: ViewFilterGroupLogicalOperator.AND,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const removedGroup = await global.testDataSource.query(
|
|
`SELECT id FROM core."viewFilterGroup"
|
|
WHERE id = $1 AND "deletedAt" IS NULL`,
|
|
[groupToRemoveId],
|
|
);
|
|
|
|
expect(removedGroup.length).toBe(0);
|
|
|
|
const keptGroup = await global.testDataSource.query(
|
|
`SELECT id FROM core."viewFilterGroup"
|
|
WHERE id = $1 AND "deletedAt" IS NULL`,
|
|
[groupToKeepId],
|
|
);
|
|
|
|
expect(keptGroup.length).toBe(1);
|
|
});
|
|
|
|
it('should create a nested filter group with parent reference', async () => {
|
|
const parentGroupId = uuidv4();
|
|
const childGroupId = uuidv4();
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFilterGroups: [
|
|
{
|
|
id: parentGroupId,
|
|
logicalOperator: ViewFilterGroupLogicalOperator.AND,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const { errors } = await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFilterGroups: [
|
|
{
|
|
id: parentGroupId,
|
|
logicalOperator: ViewFilterGroupLogicalOperator.AND,
|
|
},
|
|
{
|
|
id: childGroupId,
|
|
logicalOperator: ViewFilterGroupLogicalOperator.OR,
|
|
parentViewFilterGroupId: parentGroupId,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(errors).toBeUndefined();
|
|
|
|
const childGroup = await global.testDataSource.query(
|
|
`SELECT id, "parentViewFilterGroupId"
|
|
FROM core."viewFilterGroup"
|
|
WHERE id = $1 AND "deletedAt" IS NULL`,
|
|
[childGroupId],
|
|
);
|
|
|
|
expect(childGroup.length).toBe(1);
|
|
expect(childGroup[0].parentViewFilterGroupId).toBe(parentGroupId);
|
|
});
|
|
});
|
|
|
|
describe('view filters', () => {
|
|
it('should create a new filter', async () => {
|
|
const fieldMetadataId = testSetup.fieldMetadataIds[0];
|
|
|
|
const { data, errors } = await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFilters: [
|
|
{
|
|
fieldMetadataId,
|
|
operand: ViewFilterOperand.CONTAINS,
|
|
value: 'test-value',
|
|
},
|
|
],
|
|
},
|
|
gqlFields: VIEW_WITH_ALL_RELATIONS_GQL_FIELDS,
|
|
});
|
|
|
|
expect(errors).toBeUndefined();
|
|
expect(data.upsertViewWidget).toBeDefined();
|
|
|
|
const { data: filtersData } = await findViewFilters({
|
|
viewId: testSetup.viewId,
|
|
gqlFields: 'id fieldMetadataId operand value',
|
|
expectToFail: false,
|
|
});
|
|
|
|
const createdFilter = filtersData.getViewFilters.find(
|
|
(f: { fieldMetadataId: string; value: unknown }) =>
|
|
f.fieldMetadataId === fieldMetadataId && f.value === 'test-value',
|
|
);
|
|
|
|
expect(createdFilter).toBeDefined();
|
|
expect(createdFilter!.operand).toBe(ViewFilterOperand.CONTAINS);
|
|
});
|
|
|
|
it('should update an existing filter value and operand', async () => {
|
|
const filterId = uuidv4();
|
|
const fieldMetadataId = testSetup.fieldMetadataIds[0];
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFilters: [
|
|
{
|
|
id: filterId,
|
|
fieldMetadataId,
|
|
operand: ViewFilterOperand.CONTAINS,
|
|
value: 'initial',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFilters: [
|
|
{
|
|
id: filterId,
|
|
fieldMetadataId,
|
|
operand: ViewFilterOperand.DOES_NOT_CONTAIN,
|
|
value: 'updated',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const { data: filtersData } = await findViewFilters({
|
|
viewId: testSetup.viewId,
|
|
gqlFields: 'id operand value',
|
|
expectToFail: false,
|
|
});
|
|
|
|
const updatedFilter = filtersData.getViewFilters.find(
|
|
(f: { id: string }) => f.id === filterId,
|
|
);
|
|
|
|
expect(updatedFilter).toBeDefined();
|
|
expect(updatedFilter!.operand).toBe(ViewFilterOperand.DOES_NOT_CONTAIN);
|
|
expect(updatedFilter!.value).toBe('updated');
|
|
});
|
|
|
|
it('should remove filters not included in the input', async () => {
|
|
const filterToKeepId = uuidv4();
|
|
const filterToRemoveId = uuidv4();
|
|
const fieldMetadataId = testSetup.fieldMetadataIds[0];
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFilters: [
|
|
{
|
|
id: filterToKeepId,
|
|
fieldMetadataId,
|
|
operand: ViewFilterOperand.CONTAINS,
|
|
value: 'keep',
|
|
},
|
|
{
|
|
id: filterToRemoveId,
|
|
fieldMetadataId,
|
|
operand: ViewFilterOperand.CONTAINS,
|
|
value: 'remove',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFilters: [
|
|
{
|
|
id: filterToKeepId,
|
|
fieldMetadataId,
|
|
operand: ViewFilterOperand.CONTAINS,
|
|
value: 'keep',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const { data: filtersData } = await findViewFilters({
|
|
viewId: testSetup.viewId,
|
|
gqlFields: 'id',
|
|
expectToFail: false,
|
|
});
|
|
|
|
const keptFilter = filtersData.getViewFilters.find(
|
|
(f: { id: string }) => f.id === filterToKeepId,
|
|
);
|
|
|
|
expect(keptFilter).toBeDefined();
|
|
|
|
const removedFilter = filtersData.getViewFilters.find(
|
|
(f: { id: string }) => f.id === filterToRemoveId,
|
|
);
|
|
|
|
expect(removedFilter).toBeUndefined();
|
|
});
|
|
|
|
it('should create a filter within a filter group', async () => {
|
|
const filterGroupId = uuidv4();
|
|
const filterId = uuidv4();
|
|
const fieldMetadataId = testSetup.fieldMetadataIds[0];
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFilterGroups: [
|
|
{
|
|
id: filterGroupId,
|
|
logicalOperator: ViewFilterGroupLogicalOperator.AND,
|
|
},
|
|
],
|
|
viewFilters: [
|
|
{
|
|
id: filterId,
|
|
fieldMetadataId,
|
|
operand: ViewFilterOperand.CONTAINS,
|
|
value: 'grouped-filter',
|
|
viewFilterGroupId: filterGroupId,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const filters = await global.testDataSource.query(
|
|
`SELECT id, "viewFilterGroupId"
|
|
FROM core."viewFilter"
|
|
WHERE id = $1 AND "deletedAt" IS NULL`,
|
|
[filterId],
|
|
);
|
|
|
|
expect(filters.length).toBe(1);
|
|
expect(filters[0].viewFilterGroupId).toBe(filterGroupId);
|
|
});
|
|
});
|
|
|
|
describe('view sorts', () => {
|
|
afterEach(async () => {
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewSorts: [],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should create a new sort', async () => {
|
|
const sortId = uuidv4();
|
|
const fieldMetadataId = testSetup.fieldMetadataIds[0];
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewSorts: [
|
|
{
|
|
id: sortId,
|
|
fieldMetadataId,
|
|
direction: ViewSortDirection.DESC,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const sorts = await global.testDataSource.query(
|
|
`SELECT id, "fieldMetadataId", direction
|
|
FROM core."viewSort"
|
|
WHERE id = $1 AND "deletedAt" IS NULL`,
|
|
[sortId],
|
|
);
|
|
|
|
expect(sorts.length).toBe(1);
|
|
expect(sorts[0].fieldMetadataId).toBe(fieldMetadataId);
|
|
expect(sorts[0].direction).toBe(ViewSortDirection.DESC);
|
|
});
|
|
|
|
it('should update an existing sort direction', async () => {
|
|
const sortId = uuidv4();
|
|
const fieldMetadataId = testSetup.fieldMetadataIds[1];
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewSorts: [
|
|
{
|
|
id: sortId,
|
|
fieldMetadataId,
|
|
direction: ViewSortDirection.ASC,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewSorts: [
|
|
{
|
|
id: sortId,
|
|
fieldMetadataId,
|
|
direction: ViewSortDirection.DESC,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const sorts = await global.testDataSource.query(
|
|
`SELECT id, direction
|
|
FROM core."viewSort"
|
|
WHERE id = $1 AND "deletedAt" IS NULL`,
|
|
[sortId],
|
|
);
|
|
|
|
expect(sorts.length).toBe(1);
|
|
expect(sorts[0].direction).toBe(ViewSortDirection.DESC);
|
|
});
|
|
|
|
it('should remove sorts not included in the input', async () => {
|
|
const sortToKeepId = uuidv4();
|
|
const sortToRemoveId = uuidv4();
|
|
const fieldMetadataId = testSetup.fieldMetadataIds[0];
|
|
const secondFieldMetadataId = testSetup.fieldMetadataIds[1];
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewSorts: [
|
|
{
|
|
id: sortToKeepId,
|
|
fieldMetadataId,
|
|
direction: ViewSortDirection.ASC,
|
|
},
|
|
{
|
|
id: sortToRemoveId,
|
|
fieldMetadataId: secondFieldMetadataId,
|
|
direction: ViewSortDirection.DESC,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewSorts: [
|
|
{
|
|
id: sortToKeepId,
|
|
fieldMetadataId,
|
|
direction: ViewSortDirection.ASC,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const keptSort = await global.testDataSource.query(
|
|
`SELECT id FROM core."viewSort"
|
|
WHERE id = $1 AND "deletedAt" IS NULL`,
|
|
[sortToKeepId],
|
|
);
|
|
|
|
expect(keptSort.length).toBe(1);
|
|
|
|
const removedSort = await global.testDataSource.query(
|
|
`SELECT id FROM core."viewSort"
|
|
WHERE id = $1 AND "deletedAt" IS NULL`,
|
|
[sortToRemoveId],
|
|
);
|
|
|
|
expect(removedSort.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('combined operations', () => {
|
|
it('should upsert view fields, filters, filter groups, and sorts in a single call', async () => {
|
|
const filterGroupId = uuidv4();
|
|
const filterId = uuidv4();
|
|
const sortId = uuidv4();
|
|
const fieldMetadataId = testSetup.fieldMetadataIds[0];
|
|
const targetField = testSetup.viewFields.find(
|
|
(field) =>
|
|
field.fieldMetadataId !== testSetup.labelIdentifierFieldMetadataId,
|
|
)!;
|
|
|
|
expect(targetField).toBeDefined();
|
|
|
|
const labelIdentifierField = testSetup.viewFields.find(
|
|
(field) =>
|
|
field.fieldMetadataId === testSetup.labelIdentifierFieldMetadataId,
|
|
);
|
|
|
|
const { data, errors } = await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFields: [
|
|
...(labelIdentifierField
|
|
? [
|
|
{
|
|
viewFieldId: labelIdentifierField.id,
|
|
fieldMetadataId: labelIdentifierField.fieldMetadataId,
|
|
isVisible: true,
|
|
position: 0,
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
viewFieldId: targetField.id,
|
|
fieldMetadataId: targetField.fieldMetadataId,
|
|
isVisible: true,
|
|
position: 1,
|
|
},
|
|
],
|
|
viewFilterGroups: [
|
|
{
|
|
id: filterGroupId,
|
|
logicalOperator: ViewFilterGroupLogicalOperator.AND,
|
|
},
|
|
],
|
|
viewFilters: [
|
|
{
|
|
id: filterId,
|
|
fieldMetadataId,
|
|
operand: ViewFilterOperand.CONTAINS,
|
|
value: 'combined-test',
|
|
viewFilterGroupId: filterGroupId,
|
|
},
|
|
],
|
|
viewSorts: [
|
|
{
|
|
id: sortId,
|
|
fieldMetadataId,
|
|
direction: ViewSortDirection.DESC,
|
|
},
|
|
],
|
|
},
|
|
gqlFields: VIEW_WITH_ALL_RELATIONS_GQL_FIELDS,
|
|
});
|
|
|
|
expect(errors).toBeUndefined();
|
|
expect(data.upsertViewWidget).toBeDefined();
|
|
expect(data.upsertViewWidget.id).toBeDefined();
|
|
|
|
const filterGroups = await global.testDataSource.query(
|
|
`SELECT id FROM core."viewFilterGroup"
|
|
WHERE id = $1 AND "deletedAt" IS NULL`,
|
|
[filterGroupId],
|
|
);
|
|
|
|
expect(filterGroups.length).toBe(1);
|
|
|
|
const filters = await global.testDataSource.query(
|
|
`SELECT id, value FROM core."viewFilter"
|
|
WHERE id = $1 AND "deletedAt" IS NULL`,
|
|
[filterId],
|
|
);
|
|
|
|
expect(filters.length).toBe(1);
|
|
expect(filters[0].value).toBe('combined-test');
|
|
|
|
const sorts = await global.testDataSource.query(
|
|
`SELECT id, direction FROM core."viewSort"
|
|
WHERE id = $1 AND "deletedAt" IS NULL`,
|
|
[sortId],
|
|
);
|
|
|
|
expect(sorts.length).toBe(1);
|
|
expect(sorts[0].direction).toBe(ViewSortDirection.DESC);
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewSorts: [],
|
|
viewFilters: [],
|
|
viewFilterGroups: [],
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('validation', () => {
|
|
it('should fail when widget id does not exist', async () => {
|
|
const { errors } = await upsertViewWidget({
|
|
expectToFail: true,
|
|
input: {
|
|
widgetId: uuidv4(),
|
|
viewFields: [
|
|
{
|
|
fieldMetadataId: testSetup.fieldMetadataIds[0],
|
|
isVisible: true,
|
|
position: 0,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(errors).toBeDefined();
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should return the view without changes when no sub-entity arrays are provided', async () => {
|
|
const { data, errors } = await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
},
|
|
gqlFields: VIEW_GQL_FIELDS,
|
|
});
|
|
|
|
expect(errors).toBeUndefined();
|
|
expect(data.upsertViewWidget).toBeDefined();
|
|
expect(data.upsertViewWidget.id).toBeDefined();
|
|
});
|
|
|
|
it('should remove all filters when an empty array is provided', async () => {
|
|
const filterId = uuidv4();
|
|
const fieldMetadataId = testSetup.fieldMetadataIds[0];
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFilters: [
|
|
{
|
|
id: filterId,
|
|
fieldMetadataId,
|
|
operand: ViewFilterOperand.CONTAINS,
|
|
value: 'to-be-removed',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const { data: filtersBefore } = await findViewFilters({
|
|
viewId: testSetup.viewId,
|
|
gqlFields: 'id',
|
|
expectToFail: false,
|
|
});
|
|
|
|
const filterExistsBefore = filtersBefore.getViewFilters.find(
|
|
(f: { id: string }) => f.id === filterId,
|
|
);
|
|
|
|
expect(filterExistsBefore).toBeDefined();
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFilters: [],
|
|
},
|
|
});
|
|
|
|
const { data: filtersAfter } = await findViewFilters({
|
|
viewId: testSetup.viewId,
|
|
gqlFields: 'id',
|
|
expectToFail: false,
|
|
});
|
|
|
|
const filterExistsAfter = filtersAfter.getViewFilters.find(
|
|
(f: { id: string }) => f.id === filterId,
|
|
);
|
|
|
|
expect(filterExistsAfter).toBeUndefined();
|
|
});
|
|
|
|
it('should remove all sorts when an empty array is provided', async () => {
|
|
const sortId = uuidv4();
|
|
const fieldMetadataId = testSetup.fieldMetadataIds[3];
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewSorts: [
|
|
{
|
|
id: sortId,
|
|
fieldMetadataId,
|
|
direction: ViewSortDirection.ASC,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewSorts: [],
|
|
},
|
|
});
|
|
|
|
const sortsAfter = await global.testDataSource.query(
|
|
`SELECT id FROM core."viewSort"
|
|
WHERE id = $1 AND "deletedAt" IS NULL`,
|
|
[sortId],
|
|
);
|
|
|
|
expect(sortsAfter.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('return type', () => {
|
|
it('should return a view with the expected fields and relations', async () => {
|
|
const targetField = testSetup.viewFields.find(
|
|
(field) =>
|
|
field.fieldMetadataId !== testSetup.labelIdentifierFieldMetadataId,
|
|
)!;
|
|
|
|
expect(targetField).toBeDefined();
|
|
|
|
const labelIdentifierField = testSetup.viewFields.find(
|
|
(field) =>
|
|
field.fieldMetadataId === testSetup.labelIdentifierFieldMetadataId,
|
|
);
|
|
|
|
const { data } = await upsertViewWidget({
|
|
expectToFail: false,
|
|
input: {
|
|
widgetId: testSetup.widgetId,
|
|
viewFields: [
|
|
...(labelIdentifierField
|
|
? [
|
|
{
|
|
viewFieldId: labelIdentifierField.id,
|
|
fieldMetadataId: labelIdentifierField.fieldMetadataId,
|
|
isVisible: true,
|
|
position: 0,
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
viewFieldId: targetField.id,
|
|
fieldMetadataId: targetField.fieldMetadataId,
|
|
isVisible: true,
|
|
position: 1,
|
|
},
|
|
],
|
|
},
|
|
gqlFields: VIEW_WITH_ALL_RELATIONS_GQL_FIELDS,
|
|
});
|
|
|
|
const view = data.upsertViewWidget;
|
|
|
|
expect(view.id).toBeDefined();
|
|
expect(view.name).toBeDefined();
|
|
expect(view.objectMetadataId).toBeDefined();
|
|
expect(view.workspaceId).toBeDefined();
|
|
expect(view.createdAt).toBeDefined();
|
|
expect(view.updatedAt).toBeDefined();
|
|
expect(Array.isArray(view.viewFields)).toBe(true);
|
|
expect(Array.isArray(view.viewFilters)).toBe(true);
|
|
expect(Array.isArray(view.viewFilterGroups)).toBe(true);
|
|
expect(Array.isArray(view.viewSorts)).toBe(true);
|
|
});
|
|
});
|
|
});
|