feat: add atomic upsertFieldsWidget mutation to replace multiple view field group/field API calls (#18137)
The `useSaveFieldsWidgetGroups` hook was making multiple sequential API
calls per widget (create groups, delete groups, update groups, update
fields) — non-atomic, chatty, and with heavy diff computation on the
frontend.
## Backend
- **New input DTOs**: `UpsertFieldsWidgetInput` /
`UpsertFieldsWidgetGroupInput` / `UpsertFieldsWidgetFieldInput` — caller
passes the full desired state of groups + fields for a widget
- **`FieldsWidgetUpsertService`**: looks up widget → resolves `viewId`,
diffs against existing flat entity maps, builds optimistic group maps so
newly-created groups can be referenced by field updates in the same
pass, then runs creates/updates/deletes in a single
`validateBuildAndRunWorkspaceMigration` call
- **`upsertFieldsWidget` mutation** added to `ViewFieldGroupResolver`;
new `FIELDS_WIDGET_NOT_FOUND` exception code added and wired into the
GraphQL exception filter
## Frontend
- New `UPSERT_FIELDS_WIDGET` GQL document
- `useSaveFieldsWidgetGroups` replaces all per-operation calls with a
single mutation per widget, passing the full draft state — diff logic
moves entirely to the backend
```graphql
mutation UpsertFieldsWidget($input: UpsertFieldsWidgetInput!) {
upsertFieldsWidget(input: $input) {
id
name
position
isVisible
viewId
viewFields { id isVisible position ... }
}
}
```
Widget IDs are collected from both draft **and** persisted state so
widgets whose draft groups were cleared still trigger deletion of their
server-side groups.
<!-- START COPILOT ORIGINAL PROMPT -->
<details>
<summary>Original prompt</summary>
Start implementation
The user has attached the following file paths as relevant context:
- CLAUDE.md
<analysis>
[Chronological Review: The conversation began with the user requesting a
review of how updates on views are stored in the frontend of record page
layouts. The user expressed a desire to migrate heavy computations and
multiple requests from the frontend to the backend. They proposed
creating a custom upsertFieldsWidget mutation to handle updates
atomically. The conversation has focused on this migration strategy and
its implementation.]
[Intent Mapping: The user explicitly requested to "check how we store
updates on views in the frontend of record page layouts" and indicated a
need to "migrate this task to the backend" by creating a custom mutation
for atomic updates.]
[Technical Inventory: The conversation involves the following technical
concepts:
- Frontend and backend architecture for handling page layout updates.
- Custom GraphQL mutation (upsertFieldsWidget) for atomic updates.
- The file in focus is page-layout-update.service.ts, which is likely
responsible for handling updates related to page layouts in the
backend.]
[Code Archaeology: The specific file being worked on is
/Users/devessier/Web/twenty/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout-update.service.ts.
The user is likely looking to modify or enhance this service to
accommodate the new mutation for handling updates.]
[Progress Assessment: The user has identified the need for a backend
migration strategy but has not yet implemented any changes. The focus is
on planning the transition from frontend to backend processing.]
[Context Validation: All critical information for continuation is
captured, including the user's intent to migrate computations to the
backend and the specific file being modified.]
[Recent Commands Analysis: The last agent commands involved checking the
current file context and the user's request to summarize the
conversation. No specific tool results were generated as the focus was
on summarizing the conversation history. The agent was actively working
on summarizing the conversation when the token budget was exceeded,
which was triggered by the user's request for a summary.]
</analysis>
<summary>
1. Conversation Overview:
- Primary Objectives: The user requested to "check how we store updates
on views in the frontend of record page layouts" and expressed a desire
to "migrate this task to the backend" by creating a custom
upsertFieldsWidget mutation for atomic updates.
- Session Context: The conversation has focused on the need to shift
heavy computations from the frontend to the backend, with an emphasis on
implementing a new mutation to streamline updates.
- User Intent Evolution: The user's needs have remained consistent,
focusing on backend migration for efficiency and atomicity in updates.
2. Technical Foundation:
- Core Technology: The conversation involves a backend service likely
built with Node.js and TypeScript, as indicated by the file path.
- Framework/Library: The use of GraphQL for handling mutations is
implied through the mention of a custom mutation.
- Architectural Pattern: The user is looking to implement a
service-oriented architecture where the backend handles complex
computations instead of the frontend.
- Environment Detail: The current working file is located in a server
package of the twenty repository.
3. Codebase Status:
- File Name: page-layout-update.service.ts
- Purpose: This file is responsible for managing updates related to page
layouts in the backend.
- Current State: The user is considering modifications to implement a
new mutation for handling updates.
- Key Code Segments: Specific functions or classes have not been
detailed yet, as the focus is on planning changes.
- Dependencies: This service likely interacts with other components in
the metadata-modules related to page layouts.
4. Problem Resolution:
- Issues Encountered: The current challenge is the inefficiency of
handling updates in the frontend.
- Solutions Implemented: The proposed solution is to create a custom
mutation to handle updates atomically in the backend.
- Debugging Context: No ongoing troubleshooting efforts have been
mentioned yet.
- Lessons Learned: The need for backend processing to improve
performance has been highlighted.
5. Progress Tracking:
- Completed Tasks: No tasks have been completed yet; the user is in the
planning phase.
- Partially Complete Work: The user is preparing to implement a new
mutation for backend updates.
- Validated Outcomes: No features have been confirmed working through
testing at this stage.
6. Active Work State:
- Current Focus: The user is focused on modifying the
page-layout-update.service.ts to implement the new mutation.
- Recent Context: The last few exchanges involved discussing the
migration of update tasks from the frontend to the backend.
- Working Code: No specific code snippets have been modified yet, as the
co...
</details>
<!-- START COPILOT CODING AGENT SUFFIX -->
Created from [VS
Code](https://code.visualstudio.com/docs/copilot/copilot-coding-agent).
<!-- START COPILOT CODING AGENT TIPS -->
---
💡 You can make Copilot smarter by setting up custom instructions,
customizing its development environment and configuring Model Context
Protocol (MCP) servers. Learn more [Copilot coding agent
tips](https://gh.io/copilot-coding-agent-tips) in the docs.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Devessier <29370468+Devessier@users.noreply.github.com>
Co-authored-by: Baptiste Devessier <baptiste@devessier.fr>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
|
||||
import { VIEW_FRAGMENT } from '@/views/graphql/fragments/viewFragment';
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UPSERT_FIELDS_WIDGET = gql`
|
||||
${VIEW_FRAGMENT}
|
||||
mutation UpsertFieldsWidget($input: UpsertFieldsWidgetInput!) {
|
||||
upsertFieldsWidget(input: $input) {
|
||||
...ViewFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,18 +1,36 @@
|
||||
import { UPSERT_FIELDS_WIDGET } from '@/page-layout/graphql/mutations/upsertFieldsWidget';
|
||||
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
|
||||
import { fieldsWidgetGroupsPersistedComponentState } from '@/page-layout/states/fieldsWidgetGroupsPersistedComponentState';
|
||||
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
|
||||
import { computeFieldsWidgetFieldDiff } from '@/page-layout/utils/computeFieldsWidgetFieldDiff';
|
||||
import { computeFieldsWidgetGroupDiff } from '@/page-layout/utils/computeFieldsWidgetGroupDiff';
|
||||
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
|
||||
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
|
||||
import { usePerformViewFieldGroupAPIPersist } from '@/views/hooks/internal/usePerformViewFieldGroupAPIPersist';
|
||||
import { useRefreshAllCoreViews } from '@/views/hooks/useRefreshAllCoreViews';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
type FieldsConfiguration,
|
||||
WidgetConfigurationType,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { type ViewFragmentFragment } from '~/generated-metadata/graphql';
|
||||
|
||||
type UpsertFieldsWidgetInput = {
|
||||
widgetId: string;
|
||||
groups?: {
|
||||
id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
isVisible: boolean;
|
||||
fields: {
|
||||
viewFieldId: string;
|
||||
isVisible: boolean;
|
||||
position: number;
|
||||
}[];
|
||||
}[];
|
||||
fields?: {
|
||||
viewFieldId: string;
|
||||
isVisible: boolean;
|
||||
position: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
type UpsertFieldsWidgetResult = {
|
||||
upsertFieldsWidget: ViewFragmentFragment;
|
||||
};
|
||||
|
||||
type UseSaveFieldsWidgetGroupsParams = {
|
||||
pageLayoutId: string;
|
||||
@@ -31,52 +49,13 @@ export const useSaveFieldsWidgetGroups = ({
|
||||
pageLayoutId,
|
||||
);
|
||||
|
||||
const pageLayoutPersistedState = useRecoilComponentCallbackState(
|
||||
pageLayoutPersistedComponentState,
|
||||
pageLayoutId,
|
||||
);
|
||||
|
||||
const {
|
||||
performViewFieldGroupAPICreate,
|
||||
performViewFieldGroupAPIUpdate,
|
||||
performViewFieldGroupAPIDelete,
|
||||
} = usePerformViewFieldGroupAPIPersist();
|
||||
|
||||
const { performViewFieldAPIUpdate } = usePerformViewFieldAPIPersist();
|
||||
const [upsertFieldsWidgetMutation] = useMutation<
|
||||
UpsertFieldsWidgetResult,
|
||||
{ input: UpsertFieldsWidgetInput }
|
||||
>(UPSERT_FIELDS_WIDGET);
|
||||
|
||||
const { refreshAllCoreViews } = useRefreshAllCoreViews();
|
||||
|
||||
const getViewIdForWidget = useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
(widgetId: string): string | null => {
|
||||
const pageLayoutPersisted = snapshot
|
||||
.getLoadable(pageLayoutPersistedState)
|
||||
.getValue();
|
||||
|
||||
if (!isDefined(pageLayoutPersisted)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const tab of pageLayoutPersisted.tabs) {
|
||||
for (const widget of tab.widgets) {
|
||||
if (
|
||||
widget.id === widgetId &&
|
||||
isDefined(widget.configuration) &&
|
||||
widget.configuration.configurationType ===
|
||||
WidgetConfigurationType.FIELDS
|
||||
) {
|
||||
return (
|
||||
(widget.configuration as FieldsConfiguration).viewId ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[pageLayoutPersistedState],
|
||||
);
|
||||
|
||||
const saveFieldsWidgetGroups = useRecoilCallback(
|
||||
({ set, snapshot }) =>
|
||||
async () => {
|
||||
@@ -94,73 +73,33 @@ export const useSaveFieldsWidgetGroups = ({
|
||||
|
||||
for (const widgetId of widgetIds) {
|
||||
const draftGroups = allDraftGroups[widgetId] ?? [];
|
||||
const persistedGroups = allPersistedGroups[widgetId] ?? [];
|
||||
|
||||
if (draftGroups.length === 0 && persistedGroups.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const viewId = getViewIdForWidget(widgetId);
|
||||
|
||||
if (!isDefined(viewId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { createdGroups, deletedGroups, updatedGroups } =
|
||||
computeFieldsWidgetGroupDiff(persistedGroups, draftGroups);
|
||||
|
||||
if (createdGroups.length > 0) {
|
||||
await performViewFieldGroupAPICreate({
|
||||
inputs: createdGroups.map((group) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
position: group.position,
|
||||
isVisible: group.isVisible,
|
||||
viewId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (deletedGroups.length > 0) {
|
||||
for (const group of deletedGroups) {
|
||||
await performViewFieldGroupAPIDelete([
|
||||
{ input: { id: group.id } },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedGroups.length > 0) {
|
||||
const updates = updatedGroups.map((group) => ({
|
||||
await upsertFieldsWidgetMutation({
|
||||
variables: {
|
||||
input: {
|
||||
id: group.id,
|
||||
update: {
|
||||
widgetId,
|
||||
groups: draftGroups.map((group) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
position: group.position,
|
||||
isVisible: group.isVisible,
|
||||
},
|
||||
fields: group.fields.flatMap((field) => {
|
||||
if (!isDefined(field.viewFieldId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
viewFieldId: field.viewFieldId,
|
||||
isVisible: field.isVisible,
|
||||
position: field.position,
|
||||
},
|
||||
];
|
||||
}),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
await performViewFieldGroupAPIUpdate(updates);
|
||||
}
|
||||
|
||||
const fieldUpdates = computeFieldsWidgetFieldDiff(
|
||||
persistedGroups,
|
||||
draftGroups,
|
||||
);
|
||||
|
||||
if (fieldUpdates.length > 0) {
|
||||
const viewFieldUpdateInputs = fieldUpdates.map(
|
||||
({ viewFieldId, ...updates }) => ({
|
||||
input: {
|
||||
id: viewFieldId,
|
||||
update: updates,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await performViewFieldAPIUpdate(viewFieldUpdateInputs);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
set(fieldsWidgetGroupsPersistedState, allDraftGroups);
|
||||
@@ -172,11 +111,7 @@ export const useSaveFieldsWidgetGroups = ({
|
||||
[
|
||||
fieldsWidgetGroupsDraftState,
|
||||
fieldsWidgetGroupsPersistedState,
|
||||
getViewIdForWidget,
|
||||
performViewFieldGroupAPICreate,
|
||||
performViewFieldGroupAPIDelete,
|
||||
performViewFieldGroupAPIUpdate,
|
||||
performViewFieldAPIUpdate,
|
||||
upsertFieldsWidgetMutation,
|
||||
refreshAllCoreViews,
|
||||
],
|
||||
);
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsBoolean, IsNotEmpty, IsNumber, IsUUID } from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@InputType()
|
||||
export class UpsertFieldsWidgetFieldInput {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
@Field(() => UUIDScalarType, { description: 'The id of the view field' })
|
||||
viewFieldId: string;
|
||||
|
||||
@IsBoolean()
|
||||
@Field()
|
||||
isVisible: boolean;
|
||||
|
||||
@IsNumber()
|
||||
@Field()
|
||||
position: number;
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsString,
|
||||
IsUUID,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { UpsertFieldsWidgetFieldInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input';
|
||||
|
||||
@InputType()
|
||||
export class UpsertFieldsWidgetGroupInput {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
name: string;
|
||||
|
||||
@IsNumber()
|
||||
@Field()
|
||||
position: number;
|
||||
|
||||
@IsBoolean()
|
||||
@Field()
|
||||
isVisible: boolean;
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UpsertFieldsWidgetFieldInput)
|
||||
@Field(() => [UpsertFieldsWidgetFieldInput])
|
||||
fields: UpsertFieldsWidgetFieldInput[];
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
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 { UpsertFieldsWidgetFieldInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input';
|
||||
import { UpsertFieldsWidgetGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-group.input';
|
||||
|
||||
@InputType()
|
||||
export class UpsertFieldsWidgetInput {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
@Field(() => UUIDScalarType, {
|
||||
description:
|
||||
'The id of the fields widget whose groups and fields to upsert',
|
||||
})
|
||||
widgetId: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UpsertFieldsWidgetGroupInput)
|
||||
@Field(() => [UpsertFieldsWidgetGroupInput], {
|
||||
nullable: true,
|
||||
description:
|
||||
'The groups (with nested fields) to upsert. Mutually exclusive with "fields".',
|
||||
})
|
||||
groups?: UpsertFieldsWidgetGroupInput[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UpsertFieldsWidgetFieldInput)
|
||||
@Field(() => [UpsertFieldsWidgetFieldInput], {
|
||||
nullable: true,
|
||||
description:
|
||||
'The ungrouped fields to upsert. When provided, all existing groups are deleted and fields are detached from groups. Mutually exclusive with "groups".',
|
||||
})
|
||||
fields?: UpsertFieldsWidgetFieldInput[];
|
||||
}
|
||||
+1
@@ -19,5 +19,6 @@ export class ViewFieldGroupException extends CustomException<ViewFieldGroupExcep
|
||||
export enum ViewFieldGroupExceptionCode {
|
||||
VIEW_FIELD_GROUP_NOT_FOUND = 'VIEW_FIELD_GROUP_NOT_FOUND',
|
||||
VIEW_NOT_FOUND = 'VIEW_NOT_FOUND',
|
||||
FIELDS_WIDGET_NOT_FOUND = 'FIELDS_WIDGET_NOT_FOUND',
|
||||
INVALID_VIEW_FIELD_GROUP_DATA = 'INVALID_VIEW_FIELD_GROUP_DATA',
|
||||
}
|
||||
|
||||
+20
-1
@@ -20,17 +20,24 @@ import { CreateViewFieldGroupInput } from 'src/engine/metadata-modules/view-fiel
|
||||
import { DeleteViewFieldGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/delete-view-field-group.input';
|
||||
import { DestroyViewFieldGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/destroy-view-field-group.input';
|
||||
import { UpdateViewFieldGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/update-view-field-group.input';
|
||||
import { UpsertFieldsWidgetInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input';
|
||||
import { ViewFieldGroupDTO } from 'src/engine/metadata-modules/view-field-group/dtos/view-field-group.dto';
|
||||
import { ViewFieldGroupEntity } from 'src/engine/metadata-modules/view-field-group/entities/view-field-group.entity';
|
||||
import { FieldsWidgetUpsertService } from 'src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service';
|
||||
import { ViewFieldGroupService } from 'src/engine/metadata-modules/view-field-group/services/view-field-group.service';
|
||||
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
|
||||
import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
|
||||
import { type ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
|
||||
import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/utils/view-graphql-api-exception.filter';
|
||||
|
||||
@MetadataResolver(() => ViewFieldGroupDTO)
|
||||
@UseFilters(ViewGraphqlApiExceptionFilter)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
export class ViewFieldGroupResolver {
|
||||
constructor(private readonly viewFieldGroupService: ViewFieldGroupService) {}
|
||||
constructor(
|
||||
private readonly viewFieldGroupService: ViewFieldGroupService,
|
||||
private readonly fieldsWidgetUpsertService: FieldsWidgetUpsertService,
|
||||
) {}
|
||||
|
||||
@Query(() => [ViewFieldGroupDTO])
|
||||
@UseGuards(NoPermissionGuard)
|
||||
@@ -113,6 +120,18 @@ export class ViewFieldGroupResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => ViewDTO)
|
||||
@UseGuards(NoPermissionGuard)
|
||||
async upsertFieldsWidget(
|
||||
@Args('input') input: UpsertFieldsWidgetInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ViewEntity> {
|
||||
return await this.fieldsWidgetUpsertService.upsertFieldsWidget({
|
||||
input,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => [ViewFieldDTO])
|
||||
async viewFields(
|
||||
@Parent() viewFieldGroup: ViewFieldGroupDTO,
|
||||
|
||||
+444
@@ -0,0 +1,444 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
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 { isFlatPageLayoutWidgetConfigurationOfType } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/is-flat-page-layout-widget-configuration-of-type.util';
|
||||
import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type';
|
||||
import { type FlatViewFieldGroup } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group.type';
|
||||
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.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 { type UpsertFieldsWidgetFieldInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input';
|
||||
import { UpsertFieldsWidgetGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-group.input';
|
||||
import { UpsertFieldsWidgetInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input';
|
||||
import {
|
||||
ViewFieldGroupException,
|
||||
ViewFieldGroupExceptionCode,
|
||||
} from 'src/engine/metadata-modules/view-field-group/exceptions/view-field-group.exception';
|
||||
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class FieldsWidgetUpsertService {
|
||||
constructor(
|
||||
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
||||
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
@InjectRepository(ViewEntity)
|
||||
private readonly viewRepository: Repository<ViewEntity>,
|
||||
) {}
|
||||
|
||||
async upsertFieldsWidget({
|
||||
input,
|
||||
workspaceId,
|
||||
}: {
|
||||
input: UpsertFieldsWidgetInput;
|
||||
workspaceId: string;
|
||||
}): Promise<ViewEntity> {
|
||||
const hasGroups = isDefined(input.groups);
|
||||
const hasFields = isDefined(input.fields);
|
||||
|
||||
if (hasGroups === hasFields) {
|
||||
throw new ViewFieldGroupException(
|
||||
t`Exactly one of "groups" or "fields" must be provided`,
|
||||
ViewFieldGroupExceptionCode.INVALID_VIEW_FIELD_GROUP_DATA,
|
||||
);
|
||||
}
|
||||
|
||||
const { widgetId } = input;
|
||||
|
||||
const { workspaceCustomFlatApplication } =
|
||||
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
const {
|
||||
flatPageLayoutWidgetMaps,
|
||||
flatViewFieldGroupMaps,
|
||||
flatViewFieldMaps,
|
||||
flatViewMaps,
|
||||
} =
|
||||
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: [
|
||||
'flatPageLayoutWidgetMaps',
|
||||
'flatViewFieldGroupMaps',
|
||||
'flatViewFieldMaps',
|
||||
'flatViewMaps',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const widget = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: widgetId,
|
||||
flatEntityMaps: flatPageLayoutWidgetMaps,
|
||||
});
|
||||
|
||||
if (
|
||||
!isDefined(widget) ||
|
||||
!isFlatPageLayoutWidgetConfigurationOfType(
|
||||
widget,
|
||||
WidgetConfigurationType.FIELDS,
|
||||
)
|
||||
) {
|
||||
throw new ViewFieldGroupException(
|
||||
t`Fields widget not found`,
|
||||
ViewFieldGroupExceptionCode.FIELDS_WIDGET_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const viewId = widget.configuration.viewId;
|
||||
|
||||
if (!isDefined(viewId)) {
|
||||
throw new ViewFieldGroupException(
|
||||
t`Fields widget has no associated view`,
|
||||
ViewFieldGroupExceptionCode.VIEW_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const existingGroups = Object.values(
|
||||
flatViewFieldGroupMaps.byUniversalIdentifier,
|
||||
)
|
||||
.filter(isDefined)
|
||||
.filter(
|
||||
(group) => !isDefined(group.deletedAt) && group.viewId === viewId,
|
||||
);
|
||||
|
||||
const existingViewFields = Object.values(
|
||||
flatViewFieldMaps.byUniversalIdentifier,
|
||||
)
|
||||
.filter(isDefined)
|
||||
.filter(
|
||||
(field) => !isDefined(field.deletedAt) && field.viewId === viewId,
|
||||
);
|
||||
|
||||
if (hasGroups) {
|
||||
await this.upsertFieldsWidgetWithGroups({
|
||||
inputGroups: input.groups!,
|
||||
existingGroups,
|
||||
existingViewFields,
|
||||
viewId,
|
||||
workspaceId,
|
||||
applicationId: workspaceCustomFlatApplication.id,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
flatViewMaps,
|
||||
flatViewFieldGroupMaps,
|
||||
});
|
||||
} else {
|
||||
await this.upsertFieldsWidgetWithFields({
|
||||
inputFields: input.fields!,
|
||||
existingGroups,
|
||||
existingViewFields,
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
});
|
||||
}
|
||||
|
||||
const view = await this.viewRepository.findOne({
|
||||
where: { id: viewId, workspaceId, deletedAt: IsNull() },
|
||||
});
|
||||
|
||||
if (!isDefined(view)) {
|
||||
throw new ViewFieldGroupException(
|
||||
t`View not found after upsert`,
|
||||
ViewFieldGroupExceptionCode.VIEW_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
private async upsertFieldsWidgetWithGroups({
|
||||
inputGroups,
|
||||
existingGroups,
|
||||
existingViewFields,
|
||||
viewId,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
applicationUniversalIdentifier,
|
||||
flatViewMaps,
|
||||
flatViewFieldGroupMaps,
|
||||
}: {
|
||||
inputGroups: UpsertFieldsWidgetGroupInput[];
|
||||
existingGroups: FlatViewFieldGroup[];
|
||||
existingViewFields: FlatViewField[];
|
||||
viewId: string;
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
applicationUniversalIdentifier: string;
|
||||
flatViewMaps: FlatViewMaps;
|
||||
flatViewFieldGroupMaps: FlatViewFieldGroupMaps;
|
||||
}): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
const inputGroupIds = new Set(inputGroups.map((g) => g.id));
|
||||
|
||||
const groupsToCreate: FlatViewFieldGroup[] = [];
|
||||
const groupsToUpdate: FlatViewFieldGroup[] = [];
|
||||
const groupsToDelete: FlatViewFieldGroup[] = [];
|
||||
|
||||
for (const inputGroup of inputGroups) {
|
||||
const existingGroup = existingGroups.find((g) => g.id === inputGroup.id);
|
||||
|
||||
if (!isDefined(existingGroup)) {
|
||||
groupsToCreate.push(
|
||||
this.buildGroupToCreate({
|
||||
inputGroup,
|
||||
viewId,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
applicationUniversalIdentifier,
|
||||
now,
|
||||
flatViewMaps,
|
||||
}),
|
||||
);
|
||||
} else if (this.hasGroupChanged(existingGroup, inputGroup)) {
|
||||
groupsToUpdate.push({
|
||||
...existingGroup,
|
||||
name: inputGroup.name,
|
||||
position: inputGroup.position,
|
||||
isVisible: inputGroup.isVisible,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const existingGroup of existingGroups) {
|
||||
if (!inputGroupIds.has(existingGroup.id)) {
|
||||
groupsToDelete.push(existingGroup);
|
||||
}
|
||||
}
|
||||
|
||||
// Build optimistic maps so that newly created groups can be resolved when
|
||||
// computing viewFieldGroupUniversalIdentifier for view field updates.
|
||||
const optimisticFlatViewFieldGroupMaps: FlatViewFieldGroupMaps =
|
||||
groupsToCreate.reduce(
|
||||
(maps, group) =>
|
||||
addFlatEntityToFlatEntityMapsOrThrow({
|
||||
flatEntity: group,
|
||||
flatEntityMaps: maps,
|
||||
}),
|
||||
flatViewFieldGroupMaps,
|
||||
);
|
||||
|
||||
const viewFieldsToUpdate = existingViewFields.flatMap((existingField) => {
|
||||
const inputGroup = inputGroups.find((g) =>
|
||||
g.fields.some((f) => f.viewFieldId === existingField.id),
|
||||
);
|
||||
|
||||
if (!isDefined(inputGroup)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const inputField = inputGroup.fields.find(
|
||||
(f) => f.viewFieldId === existingField.id,
|
||||
);
|
||||
|
||||
if (!isDefined(inputField)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const newViewFieldGroupId = inputGroup.id;
|
||||
|
||||
const hasChanged =
|
||||
existingField.isVisible !== inputField.isVisible ||
|
||||
existingField.position !== inputField.position ||
|
||||
existingField.viewFieldGroupId !== newViewFieldGroupId;
|
||||
|
||||
if (!hasChanged) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { viewFieldGroupUniversalIdentifier } =
|
||||
resolveEntityRelationUniversalIdentifiers({
|
||||
metadataName: 'viewField',
|
||||
foreignKeyValues: {
|
||||
viewFieldGroupId: newViewFieldGroupId,
|
||||
},
|
||||
flatEntityMaps: {
|
||||
flatViewFieldGroupMaps: optimisticFlatViewFieldGroupMaps,
|
||||
},
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
...existingField,
|
||||
isVisible: inputField.isVisible,
|
||||
position: inputField.position,
|
||||
viewFieldGroupId: newViewFieldGroupId,
|
||||
viewFieldGroupUniversalIdentifier,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const validateAndBuildResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
allFlatEntityOperationByMetadataName: {
|
||||
viewFieldGroup: {
|
||||
flatEntityToCreate: groupsToCreate,
|
||||
flatEntityToDelete: groupsToDelete,
|
||||
flatEntityToUpdate: groupsToUpdate,
|
||||
},
|
||||
viewField: {
|
||||
flatEntityToCreate: [],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: viewFieldsToUpdate,
|
||||
},
|
||||
},
|
||||
workspaceId,
|
||||
isSystemBuild: false,
|
||||
applicationUniversalIdentifier,
|
||||
},
|
||||
);
|
||||
|
||||
if (validateAndBuildResult.status === 'fail') {
|
||||
throw new WorkspaceMigrationBuilderException(
|
||||
validateAndBuildResult,
|
||||
'Multiple validation errors occurred while upserting fields widget',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertFieldsWidgetWithFields({
|
||||
inputFields,
|
||||
existingGroups,
|
||||
existingViewFields,
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
}: {
|
||||
inputFields: UpsertFieldsWidgetFieldInput[];
|
||||
existingGroups: FlatViewFieldGroup[];
|
||||
existingViewFields: FlatViewField[];
|
||||
workspaceId: string;
|
||||
applicationUniversalIdentifier: string;
|
||||
}): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const groupsToDelete: FlatViewFieldGroup[] = [...existingGroups];
|
||||
|
||||
const viewFieldsToUpdate = existingViewFields.flatMap((existingField) => {
|
||||
const inputField = inputFields.find(
|
||||
(f) => f.viewFieldId === existingField.id,
|
||||
);
|
||||
|
||||
if (!isDefined(inputField)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hasChanged =
|
||||
existingField.isVisible !== inputField.isVisible ||
|
||||
existingField.position !== inputField.position ||
|
||||
existingField.viewFieldGroupId !== null;
|
||||
|
||||
if (!hasChanged) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
...existingField,
|
||||
isVisible: inputField.isVisible,
|
||||
position: inputField.position,
|
||||
viewFieldGroupId: null,
|
||||
viewFieldGroupUniversalIdentifier: null,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const validateAndBuildResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
allFlatEntityOperationByMetadataName: {
|
||||
viewFieldGroup: {
|
||||
flatEntityToCreate: [],
|
||||
flatEntityToDelete: groupsToDelete,
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
viewField: {
|
||||
flatEntityToCreate: [],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: viewFieldsToUpdate,
|
||||
},
|
||||
},
|
||||
workspaceId,
|
||||
isSystemBuild: false,
|
||||
applicationUniversalIdentifier,
|
||||
},
|
||||
);
|
||||
|
||||
if (validateAndBuildResult.status === 'fail') {
|
||||
throw new WorkspaceMigrationBuilderException(
|
||||
validateAndBuildResult,
|
||||
'Multiple validation errors occurred while upserting fields widget',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private buildGroupToCreate({
|
||||
inputGroup,
|
||||
viewId,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
applicationUniversalIdentifier,
|
||||
now,
|
||||
flatViewMaps,
|
||||
}: {
|
||||
inputGroup: UpsertFieldsWidgetGroupInput;
|
||||
viewId: string;
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
applicationUniversalIdentifier: string;
|
||||
now: string;
|
||||
flatViewMaps: FlatViewMaps;
|
||||
}): FlatViewFieldGroup {
|
||||
const { viewUniversalIdentifier } =
|
||||
resolveEntityRelationUniversalIdentifiers({
|
||||
metadataName: 'viewFieldGroup',
|
||||
foreignKeyValues: { viewId },
|
||||
flatEntityMaps: { flatViewMaps },
|
||||
});
|
||||
|
||||
return {
|
||||
id: inputGroup.id,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
universalIdentifier: inputGroup.id,
|
||||
applicationUniversalIdentifier,
|
||||
name: inputGroup.name,
|
||||
position: inputGroup.position,
|
||||
isVisible: inputGroup.isVisible,
|
||||
viewId,
|
||||
viewUniversalIdentifier,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
deletedAt: null,
|
||||
viewFieldIds: [],
|
||||
viewFieldUniversalIdentifiers: [],
|
||||
};
|
||||
}
|
||||
|
||||
private hasGroupChanged(
|
||||
existing: FlatViewFieldGroup,
|
||||
input: UpsertFieldsWidgetGroupInput,
|
||||
): boolean {
|
||||
return (
|
||||
existing.name !== input.name ||
|
||||
existing.position !== input.position ||
|
||||
existing.isVisible !== input.isVisible
|
||||
);
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -6,6 +6,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { ViewFieldGroupEntity } from 'src/engine/metadata-modules/view-field-group/entities/view-field-group.entity';
|
||||
import { ViewFieldGroupResolver } from 'src/engine/metadata-modules/view-field-group/resolvers/view-field-group.resolver';
|
||||
import { FieldsWidgetUpsertService } from 'src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service';
|
||||
import { ViewFieldGroupService } from 'src/engine/metadata-modules/view-field-group/services/view-field-group.service';
|
||||
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
@@ -20,7 +21,11 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
WorkspaceMigrationModule,
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
],
|
||||
providers: [ViewFieldGroupResolver, ViewFieldGroupService],
|
||||
providers: [
|
||||
ViewFieldGroupResolver,
|
||||
ViewFieldGroupService,
|
||||
FieldsWidgetUpsertService,
|
||||
],
|
||||
exports: [ViewFieldGroupService],
|
||||
})
|
||||
export class ViewFieldGroupModule {}
|
||||
|
||||
@@ -30,6 +30,9 @@ export class ViewFieldDTO {
|
||||
@Field(() => UUIDScalarType, { nullable: false })
|
||||
viewId: string;
|
||||
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
viewFieldGroupId?: string | null;
|
||||
|
||||
@Field(() => UUIDScalarType, { nullable: false })
|
||||
workspaceId: string;
|
||||
|
||||
|
||||
+2
@@ -87,6 +87,8 @@ export const viewGraphqlApiExceptionHandler = (error: Error, i18n: I18n) => {
|
||||
throw new NotFoundError(error.message);
|
||||
case ViewFieldGroupExceptionCode.VIEW_NOT_FOUND:
|
||||
throw new NotFoundError(error.message);
|
||||
case ViewFieldGroupExceptionCode.FIELDS_WIDGET_NOT_FOUND:
|
||||
throw new NotFoundError(error.message);
|
||||
case ViewFieldGroupExceptionCode.INVALID_VIEW_FIELD_GROUP_DATA:
|
||||
throw new UserInputError(error.message, {
|
||||
userFriendlyMessage: error.userFriendlyMessage,
|
||||
|
||||
@@ -23,6 +23,7 @@ export const VIEW_FIELD_GQL_FIELDS = `
|
||||
isVisible
|
||||
size
|
||||
viewId
|
||||
viewFieldGroupId
|
||||
createdAt
|
||||
updatedAt
|
||||
deletedAt
|
||||
|
||||
+504
@@ -0,0 +1,504 @@
|
||||
import {
|
||||
VIEW_FIELD_GQL_FIELDS,
|
||||
VIEW_FIELD_GROUP_GQL_FIELDS,
|
||||
VIEW_GQL_FIELDS,
|
||||
} from 'test/integration/constants/view-gql-fields.constants';
|
||||
import { findCoreViewFieldGroups } from 'test/integration/metadata/suites/view-field-group/utils/find-core-view-field-groups.util';
|
||||
import { upsertFieldsWidget } from 'test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget.util';
|
||||
import { findCoreViewFields } from 'test/integration/metadata/suites/view-field/utils/find-core-view-fields.util';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
type FieldsWidgetTestSetup = {
|
||||
widgetId: string;
|
||||
viewId: string;
|
||||
viewFields: Array<{
|
||||
id: string;
|
||||
fieldMetadataId: string;
|
||||
position: number;
|
||||
isVisible: boolean;
|
||||
viewFieldGroupId: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
const VIEW_WITH_FIELDS_AND_GROUPS_GQL_FIELDS = `
|
||||
${VIEW_GQL_FIELDS}
|
||||
viewFields {
|
||||
${VIEW_FIELD_GQL_FIELDS}
|
||||
}
|
||||
viewFieldGroups {
|
||||
${VIEW_FIELD_GROUP_GQL_FIELDS}
|
||||
}
|
||||
`;
|
||||
|
||||
const fetchFieldsWidgetTestSetup = async (): Promise<FieldsWidgetTestSetup> => {
|
||||
const widgets = await global.testDataSource.query(
|
||||
`SELECT id, configuration->>'viewId' AS "viewId"
|
||||
FROM core."pageLayoutWidget"
|
||||
WHERE type = 'FIELDS'
|
||||
AND "deletedAt" IS NULL
|
||||
LIMIT 1`,
|
||||
);
|
||||
|
||||
expect(widgets.length).toBeGreaterThan(0);
|
||||
|
||||
const { id: widgetId, viewId } = widgets[0];
|
||||
|
||||
expect(widgetId).toBeDefined();
|
||||
expect(viewId).toBeDefined();
|
||||
|
||||
const { data } = await findCoreViewFields({
|
||||
viewId,
|
||||
gqlFields: 'id fieldMetadataId position isVisible viewFieldGroupId',
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const viewFields = data.getCoreViewFields;
|
||||
|
||||
return { widgetId, viewId, viewFields };
|
||||
};
|
||||
|
||||
describe('upsertFieldsWidget', () => {
|
||||
let testSetup: FieldsWidgetTestSetup;
|
||||
|
||||
beforeAll(async () => {
|
||||
testSetup = await fetchFieldsWidgetTestSetup();
|
||||
});
|
||||
|
||||
describe('with groups input', () => {
|
||||
it('should upsert fields widget with a new group and return a view', async () => {
|
||||
const newGroupId = uuidv4();
|
||||
const targetFields = testSetup.viewFields.slice(0, 2);
|
||||
|
||||
const { data, errors } = await upsertFieldsWidget({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
widgetId: testSetup.widgetId,
|
||||
groups: [
|
||||
{
|
||||
id: newGroupId,
|
||||
name: 'Test Group',
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
fields: targetFields.map((f, index) => ({
|
||||
viewFieldId: f.id,
|
||||
isVisible: true,
|
||||
position: index,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
gqlFields: VIEW_WITH_FIELDS_AND_GROUPS_GQL_FIELDS,
|
||||
});
|
||||
|
||||
expect(errors).toBeUndefined();
|
||||
expect(data.upsertFieldsWidget).toBeDefined();
|
||||
expect(data.upsertFieldsWidget.id).toBeDefined();
|
||||
expect(data.upsertFieldsWidget.name).toBeDefined();
|
||||
|
||||
const { data: groupsData } = await findCoreViewFieldGroups({
|
||||
viewId: testSetup.viewId,
|
||||
gqlFields: 'id name',
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const createdGroup = groupsData.getCoreViewFieldGroups.find(
|
||||
(g: { id: string }) => g.id === newGroupId,
|
||||
);
|
||||
|
||||
expect(createdGroup).toBeDefined();
|
||||
expect(createdGroup!.name).toBe('Test Group');
|
||||
});
|
||||
|
||||
it('should hard-delete groups not included in the input', async () => {
|
||||
// First, create a group via upsert
|
||||
const groupToDeleteId = uuidv4();
|
||||
const groupToKeepId = uuidv4();
|
||||
|
||||
const twoFields = testSetup.viewFields.slice(0, 2);
|
||||
|
||||
await upsertFieldsWidget({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
widgetId: testSetup.widgetId,
|
||||
groups: [
|
||||
{
|
||||
id: groupToDeleteId,
|
||||
name: 'Group To Delete',
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
fields: [
|
||||
{
|
||||
viewFieldId: twoFields[0].id,
|
||||
isVisible: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: groupToKeepId,
|
||||
name: 'Group To Keep',
|
||||
position: 1,
|
||||
isVisible: true,
|
||||
fields: [
|
||||
{
|
||||
viewFieldId: twoFields[1].id,
|
||||
isVisible: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Now upsert again without the first group
|
||||
await upsertFieldsWidget({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
widgetId: testSetup.widgetId,
|
||||
groups: [
|
||||
{
|
||||
id: groupToKeepId,
|
||||
name: 'Group To Keep',
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
fields: [
|
||||
{
|
||||
viewFieldId: twoFields[1].id,
|
||||
isVisible: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Verify the omitted group was hard-deleted (row should be completely gone)
|
||||
const deletedGroup = await global.testDataSource.query(
|
||||
`SELECT id FROM core."viewFieldGroup"
|
||||
WHERE id = $1`,
|
||||
[groupToDeleteId],
|
||||
);
|
||||
|
||||
expect(deletedGroup.length).toBe(0);
|
||||
|
||||
// Verify the kept group is still active
|
||||
const { data: keptGroupData } = await findCoreViewFieldGroups({
|
||||
viewId: testSetup.viewId,
|
||||
gqlFields: 'id',
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const keptGroup = keptGroupData.getCoreViewFieldGroups.find(
|
||||
(g: { id: string }) => g.id === groupToKeepId,
|
||||
);
|
||||
|
||||
expect(keptGroup).toBeDefined();
|
||||
});
|
||||
|
||||
it('should update view field positions and visibility within groups', async () => {
|
||||
const groupId = uuidv4();
|
||||
const targetField = testSetup.viewFields[0];
|
||||
|
||||
await upsertFieldsWidget({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
widgetId: testSetup.widgetId,
|
||||
groups: [
|
||||
{
|
||||
id: groupId,
|
||||
name: 'Position Test Group',
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
fields: [
|
||||
{
|
||||
viewFieldId: targetField.id,
|
||||
isVisible: false,
|
||||
position: 42,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Verify the view field was updated
|
||||
const { data: fieldsData } = await findCoreViewFields({
|
||||
viewId: testSetup.viewId,
|
||||
gqlFields: 'id isVisible position viewFieldGroupId',
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const updatedField = fieldsData.getCoreViewFields.find(
|
||||
(f: { id: string }) => f.id === targetField.id,
|
||||
);
|
||||
|
||||
expect(updatedField).toBeDefined();
|
||||
expect(updatedField!.isVisible).toBe(false);
|
||||
expect(updatedField!.position).toBe(42);
|
||||
expect(updatedField!.viewFieldGroupId).toBe(groupId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with fields input (ungrouped)', () => {
|
||||
it('should upsert fields widget with flat fields and return a view', async () => {
|
||||
const targetFields = testSetup.viewFields.slice(0, 3);
|
||||
|
||||
const { data, errors } = await upsertFieldsWidget({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
widgetId: testSetup.widgetId,
|
||||
fields: targetFields.map((f, index) => ({
|
||||
viewFieldId: f.id,
|
||||
isVisible: true,
|
||||
position: index,
|
||||
})),
|
||||
},
|
||||
gqlFields: VIEW_WITH_FIELDS_AND_GROUPS_GQL_FIELDS,
|
||||
});
|
||||
|
||||
expect(errors).toBeUndefined();
|
||||
expect(data.upsertFieldsWidget).toBeDefined();
|
||||
expect(data.upsertFieldsWidget.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('should hard-delete all existing groups when using flat fields', async () => {
|
||||
// First create a group via upsert
|
||||
const groupId = uuidv4();
|
||||
const targetField = testSetup.viewFields[0];
|
||||
|
||||
await upsertFieldsWidget({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
widgetId: testSetup.widgetId,
|
||||
groups: [
|
||||
{
|
||||
id: groupId,
|
||||
name: 'Group To Be Deleted',
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
fields: [
|
||||
{
|
||||
viewFieldId: targetField.id,
|
||||
isVisible: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Verify group exists
|
||||
const { data: groupBeforeData } = await findCoreViewFieldGroups({
|
||||
viewId: testSetup.viewId,
|
||||
gqlFields: 'id',
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const groupBefore = groupBeforeData.getCoreViewFieldGroups.find(
|
||||
(g: { id: string }) => g.id === groupId,
|
||||
);
|
||||
|
||||
expect(groupBefore).toBeDefined();
|
||||
|
||||
// Now upsert with flat fields
|
||||
await upsertFieldsWidget({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
widgetId: testSetup.widgetId,
|
||||
fields: [
|
||||
{
|
||||
viewFieldId: targetField.id,
|
||||
isVisible: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Verify all groups are soft-deleted
|
||||
const { data: activeGroupsData } = await findCoreViewFieldGroups({
|
||||
viewId: testSetup.viewId,
|
||||
gqlFields: 'id',
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
expect(activeGroupsData.getCoreViewFieldGroups.length).toBe(0);
|
||||
|
||||
// Verify the field's viewFieldGroupId is null
|
||||
const { data: updatedFieldData } = await findCoreViewFields({
|
||||
viewId: testSetup.viewId,
|
||||
gqlFields: 'id viewFieldGroupId',
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const updatedField = updatedFieldData.getCoreViewFields.find(
|
||||
(f: { id: string }) => f.id === targetField.id,
|
||||
);
|
||||
|
||||
expect(updatedField).toBeDefined();
|
||||
expect(updatedField!.viewFieldGroupId).toBeNull();
|
||||
});
|
||||
|
||||
it('should update field positions and visibility without groups', async () => {
|
||||
const targetField = testSetup.viewFields[0];
|
||||
const groupId = uuidv4();
|
||||
|
||||
// First assign the field to a group so it has a non-null viewFieldGroupId
|
||||
await upsertFieldsWidget({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
widgetId: testSetup.widgetId,
|
||||
groups: [
|
||||
{
|
||||
id: groupId,
|
||||
name: 'Temporary Group',
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
fields: [
|
||||
{
|
||||
viewFieldId: targetField.id,
|
||||
isVisible: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Now switch to flat fields with different position and visibility
|
||||
await upsertFieldsWidget({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
widgetId: testSetup.widgetId,
|
||||
fields: [
|
||||
{
|
||||
viewFieldId: targetField.id,
|
||||
isVisible: false,
|
||||
position: 99,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { data: updatedFieldData } = await findCoreViewFields({
|
||||
viewId: testSetup.viewId,
|
||||
gqlFields: 'id isVisible position viewFieldGroupId',
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const updatedField = updatedFieldData.getCoreViewFields.find(
|
||||
(f: { id: string }) => f.id === targetField.id,
|
||||
);
|
||||
|
||||
expect(updatedField).toBeDefined();
|
||||
expect(updatedField!.isVisible).toBe(false);
|
||||
expect(updatedField!.position).toBe(99);
|
||||
expect(updatedField!.viewFieldGroupId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('should fail when both groups and fields are provided', async () => {
|
||||
const targetField = testSetup.viewFields[0];
|
||||
|
||||
const { errors } = await upsertFieldsWidget({
|
||||
expectToFail: true,
|
||||
input: {
|
||||
widgetId: testSetup.widgetId,
|
||||
groups: [
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: 'Test',
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
fields: [
|
||||
{
|
||||
viewFieldId: targetField.id,
|
||||
isVisible: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
viewFieldId: targetField.id,
|
||||
isVisible: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(errors).toBeDefined();
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should fail when neither groups nor fields are provided', async () => {
|
||||
const { errors } = await upsertFieldsWidget({
|
||||
expectToFail: true,
|
||||
input: {
|
||||
widgetId: testSetup.widgetId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(errors).toBeDefined();
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should fail when widget id does not exist', async () => {
|
||||
const { errors } = await upsertFieldsWidget({
|
||||
expectToFail: true,
|
||||
input: {
|
||||
widgetId: uuidv4(),
|
||||
fields: [
|
||||
{
|
||||
viewFieldId: testSetup.viewFields[0].id,
|
||||
isVisible: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(errors).toBeDefined();
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('return type', () => {
|
||||
it('should return a view with the expected fields', async () => {
|
||||
const targetField = testSetup.viewFields[0];
|
||||
|
||||
const { data } = await upsertFieldsWidget({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
widgetId: testSetup.widgetId,
|
||||
fields: [
|
||||
{
|
||||
viewFieldId: targetField.id,
|
||||
isVisible: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
gqlFields: VIEW_WITH_FIELDS_AND_GROUPS_GQL_FIELDS,
|
||||
});
|
||||
|
||||
const view = data.upsertFieldsWidget;
|
||||
|
||||
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.viewFieldGroups)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { VIEW_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants';
|
||||
|
||||
import { type UpsertFieldsWidgetInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input';
|
||||
|
||||
export const upsertFieldsWidgetQueryFactory = ({
|
||||
gqlFields = VIEW_GQL_FIELDS,
|
||||
input,
|
||||
}: {
|
||||
gqlFields?: string;
|
||||
input: UpsertFieldsWidgetInput;
|
||||
}) => ({
|
||||
query: gql`
|
||||
mutation UpsertFieldsWidget($input: UpsertFieldsWidgetInput!) {
|
||||
upsertFieldsWidget(input: $input) {
|
||||
${gqlFields}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
import { upsertFieldsWidgetQueryFactory } from 'test/integration/metadata/suites/view-field-group/utils/upsert-fields-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 UpsertFieldsWidgetInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input';
|
||||
import { type ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
|
||||
|
||||
export const upsertFieldsWidget = async ({
|
||||
input,
|
||||
gqlFields,
|
||||
expectToFail,
|
||||
}: {
|
||||
input: UpsertFieldsWidgetInput;
|
||||
gqlFields?: string;
|
||||
expectToFail?: boolean | null;
|
||||
}): CommonResponseBody<{
|
||||
upsertFieldsWidget: ViewDTO;
|
||||
}> => {
|
||||
const graphqlOperation = upsertFieldsWidgetQueryFactory({
|
||||
input,
|
||||
gqlFields,
|
||||
});
|
||||
|
||||
const response = await makeMetadataAPIRequest(graphqlOperation);
|
||||
|
||||
if (expectToFail === true) {
|
||||
warnIfNoErrorButExpectedToFail({
|
||||
response,
|
||||
errorMessage: 'Upsert fields widget should have failed but did not',
|
||||
});
|
||||
}
|
||||
|
||||
if (expectToFail === false) {
|
||||
warnIfErrorButNotExpectedToFail({
|
||||
response,
|
||||
errorMessage: 'Upsert fields widget has failed but should not',
|
||||
});
|
||||
}
|
||||
|
||||
return { data: response.body.data, errors: response.body.errors };
|
||||
};
|
||||
Reference in New Issue
Block a user